Files
store-scraper-bot/src/repository/upstash.js
T
tiennm99 f1710a5698 chore: remove cloudflare + docker + legacy migration scripts
Phase 7 cleanup of the Vercel + Upstash consolidation plan:

- delete wrangler.toml, Dockerfile, docker-compose{,.dev}.yml,
  scripts/migrate-atlas-to-upstash.js (one-shot migration done)
- drop wrangler + mongodb devDeps and migrate* npm scripts;
  regenerate package-lock.json (-70 packages)
- prune CF/Wrangler/Atlas-export entries from .gitignore + .vercelignore
- drop MONGODB_URI from .env.deploy.example
- rewrite README for Vercel + Upstash architecture
- refresh stale Cloudflare comments in src/{logger,models,repository}
2026-05-09 21:49:48 +07:00

91 lines
3.7 KiB
JavaScript

// Upstash Redis adapter.
//
// Logical key namespace:
// admin singleton
// group:{chatId} per-group state
// apple:{appId} cached Apple response (with TTL)
// google:{appId} cached Google response (with TTL)
//
// Multi-tenancy: every physical Redis key carries a configurable prefix
// (env.KEY_PREFIX, default 'store-scraper-bot:') so this bot can safely share
// an Upstash database with other Vercel projects without collision. Repository
// callers pass logical keys; the adapter applies the prefix transparently.
//
// 60s minimum TTL clamp is preserved from the KV days for parity safety,
// even though Redis would accept lower values.
import { Redis } from '@upstash/redis';
const MIN_TTL_SECONDS = 60;
const DEFAULT_KEY_PREFIX = 'store-scraper-bot:';
export class UpstashUnavailable extends Error {
constructor(missing) {
super(`Upstash env var missing: ${missing}`);
this.name = 'UpstashUnavailable';
}
}
// Build a handle bundling the Redis client and the key prefix together.
// The handle is what callers pass into getJson/putJson/del/scan — it stays
// opaque so repositories never need to know about prefixing themselves.
//
// Accepts both env var naming conventions:
// - UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN (vanilla Upstash signup)
// - KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Marketplace integration)
// so the operator doesn't have to duplicate vars when the bot shares an
// Upstash DB provisioned via Vercel.
export function createUpstashClient(env) {
const url = env?.UPSTASH_REDIS_REST_URL ?? env?.KV_REST_API_URL;
const token = env?.UPSTASH_REDIS_REST_TOKEN ?? env?.KV_REST_API_TOKEN;
if (!url) throw new UpstashUnavailable('UPSTASH_REDIS_REST_URL or KV_REST_API_URL');
if (!token) throw new UpstashUnavailable('UPSTASH_REDIS_REST_TOKEN or KV_REST_API_TOKEN');
const client = new Redis({ url, token });
const prefix = env.KEY_PREFIX ?? DEFAULT_KEY_PREFIX;
return { client, prefix };
}
function physicalKey(handle, key) {
return `${handle.prefix}${key}`;
}
// Upstash auto-deserializes values that look like JSON. We always store via
// JSON.stringify, so reads can return the parsed object directly. Returns null
// on missing key, matching the prior KV semantics.
export async function getJson(handle, key) {
const value = await handle.client.get(physicalKey(handle, key));
if (value == null) return null;
// Some SDK versions return strings, others return parsed objects depending
// on content. Normalize: if string, parse; if object, pass through.
return typeof value === 'string' ? JSON.parse(value) : value;
}
export async function putJson(handle, key, value, opts = {}) {
const ex =
opts.expirationTtl != null ? Math.max(MIN_TTL_SECONDS, opts.expirationTtl) : null;
const setOpts = ex != null ? { ex } : undefined;
await handle.client.set(physicalKey(handle, key), JSON.stringify(value), setOpts);
}
export async function del(handle, key) {
await handle.client.del(physicalKey(handle, key));
}
// Suffix-based scan. Caller passes a logical match like 'group:*'; adapter
// prepends the key prefix so only this bot's keys are returned.
// Returns the list of *logical* keys (prefix stripped) so callers stay
// prefix-unaware.
export async function scan(handle, matchSuffix) {
const match = `${handle.prefix}${matchSuffix}`;
const out = [];
let cursor = '0';
do {
const [next, batch] = await handle.client.scan(cursor, { match, count: 100 });
cursor = next;
for (const physical of batch) {
out.push(physical.startsWith(handle.prefix) ? physical.slice(handle.prefix.length) : physical);
}
} while (cursor !== '0');
return out;
}