@supabase-cache-helpers/postgrest-server
Version:
A collection of server-side caching utilities for working with Supabase.
374 lines (367 loc) • 8.81 kB
JavaScript
// src/stores/memory.ts
var MemoryStore = class {
state;
capacity;
name = "memory";
constructor(config) {
this.state = config.persistentMap;
this.capacity = config.capacity;
}
setMostRecentlyUsed(key, value) {
this.state.delete(key);
this.state.set(key, value);
}
async get(key) {
const value = this.state.get(key);
if (!value) {
return Promise.resolve(void 0);
}
if (value.expires <= Date.now()) {
await this.remove(key);
}
if (this.capacity) {
this.setMostRecentlyUsed(key, value);
}
return Promise.resolve(value.entry);
}
async set(key, entry) {
if (this.capacity) {
this.setMostRecentlyUsed(key, {
expires: entry.staleUntil,
entry
});
} else {
this.state.set(key, {
expires: entry.staleUntil,
entry
});
}
if (this.capacity && this.state.size > this.capacity) {
const oldestKey = this.state.keys().next().value;
if (oldestKey !== void 0) {
this.state.delete(oldestKey);
}
}
return Promise.resolve();
}
async remove(keys) {
const cacheKeys = Array.isArray(keys) ? keys : [keys];
for (const key of cacheKeys) {
this.state.delete(key);
}
return Promise.resolve();
}
async removeByPrefix(prefix) {
for (const key of this.state.keys()) {
if (key.startsWith(prefix)) {
this.state.delete(key);
}
}
}
};
// src/stores/redis.ts
var RedisStore = class {
redis;
name = "redis";
prefix;
constructor(config) {
this.redis = config.redis;
this.prefix = config.prefix || "sbch";
}
buildCacheKey(key) {
return [this.prefix, key].join("::");
}
async get(key) {
const res = await this.redis.get(this.buildCacheKey(key));
if (!res) return;
return JSON.parse(res);
}
async set(key, entry) {
await this.redis.set(
this.buildCacheKey(key),
JSON.stringify(entry),
"PXAT",
entry.staleUntil
);
}
async remove(keys) {
const cacheKeys = (Array.isArray(keys) ? keys : [keys]).map(
(key) => this.buildCacheKey(key).toString()
);
this.redis.del(...cacheKeys);
}
async removeByPrefix(prefix) {
const pattern = `${prefix}*`;
let cursor = "0";
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await this.redis.del(...keys);
}
} while (cursor !== "0");
}
};
// src/context.ts
var DefaultStatefulContext = class {
waitUntil(_p) {
}
};
// src/key.ts
import {
PostgrestParser,
isPostgrestBuilder
} from "@supabase-cache-helpers/postgrest-core";
var SEPARATOR = "$";
function encode(query) {
if (!isPostgrestBuilder(query)) {
throw new Error("Query is not a PostgrestBuilder");
}
const parser = new PostgrestParser(query);
return [
parser.schema,
parser.table,
parser.queryKey,
parser.bodyKey ?? "null",
`count=${parser.count}`,
`head=${parser.isHead}`,
parser.orderByKey
].join(SEPARATOR);
}
function buildTablePrefix(schema, table) {
return [schema, table].join(SEPARATOR);
}
// src/utils.ts
function isEmpty(result) {
if (typeof result.count === "number") {
return false;
}
if (!result.data) {
return true;
}
if (Array.isArray(result.data)) {
return result.data.length === 0;
}
return false;
}
// src/swr-cache.ts
var SwrCache = class {
ctx;
store;
fresh;
stale;
constructor({ ctx, store, fresh, stale }) {
this.ctx = ctx;
this.store = store;
this.fresh = fresh;
this.stale = stale;
}
/**
* Invalidate all keys that start with the given prefix
**/
async removeByPrefix(prefix) {
return this.store.removeByPrefix(prefix);
}
/**
* Return the cached value
*
* The response will be `undefined` for cache misses or `null` when the key was not found in the origin
*/
async get(key) {
const res = await this._get(key);
return res.value;
}
async _get(key) {
const res = await this.store.get(key);
const now = Date.now();
if (!res) {
return { value: void 0 };
}
if (now >= res.staleUntil) {
this.ctx.waitUntil(this.remove(key));
return { value: void 0 };
}
if (now >= res.freshUntil) {
return { value: res.value, revalidate: true };
}
return { value: res.value };
}
/**
* Set the value
*/
async set(key, value, opts) {
const now = Date.now();
return this.store.set(key, {
value,
freshUntil: now + (opts?.fresh ?? this.fresh),
staleUntil: now + (opts?.stale ?? this.stale)
});
}
/**
* Removes the key from the cache.
*/
async remove(key) {
return this.store.remove(key);
}
async swr(key, loadFromOrigin, opts) {
const res = await this._get(key);
const { value, revalidate } = res;
if (typeof value !== "undefined") {
if (revalidate) {
this.ctx.waitUntil(
loadFromOrigin(key).then((res2) => {
if (!isEmpty(res2)) {
this.set(key, res2, opts);
}
})
);
}
return value;
}
const loadedValue = await loadFromOrigin(key);
if (!isEmpty(loadedValue)) {
this.ctx.waitUntil(this.set(key, loadedValue));
}
return loadedValue;
}
};
// src/tiered-store.ts
var TieredStore = class {
ctx;
tiers;
name = "tiered";
/**
* Create a new tiered store
* Stored are checked in the order they are provided
* The first store to return a value will be used to populate all previous stores
*
*
* `stores` can accept `undefined` as members to allow you to construct the tiers dynamically
* @example
* ```ts
* new TieredStore(ctx, [
* new MemoryStore(..),
* process.env.ENABLE_X_STORE ? new XStore(..) : undefined
* ])
* ```
*/
constructor(ctx, stores) {
this.ctx = ctx;
this.tiers = stores.filter(Boolean);
}
/**
* Return the cached value
*
* The response will be `undefined` for cache misses or `null` when the key was not found in the origin
*/
async get(key) {
if (this.tiers.length === 0) {
return;
}
for (let i = 0; i < this.tiers.length; i++) {
const res = await this.tiers[i].get(key);
if (!res) {
return;
}
this.ctx.waitUntil(
Promise.all(
this.tiers.filter((_, j) => j < i).map((t) => () => t.set(key, res))
)
);
return res;
}
}
/**
* Sets the value for the given key.
*/
async set(key, value) {
await Promise.all(this.tiers.map((t) => t.set(key, value)));
}
/**
* Removes the key from the cache.
*/
async remove(key) {
await Promise.all(this.tiers.map((t) => t.remove(key)));
}
/**
* Removes all keys with the given prefix.
*/
async removeByPrefix(prefix) {
await Promise.all(this.tiers.map((t) => t.removeByPrefix(prefix)));
}
};
// src/query-cache.ts
var QueryCache = class {
inner;
/**
* To prevent concurrent requests of the same data, all queries are deduplicated using
* this map.
*/
runningQueries = /* @__PURE__ */ new Map();
constructor(ctx, opts) {
const tieredStore = new TieredStore(ctx, opts.stores);
this.inner = new SwrCache({
ctx,
store: tieredStore,
fresh: opts.fresh,
stale: opts.stale
});
}
/**
* Invalidate all cache entries for a given table
*/
async invalidateQueries({
schema,
table
}) {
const prefix = buildTablePrefix(schema, table);
return this.inner.removeByPrefix(prefix);
}
async query(query, opts) {
const key = encode(query);
const value = await this.inner.get(key);
if (value) return value;
const result = await this.dedupeQuery(query);
if (!isEmpty(result) && (!opts?.store || opts.store(result))) {
await this.inner.set(key, result, opts);
}
return result;
}
async swr(query, opts) {
return await this.inner.swr(
encode(query),
() => this.dedupeQuery(query),
opts
);
}
/**
* Deduplicating the origin load helps when the same value is requested many times at once and is
* not yet in the cache. If we don't deduplicate, we'd create a lot of unnecessary load on the db.
*/
async dedupeQuery(query) {
const key = encode(query);
try {
const querying = this.runningQueries.get(key);
if (querying) {
return querying;
}
this.runningQueries.set(key, query);
return await query;
} finally {
this.runningQueries.delete(key);
}
}
};
export {
DefaultStatefulContext,
MemoryStore,
QueryCache,
RedisStore
};
//# sourceMappingURL=index.js.map