@epic-web/cachified
Version:
neat wrapper for various caches
656 lines (644 loc) • 19.3 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/common.ts
var HANDLE = /* @__PURE__ */ Symbol();
var MIGRATED = /* @__PURE__ */ Symbol();
function validateWithSchema(checkValue2) {
return async (value, migrate) => {
let validatedValue;
if ("~standard" in checkValue2) {
let result = checkValue2["~standard"].validate(value);
if (result instanceof Promise) result = await result;
if (result.issues) {
throw result.issues;
}
validatedValue = result.value;
} else {
validatedValue = await checkValue2.parseAsync(value);
}
return migrate(validatedValue, false);
};
}
function createContext({ fallbackToCache, checkValue: checkValue2, ...options }, reporter) {
const ttl = options.ttl ?? Infinity;
const staleWhileRevalidate2 = options.swr ?? options.staleWhileRevalidate ?? 0;
const checkValueCompat = typeof checkValue2 === "function" ? checkValue2 : typeof checkValue2 === "object" ? validateWithSchema(checkValue2) : () => true;
const contextWithoutReport = {
checkValue: checkValueCompat,
ttl,
staleWhileRevalidate: staleWhileRevalidate2,
fallbackToCache: fallbackToCache === false ? 0 : fallbackToCache === true || fallbackToCache === void 0 ? Infinity : fallbackToCache,
staleRefreshTimeout: 0,
forceFresh: false,
...options,
metadata: createCacheMetaData({
ttl,
swr: staleWhileRevalidate2,
traceId: options.traceId
}),
waitUntil: options.waitUntil ?? (() => {
})
};
const report = reporter?.(contextWithoutReport) || (() => {
});
return {
...contextWithoutReport,
report
};
}
function staleWhileRevalidate(metadata) {
return (typeof metadata.swr === "undefined" ? metadata.swv : metadata.swr) || null;
}
function totalTtl(metadata) {
if (!metadata) {
return 0;
}
if (metadata.ttl === null) {
return Infinity;
}
return (metadata.ttl || 0) + (staleWhileRevalidate(metadata) || 0);
}
function createCacheMetaData({
ttl = null,
swr = 0,
createdTime = Date.now(),
traceId
} = {}) {
return {
ttl: ttl === Infinity ? null : ttl,
swr: swr === Infinity ? null : swr,
createdTime,
...traceId ? { traceId } : {}
};
}
function createCacheEntry(value, metadata) {
return {
value,
metadata: createCacheMetaData(metadata)
};
}
// src/reporter.ts
var defaultFormatDuration = (ms) => `${Math.round(ms)}ms`;
function formatCacheTime(metadata, formatDuration) {
const swr = staleWhileRevalidate(metadata);
if (metadata.ttl == null || swr == null) {
return `forever${metadata.ttl != null ? ` (revalidation after ${formatDuration(metadata.ttl)})` : ""}`;
}
return `${formatDuration(metadata.ttl)} + ${formatDuration(swr)} stale`;
}
function verboseReporter({
formatDuration = defaultFormatDuration,
logger = console,
performance = globalThis.performance || Date
} = {}) {
return ({ key, fallbackToCache, forceFresh, metadata, cache }) => {
const cacheName = cache.name || cache.toString().toString().replace(/^\[object (.*?)]$/, "$1");
let cached;
let freshValue;
let getFreshValueStartTs;
let refreshValueStartTS;
return (event) => {
switch (event.name) {
case "getCachedValueRead":
cached = event.entry;
break;
case "checkCachedValueError":
logger.warn(
`check failed for cached value of ${key}
Reason: ${event.reason}.
Deleting the cache key and trying to get a fresh value.`,
cached
);
break;
case "getCachedValueError":
logger.error(
`error with cache at ${key}. Deleting the cache key and trying to get a fresh value.`,
event.error
);
break;
case "getFreshValueError":
logger.error(
`getting a fresh value for ${key} failed`,
{ fallbackToCache, forceFresh },
event.error
);
break;
case "getFreshValueStart":
getFreshValueStartTs = performance.now();
break;
case "writeFreshValueSuccess": {
const totalTime = performance.now() - getFreshValueStartTs;
if (event.written) {
logger.log(
`Updated the cache value for ${key}.`,
`Getting a fresh value for this took ${formatDuration(
totalTime
)}.`,
`Caching for ${formatCacheTime(
metadata,
formatDuration
)} in ${cacheName}.`
);
} else {
logger.log(
`Not updating the cache value for ${key}.`,
`Getting a fresh value for this took ${formatDuration(
totalTime
)}.`,
`Thereby exceeding caching time of ${formatCacheTime(
metadata,
formatDuration
)}`
);
}
break;
}
case "writeFreshValueError":
logger.error(`error setting cache: ${key}`, event.error);
break;
case "getFreshValueSuccess":
freshValue = event.value;
break;
case "checkFreshValueError":
logger.error(
`check failed for fresh value of ${key}
Reason: ${event.reason}.`,
freshValue
);
break;
case "refreshValueStart":
refreshValueStartTS = performance.now();
break;
case "refreshValueSuccess":
logger.log(
`Background refresh for ${key} successful.`,
`Getting a fresh value for this took ${formatDuration(
performance.now() - refreshValueStartTS
)}.`,
`Caching for ${formatCacheTime(
metadata,
formatDuration
)} in ${cacheName}.`
);
break;
case "refreshValueError":
logger.error(`Background refresh for ${key} failed.`, event.error);
break;
}
};
};
}
function mergeReporters(...reporters) {
return (context) => {
const reporter = reporters.map((r) => r?.(context));
return (event) => {
reporter.forEach((r) => r?.(event));
};
};
}
// src/createBatch.ts
function createBatch(getFreshValues, autoSubmit = true) {
const requests = [];
let count = 0;
let submitted = false;
const submission = new Deferred();
const checkSubmission = () => {
if (submitted) {
throw new Error("Can not add to batch after submission");
}
};
const submit = async () => {
if (count !== 0) {
autoSubmit = true;
return submission.promise;
}
checkSubmission();
submitted = true;
if (requests.length === 0) {
submission.resolve();
return;
}
try {
const results = await Promise.resolve(
getFreshValues(
requests.map(([param]) => param),
requests.map((args) => args[3])
)
);
if (results.length !== requests.length) {
throw new Error(
`Batch loader must return an array with the same length as the input array (expected ${requests.length}, got ${results.length})`
);
}
results.forEach((value, index) => requests[index][1](value));
submission.resolve();
} catch (err) {
requests.forEach(([_, __, rej]) => rej(err));
submission.resolve();
}
};
const trySubmitting = () => {
count--;
if (autoSubmit === false) {
return;
}
submit();
};
return {
...autoSubmit === false ? { submit } : {},
add(param, onValue) {
checkSubmission();
count++;
let handled = false;
return Object.assign(
(context) => {
return new Promise((res, rej) => {
requests.push([
param,
(value) => {
onValue?.({ ...context, value });
res(value);
},
rej,
context.metadata
]);
if (!handled) {
handled = true;
trySubmitting();
}
});
},
{
[HANDLE]: () => {
if (!handled) {
handled = true;
trySubmitting();
}
}
}
);
}
};
}
var Deferred = class {
constructor() {
__publicField(this, "promise");
// @ts-ignore
__publicField(this, "resolve");
// @ts-ignore
__publicField(this, "reject");
this.promise = new Promise((res, rej) => {
this.resolve = res;
this.reject = rej;
});
}
};
// src/assertCacheEntry.ts
function logKey(key) {
return key ? `for ${key} ` : "";
}
function assertCacheEntry(entry, key) {
if (!isRecord(entry)) {
throw new Error(
`Cache entry ${logKey(
key
)}is not a cache entry object, it's a ${typeof entry}`
);
}
if (!isRecord(entry.metadata) || typeof entry.metadata.createdTime !== "number" || entry.metadata.ttl != null && typeof entry.metadata.ttl !== "number" || entry.metadata.swr != null && typeof entry.metadata.swr !== "number") {
throw new Error(
`Cache entry ${logKey(key)}does not have valid metadata property`
);
}
if (!("value" in entry)) {
throw new Error(
`Cache entry for ${logKey(key)}does not have a value property`
);
}
}
function isRecord(entry) {
return typeof entry === "object" && entry !== null && !Array.isArray(entry);
}
// src/isExpired.ts
function isExpired(metadata) {
if (metadata.ttl === null) {
return false;
}
const validUntil = metadata.createdTime + (metadata.ttl || 0);
const staleUntil = validUntil + (staleWhileRevalidate(metadata) || 0);
const now = Date.now();
if (now <= validUntil) {
return false;
}
if (now <= staleUntil) {
return "stale";
}
return true;
}
function shouldRefresh(metadata) {
const expired = isExpired(metadata);
if (expired === true) {
return "now";
}
return expired;
}
// src/checkValue.ts
async function checkValue(context, value) {
try {
const checkResponse = await context.checkValue(
value,
(value2, updateCache = true) => ({
[MIGRATED]: updateCache,
value: value2
})
);
if (typeof checkResponse === "string") {
return { success: false, reason: checkResponse };
}
if (checkResponse == null || checkResponse === true) {
return {
success: true,
value,
migrated: false
};
}
if (checkResponse && typeof checkResponse[MIGRATED] === "boolean") {
return {
success: true,
migrated: checkResponse[MIGRATED],
value: checkResponse.value
};
}
return { success: false, reason: "unknown" };
} catch (err) {
return {
success: false,
reason: err
};
}
}
// src/getCachedValue.ts
var CACHE_EMPTY = /* @__PURE__ */ Symbol();
async function getCacheEntry({ key, cache }, report) {
report({ name: "getCachedValueStart" });
const cached = await cache.get(key);
report({ name: "getCachedValueRead", entry: cached });
if (cached) {
assertCacheEntry(cached, key);
return cached;
}
return CACHE_EMPTY;
}
async function getCachedValue(context, report, hasPendingValue) {
const {
key,
cache,
staleWhileRevalidate: staleWhileRevalidate2,
staleRefreshTimeout,
metadata,
getFreshValue: getFreshValue2
} = context;
try {
const cached = await getCacheEntry(context, report);
if (cached === CACHE_EMPTY) {
report({ name: "getCachedValueEmpty" });
return CACHE_EMPTY;
}
const expired = isExpired(cached.metadata);
const staleRefresh = expired === "stale" || expired === true && staleWhileRevalidate2 === Infinity;
if (expired === true) {
report({ name: "getCachedValueOutdated", ...cached });
}
if (staleRefresh) {
const staleRefreshOptions = {
...context,
async getFreshValue({ metadata: metadata2 }) {
await sleep(staleRefreshTimeout);
report({ name: "refreshValueStart" });
return getFreshValue2({
metadata: metadata2,
background: true
});
},
forceFresh: true,
fallbackToCache: false
};
staleRefreshOptions.getFreshValue[HANDLE] = context.getFreshValue[HANDLE];
context.waitUntil(
cachified(staleRefreshOptions).then((value) => {
report({ name: "refreshValueSuccess", value });
}).catch((error) => {
report({ name: "refreshValueError", error });
})
);
}
if (!expired || staleRefresh) {
const valueCheck = await checkValue(context, cached.value);
if (valueCheck.success) {
report({
name: "getCachedValueSuccess",
value: valueCheck.value,
migrated: valueCheck.migrated
});
if (!staleRefresh) {
getFreshValue2[HANDLE]?.();
}
if (valueCheck.migrated) {
context.waitUntil(
Promise.resolve().then(async () => {
try {
await sleep(0);
const cached2 = await context.cache.get(context.key);
if (cached2 && cached2.metadata.createdTime === metadata.createdTime && !hasPendingValue()) {
await context.cache.set(context.key, {
...cached2,
value: valueCheck.value
});
}
} catch (err) {
}
})
);
}
return valueCheck.value;
} else {
report({ name: "checkCachedValueErrorObj", reason: valueCheck.reason });
report({
name: "checkCachedValueError",
reason: valueCheck.reason instanceof Error ? valueCheck.reason.message : String(valueCheck.reason)
});
await cache.delete(key);
}
}
} catch (error) {
report({ name: "getCachedValueError", error });
await cache.delete(key);
}
return CACHE_EMPTY;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// src/getFreshValue.ts
async function getFreshValue(context, metadata, report) {
const { fallbackToCache, key, getFreshValue: getFreshValue2, forceFresh, cache } = context;
let value;
try {
report({ name: "getFreshValueStart" });
const freshValue = await getFreshValue2({
metadata: context.metadata,
background: false
});
value = freshValue;
report({ name: "getFreshValueSuccess", value: freshValue });
} catch (error) {
report({ name: "getFreshValueError", error });
if (forceFresh && fallbackToCache > 0) {
const entry = await getCacheEntry(context, report);
if (entry === CACHE_EMPTY || entry.metadata.createdTime + fallbackToCache < Date.now()) {
throw error;
}
value = entry.value;
report({ name: "getFreshValueCacheFallback", value });
} else {
throw error;
}
}
const valueCheck = await checkValue(context, value);
if (!valueCheck.success) {
report({ name: "checkFreshValueErrorObj", reason: valueCheck.reason });
report({
name: "checkFreshValueError",
reason: valueCheck.reason instanceof Error ? valueCheck.reason.message : String(valueCheck.reason)
});
throw new Error(`check failed for fresh value of ${key}`, {
cause: valueCheck.reason
});
}
try {
const write = isExpired(metadata) !== true;
if (write) {
await cache.set(key, createCacheEntry(value, metadata));
}
report({
name: "writeFreshValueSuccess",
metadata,
migrated: valueCheck.migrated,
written: write
});
} catch (error) {
report({ name: "writeFreshValueError", error });
}
return valueCheck.value;
}
// src/cachified.ts
var pendingValuesByCache = /* @__PURE__ */ new WeakMap();
function getPendingValuesCache(cache) {
if (!pendingValuesByCache.has(cache)) {
pendingValuesByCache.set(cache, /* @__PURE__ */ new Map());
}
return pendingValuesByCache.get(cache);
}
async function cachified(options, reporter) {
const context = createContext(options, reporter);
const { key, cache, forceFresh, report, metadata } = context;
const pendingValues = getPendingValuesCache(cache);
const hasPendingValue = () => {
return pendingValues.has(key);
};
const cachedValue = !forceFresh ? await getCachedValue(context, report, hasPendingValue) : CACHE_EMPTY;
if (cachedValue !== CACHE_EMPTY) {
report({ name: "done", value: cachedValue });
return cachedValue;
}
if (pendingValues.has(key)) {
const { value: pendingRefreshValue, metadata: metadata2 } = pendingValues.get(key);
if (!isExpired(metadata2)) {
context.getFreshValue[HANDLE]?.();
report({ name: "getFreshValueHookPending" });
const value2 = await pendingRefreshValue;
report({ name: "done", value: value2 });
return value2;
}
}
let resolveFromFuture;
const freshValue = Promise.race([
// try to get a fresh value
getFreshValue(context, metadata, report),
// or when a future call is faster, we'll take it's value
// this happens when getting value of first call takes longer then ttl + second response
new Promise((r) => {
resolveFromFuture = r;
})
]).finally(() => {
pendingValues.delete(key);
});
if (pendingValues.has(key)) {
const { resolve } = pendingValues.get(key);
freshValue.then((value2) => resolve(value2));
}
pendingValues.set(key, {
metadata,
value: freshValue,
// here we receive a fresh value from a future call
resolve: resolveFromFuture
});
const value = await freshValue;
report({ name: "done", value });
return value;
}
// src/softPurge.ts
async function softPurge({
cache,
key,
...swrOverwrites
}) {
const swrOverwrite = swrOverwrites.swr ?? swrOverwrites.staleWhileRevalidate;
const entry = await getCacheEntry({ cache, key }, () => {
});
if (entry === CACHE_EMPTY || isExpired(entry.metadata)) {
return;
}
const ttl = entry.metadata.ttl || Infinity;
const swr = staleWhileRevalidate(entry.metadata) || 0;
const lt = Date.now() - entry.metadata.createdTime;
await cache.set(
key,
createCacheEntry(entry.value, {
ttl: 0,
swr: swrOverwrite === void 0 ? ttl + swr : swrOverwrite + lt,
createdTime: entry.metadata.createdTime
})
);
}
// src/configure.ts
function configure(defaultOptions, defaultReporter) {
function configuredCachified(options, reporter) {
return cachified(
{
...defaultOptions,
...options
},
mergeReporters(defaultReporter, reporter)
);
}
return configuredCachified;
}
export {
assertCacheEntry,
cachified,
configure,
createBatch,
createCacheEntry,
cachified as default,
getPendingValuesCache,
isExpired,
mergeReporters,
shouldRefresh,
softPurge,
staleWhileRevalidate,
totalTtl,
verboseReporter
};
//# sourceMappingURL=index.mjs.map