@stainless-code/persist
Version:
Hydration-aware persistence middleware for reactive stores (storage × codec seams, TanStack Store adapters, React hydration hook)
422 lines (421 loc) • 13.5 kB
JavaScript
//#region src/persist-core.ts
/**
* Build a `PersistRegistry`. `clearAll` uses `allSettled` +
* rethrow-first-rejection semantics: one throwing backend can't skip the
* remaining clears, and the caller still sees the first failure.
*/
function createPersistRegistry() {
const clears = /* @__PURE__ */ new Set();
return {
register(clearStorage) {
clears.add(clearStorage);
return () => clears.delete(clearStorage);
},
async clearAll() {
const rejected = (await Promise.allSettled([...clears].map((clear) => Promise.resolve().then(clear)))).find((r) => r.status === "rejected");
if (rejected) throw rejected.reason;
}
};
}
/**
* JSON codec — no `Set` / `Map` / `Date` round-trip. Accepts the standard
* `JSON.parse` reviver / `JSON.stringify` replacer.
*/
const jsonCodec = (options) => ({
encode: (value) => JSON.stringify(value, options?.replacer),
decode: (raw) => JSON.parse(raw, options?.reviver)
});
/**
* Identity codec for structured-clone backends (`TRaw = StorageValue<S>`):
* the backend stores the envelope natively — IndexedDB's structured-clone
* algorithm round-trips `Set` / `Map` / `Date` without any serialization, so
* string codecs (JSON / seroval) become pure overhead there. Never use with
* string-only backends (`localStorage`).
*/
const identityCodec = () => ({
encode: (value) => value,
decode: (raw) => raw
});
/**
* The one shared `PersistStorage` plumbing: getStorage try-guard (returns
* `undefined` when the backend is unavailable), sync-vs-Promise branching on
* `getItem`, and unified corrupt-payload handling (decode throw → `null` +
* optional key removal via `clearCorruptOnFailure`). Pass any
* {@link StorageCodec} to swap the serialization format without
* reimplementing this layer.
*
* @example
* ```ts
* // custom codec (superjson, devalue, compression, encryption, …)
* const storage = createStorage<Prefs>(
* () => localStorage,
* { encode: superjson.stringify, decode: superjson.parse },
* { clearCorruptOnFailure: true },
* );
* ```
*/
function createStorage(getStorage, codec, options) {
let storage;
try {
storage = getStorage();
} catch {
return;
}
if (typeof storage?.getItem !== "function" || typeof storage?.setItem !== "function" || typeof storage?.removeItem !== "function") return;
const parseStored = (name, raw) => {
if (raw === null) return null;
try {
return codec.decode(raw);
} catch {
if (options?.clearCorruptOnFailure) try {
const removal = storage.removeItem(name);
if (removal instanceof Promise) removal.catch(() => {});
} catch {}
return null;
}
};
return {
getItem(name) {
const raw = storage.getItem(name) ?? null;
if (raw instanceof Promise) return raw.then((value) => parseStored(name, value ?? null));
return parseStored(name, raw);
},
setItem(name, value) {
return storage.setItem(name, codec.encode(value));
},
removeItem(name) {
return storage.removeItem(name);
},
raw: storage
};
}
/**
* Build a JSON-encoded `PersistStorage` (no `Set`/`Map`/`Date` round-trip).
* The backend is the argument — `() => localStorage`, `() => sessionStorage`,
* or any custom `StateStorage`. For corrupt-payload self-heal
* (`clearCorruptOnFailure`), use
* `createStorage(getStorage, jsonCodec(options), { clearCorruptOnFailure })`
* directly — this factory's options are codec-only (reviver/replacer).
*/
function createJSONStorage(getStorage, options) {
return createStorage(getStorage, jsonCodec(options));
}
/**
* Default merge: shallow-spread persisted over current. Single definition
* referenced by both the options initializer and the hydrate fallback.
*/
const shallowSpreadMerge = (persistedState, currentState) => ({
...currentState,
...persistedState
});
/**
* Spread `patch` into `base` skipping explicit `undefined` values, so a caller
* passing `{ merge: undefined }` to `setOptions` can't clobber an existing
* (or default) value — only real values win.
*/
function mergeDefined(base, patch) {
const result = { ...base };
for (const key of Object.keys(patch)) if (patch[key] !== void 0) result[key] = patch[key];
return result;
}
function createNoopApi(options) {
return {
setOptions() {},
clearStorage() {},
rehydrate() {},
hasHydrated: () => true,
onHydrate: () => () => {},
onFinishHydration: () => () => {},
getOptions: () => options,
destroy() {}
};
}
function resolveDefaultStorage(options) {
if (options.storage) return options.storage;
if (typeof localStorage === "undefined") return;
return createJSONStorage(() => localStorage);
}
/**
* Attach persist to any `PersistableSource`: hydrate from storage on create,
* subscribe-write on every `setState`. Returns the lifecycle `PersistApi`.
* No-op `PersistApi` (always hydrated) when storage is unavailable (SSR / tests).
*/
function persistSource(source, baseOptions) {
let options = mergeDefined({
version: 0,
merge: shallowSpreadMerge
}, baseOptions);
let storage = resolveDefaultStorage(options);
const reportError = (error, phase) => {
if (options.onError) options.onError(error, {
name: options.name,
phase
});
else if (process.env.NODE_ENV !== "production") console.error(`[persistStore] ${phase} error for '${options.name}':`, error);
};
if (!storage) {
const message = `[persistStore] Unable to persist '${options.name}' — storage unavailable.`;
if (options.onError) options.onError(new Error(message), {
name: options.name,
phase: "hydrate"
});
else if (process.env.NODE_ENV !== "production") console.warn(message);
return createNoopApi(options);
}
let hasHydratedFlag = false;
let hydrationVersion = 0;
const hydrationListeners = /* @__PURE__ */ new Set();
const finishHydrationListeners = /* @__PURE__ */ new Set();
const getPersistedSlice = () => {
return (options.partialize ?? ((s) => s))(source.getState());
};
let writeGeneration = 0;
const buildEnvelope = (state) => ({
state,
version: options.version,
timestamp: Date.now(),
...options.buster !== void 0 ? { buster: options.buster } : {}
});
const attemptWrite = (state) => storage.setItem(options.name, buildEnvelope(state));
const retryLoop = async (state, firstError, generation) => {
let current = state;
let error = firstError;
let errorCount = 0;
while (true) {
if (generation !== writeGeneration) return;
const retryWrite = options.retryWrite;
if (!retryWrite) {
reportError(error, "write");
return;
}
errorCount++;
const next = await retryWrite({
state: current,
error,
errorCount
});
if (generation !== writeGeneration) return;
if (next === void 0) {
reportError(error, "write");
return;
}
current = next;
try {
await attemptWrite(current);
return;
} catch (nextError) {
error = nextError;
}
}
};
const writeGuarded = (state, generation) => {
try {
const result = attemptWrite(state);
if (result instanceof Promise) return result.catch((error) => {
if (!options.retryWrite) throw error;
return retryLoop(state, error, generation);
});
} catch (error) {
if (!options.retryWrite) throw error;
return retryLoop(state, error, generation);
}
};
const writeToStorage = () => {
const state = getPersistedSlice();
const generation = ++writeGeneration;
if (options.skipPersist?.(state)) return storage.removeItem(options.name);
return writeGuarded(state, generation);
};
const writeSafe = () => {
try {
const result = writeToStorage();
if (result instanceof Promise) return result.catch((error) => reportError(error, "write"));
} catch (error) {
reportError(error, "write");
}
};
let throttleTimer;
const cancelPendingWrite = () => {
if (throttleTimer === void 0) return;
clearTimeout(throttleTimer);
throttleTimer = void 0;
};
const flushPendingWrite = (mode) => {
if (throttleTimer === void 0) return;
cancelPendingWrite();
if (mode === "noRetry") {
try {
const result = attemptWrite(getPersistedSlice());
if (result instanceof Promise) result.catch((error) => reportError(error, "write"));
} catch (error) {
reportError(error, "write");
}
return;
}
writeSafe();
};
const scheduleWrite = () => {
if (options.throttleMs === void 0) {
writeSafe();
return;
}
if (options.skipPersist?.(getPersistedSlice())) {
cancelPendingWrite();
writeSafe();
return;
}
writeGeneration++;
if (throttleTimer !== void 0) return;
throttleTimer = setTimeout(() => {
throttleTimer = void 0;
if (!hasHydratedFlag) return;
writeSafe();
}, options.throttleMs);
};
const sourceSubscription = source.subscribe(() => {
if (!hasHydratedFlag) return;
scheduleWrite();
});
let crossTabListener;
let crossTabTarget;
if (options.crossTab) {
const target = options.crossTabEventTarget ?? (typeof window !== "undefined" && typeof window.addEventListener === "function" ? window : void 0);
if (target) {
crossTabListener = (event) => {
if (event.key !== options.name) return;
const raw = storage?.raw;
if (raw !== void 0 && event.storageArea != null && event.storageArea !== raw) return;
if (event.newValue === null && options.onCrossTabRemove) {
try {
options.onCrossTabRemove();
} catch (error) {
reportError(error, "crossTab");
}
return;
}
api.rehydrate();
};
target.addEventListener("storage", crossTabListener);
crossTabTarget = target;
}
}
const isStoredExpired = (stored) => {
if (options.maxAge !== void 0 && Date.now() - (stored.timestamp ?? 0) > options.maxAge) return true;
return options.buster !== void 0 && stored.buster !== options.buster;
};
const resolveStoredState = async (stored) => {
if (typeof stored.version === "number" && stored.version !== options.version) {
if (options.migrate) return {
state: await options.migrate(stored.state, stored.version),
migrated: true
};
reportError(/* @__PURE__ */ new Error(`[persistStore] State loaded from storage couldn't be migrated — no migrate function provided`), "migrate");
return {
state: void 0,
migrated: false
};
}
return {
state: stored.state,
migrated: false
};
};
const beginHydration = () => {
for (const cb of hydrationListeners) cb(source.getState());
return options.onRehydrateStorage?.(source.getState()) || void 0;
};
const partitionExpired = (stored) => {
const expired = stored != null && isStoredExpired(stored);
return {
expired,
live: stored != null && !expired ? stored : null
};
};
const applyResolvedState = (migratedState) => {
if (migratedState === void 0) return;
const merge = options.merge ?? shallowSpreadMerge;
source.setState(() => merge(migratedState, source.getState()));
};
const settleHydration = (postRehydrationCallback, failure) => {
hasHydratedFlag = true;
if (failure) postRehydrationCallback?.(void 0, failure.error);
else postRehydrationCallback?.(source.getState(), void 0);
for (const cb of finishHydrationListeners) cb(source.getState());
};
const hydrate = async () => {
if (!storage) return;
const currentVersion = ++hydrationVersion;
hasHydratedFlag = false;
const hadPendingWrite = throttleTimer !== void 0;
cancelPendingWrite();
writeGeneration++;
let postRehydrationCallback;
let errorPhase = "hydrate";
try {
postRehydrationCallback = beginHydration();
const stored = await storage.getItem(options.name);
if (currentVersion !== hydrationVersion) return;
const { expired, live } = partitionExpired(stored);
if (expired) {
await storage.removeItem(options.name);
if (currentVersion !== hydrationVersion) return;
}
errorPhase = "migrate";
const resolved = live ? await resolveStoredState(live) : {
state: void 0,
migrated: false
};
errorPhase = "hydrate";
if (currentVersion !== hydrationVersion) return;
applyResolvedState(resolved.state);
if (resolved.migrated) await writeSafe();
if (currentVersion !== hydrationVersion) return;
settleHydration(postRehydrationCallback);
if (hadPendingWrite) scheduleWrite();
} catch (error) {
if (currentVersion !== hydrationVersion) return;
reportError(error, errorPhase);
if (hasHydratedFlag) return;
try {
settleHydration(postRehydrationCallback, { error });
} catch (settleError) {
reportError(settleError, "hydrate");
} finally {
if (hadPendingWrite) scheduleWrite();
}
}
};
if (!options.skipHydration) hydrate();
const unregisterClear = options.registry?.register(() => api.clearStorage());
const api = {
setOptions(newOptions) {
options = mergeDefined(options, newOptions);
if (newOptions.storage) storage = newOptions.storage;
},
clearStorage() {
return storage?.removeItem(options.name);
},
rehydrate: hydrate,
hasHydrated: () => hasHydratedFlag,
onHydrate(fn) {
hydrationListeners.add(fn);
return () => hydrationListeners.delete(fn);
},
onFinishHydration(fn) {
finishHydrationListeners.add(fn);
return () => finishHydrationListeners.delete(fn);
},
getOptions: () => options,
destroy() {
sourceSubscription.unsubscribe();
unregisterClear?.();
if (crossTabListener && crossTabTarget) crossTabTarget.removeEventListener("storage", crossTabListener);
flushPendingWrite("noRetry");
hydrationVersion++;
writeGeneration++;
}
};
return api;
}
//#endregion
export { jsonCodec as a, identityCodec as i, createPersistRegistry as n, persistSource as o, createStorage as r, createJSONStorage as t };