UNPKG

@supabase-cache-helpers/postgrest-server

Version:

A collection of server-side caching utilities for working with Supabase.

374 lines (367 loc) 8.81 kB
// 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