diff --git a/backend/__tests__/server.test.ts b/backend/__tests__/server.test.ts new file mode 100644 index 00000000..de7d765f --- /dev/null +++ b/backend/__tests__/server.test.ts @@ -0,0 +1,168 @@ +/** + * Backend server integration tests (no real Redis/Postgres required). + */ + +import http from 'node:http'; +import { startServer } from '../server'; +import type { Pool } from '../shared/db/connectionPool'; +import { PlanCacheService } from '../subscription/domain/PlanCacheService'; +import { InMemoryPlanRepository } from '../subscription/domain/PlanRepository'; +import type { PlanMetadata } from '../subscription/domain/types'; +import type { RedisClient } from '../shared/cache/types'; +import { setPlanCacheService } from '../subscription/planCacheRegistry'; + +class FakeRedis implements RedisClient { + private store = new Map(); + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async set(key: string, value: string, _mode: 'EX', _ttl: number): Promise<'OK'> { + this.store.set(key, value); + return 'OK'; + } + + async del(...keys: string[]): Promise { + let n = 0; + for (const k of keys) { + if (this.store.delete(k)) n++; + } + return n; + } + + async keys(pattern: string): Promise { + const prefix = pattern.replace(/\*$/, ''); + return [...this.store.keys()].filter((k) => k.startsWith(prefix)); + } + + async ping(): Promise { + return 'PONG'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } +} + +const seedPlan: PlanMetadata = { + id: 'plan-1', + name: 'Starter', + price: 9, + currency: 'USD', + billingCycle: 'monthly', + features: ['basic'], + limits: {}, + isActive: true, + metadata: {}, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}; + +function makeMockPool(): Pool { + return { + query: jest.fn(async () => ({ rows: [], rowCount: 0 })), + connect: jest.fn(), + end: jest.fn(), + on: jest.fn(), + totalCount: 0, + idleCount: 0, + waitingCount: 0, + } as unknown as Pool; +} + +function makeBootstrap(repository = new InMemoryPlanRepository([seedPlan])) { + const redis = new FakeRedis(); + const planCache = new PlanCacheService(redis, repository); + return { planCache, redis, repository }; +} + +function request( + port: number, + path: string, + method = 'GET', + body?: unknown, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const payload = body !== undefined ? JSON.stringify(body) : undefined; + const req = http.request( + { + host: '127.0.0.1', + port, + path, + method, + headers: payload + ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } + : undefined, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }, + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function listenEphemeral(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + return typeof address === 'object' && address ? address.port : 0; +} + +describe('backend server', () => { + afterEach(() => { + setPlanCacheService(null); + }); + + it('serves health, plan REST, and plan cache metrics', async () => { + const pool = makeMockPool(); + const planBootstrap = makeBootstrap(); + + const running = await startServer({ pool, planBootstrap, listen: false }); + const port = await listenEphemeral(running.server); + + const health = await request(port, '/health'); + expect(health.status).toBe(200); + expect(JSON.parse(health.body).status).toBe('ok'); + + const plan = await request(port, '/plans/plan-1'); + expect(plan.status).toBe(200); + expect(JSON.parse(plan.body).data.name).toBe('Starter'); + + const metrics = await request(port, '/metrics/plan-cache'); + expect(metrics.status).toBe(200); + expect(metrics.body).toContain('subtrackr_plan_cache_hits_total'); + + await running.shutdown(); + }); + + it('creates a plan via POST /plans with write-through cache', async () => { + const pool = makeMockPool(); + const planBootstrap = makeBootstrap(new InMemoryPlanRepository()); + + const running = await startServer({ pool, planBootstrap, listen: false }); + const port = await listenEphemeral(running.server); + + const created = await request(port, '/plans', 'POST', { + name: 'Growth', + price: 25, + currency: 'USD', + billingCycle: 'monthly', + }); + + expect(created.status).toBe(201); + const parsed = JSON.parse(created.body); + expect(parsed.data.name).toBe('Growth'); + + await running.shutdown(); + }); +}); diff --git a/backend/config/__tests__/redis.test.ts b/backend/config/__tests__/redis.test.ts new file mode 100644 index 00000000..fdb163f6 --- /dev/null +++ b/backend/config/__tests__/redis.test.ts @@ -0,0 +1,53 @@ +import { + DEFAULT_REDIS_CONFIG, + loadRedisConfig, + redisConnectionUrl, +} from '../redis'; + +describe('redis config', () => { + it('loads defaults when env vars are unset', () => { + const config = loadRedisConfig({}); + expect(config.host).toBe(DEFAULT_REDIS_CONFIG.host); + expect(config.port).toBe(6379); + expect(config.db).toBe(0); + expect(config.defaultTtlSeconds).toBe(3600); + }); + + it('reads custom env values', () => { + const config = loadRedisConfig({ + REDIS_HOST: 'redis.internal', + REDIS_PORT: '6380', + REDIS_PASSWORD: 'secret', + REDIS_DB: '2', + REDIS_DEFAULT_TTL_SECONDS: '7200', + }); + expect(config.host).toBe('redis.internal'); + expect(config.port).toBe(6380); + expect(config.password).toBe('secret'); + expect(config.db).toBe(2); + expect(config.defaultTtlSeconds).toBe(7200); + }); + + it('builds connection URL without password', () => { + expect(redisConnectionUrl({ ...DEFAULT_REDIS_CONFIG })).toBe( + 'redis://localhost:6379/0', + ); + }); + + it('builds connection URL with password', () => { + const url = redisConnectionUrl({ + ...DEFAULT_REDIS_CONFIG, + password: 'p@ss', + }); + expect(url).toBe('redis://:p%40ss@localhost:6379/0'); + }); + + it('falls back for invalid numeric env values', () => { + const config = loadRedisConfig({ + REDIS_PORT: 'not-a-number', + REDIS_DB: '-5', + }); + expect(config.port).toBe(DEFAULT_REDIS_CONFIG.port); + expect(config.db).toBe(0); + }); +}); diff --git a/backend/config/redis.ts b/backend/config/redis.ts new file mode 100644 index 00000000..402d84ce --- /dev/null +++ b/backend/config/redis.ts @@ -0,0 +1,60 @@ +/** + * Redis connection configuration for distributed caching. + * + * Environment variables: + * REDIS_HOST – default: localhost + * REDIS_PORT – default: 6379 + * REDIS_PASSWORD – optional + * REDIS_DB – default: 0 + * REDIS_DEFAULT_TTL_SECONDS – default plan cache TTL: 3600 (1 hour) + */ + +export interface RedisConfig { + host: string; + port: number; + password?: string; + db: number; + /** Default TTL for plan metadata entries in seconds. */ + defaultTtlSeconds: number; + /** Connection timeout in milliseconds. */ + connectTimeoutMs: number; +} + +export const DEFAULT_REDIS_CONFIG: Readonly = { + host: 'localhost', + port: 6379, + db: 0, + defaultTtlSeconds: 3600, + connectTimeoutMs: 5_000, +}; + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (value === undefined || value === '') return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +/** Load Redis configuration from environment variables. */ +export function loadRedisConfig(env: NodeJS.ProcessEnv = process.env): RedisConfig { + const password = env.REDIS_PASSWORD?.trim(); + return { + host: env.REDIS_HOST?.trim() || DEFAULT_REDIS_CONFIG.host, + port: parsePositiveInt(env.REDIS_PORT, DEFAULT_REDIS_CONFIG.port), + password: password || undefined, + db: parsePositiveInt(env.REDIS_DB, DEFAULT_REDIS_CONFIG.db), + defaultTtlSeconds: parsePositiveInt( + env.REDIS_DEFAULT_TTL_SECONDS, + DEFAULT_REDIS_CONFIG.defaultTtlSeconds, + ), + connectTimeoutMs: parsePositiveInt( + env.REDIS_CONNECT_TIMEOUT_MS, + DEFAULT_REDIS_CONFIG.connectTimeoutMs, + ), + }; +} + +/** Build a redis:// connection URL from config (password omitted when unset). */ +export function redisConnectionUrl(config: RedisConfig = loadRedisConfig()): string { + const auth = config.password ? `:${encodeURIComponent(config.password)}@` : ''; + return `redis://${auth}${config.host}:${config.port}/${config.db}`; +} diff --git a/backend/graphql/dataloaders/index.ts b/backend/graphql/dataloaders/index.ts index 497f371f..eb972034 100644 --- a/backend/graphql/dataloaders/index.ts +++ b/backend/graphql/dataloaders/index.ts @@ -10,6 +10,8 @@ */ import { Pool } from '../../shared/db/connectionPool'; +import { getPlanCacheService } from '../../subscription/planCacheRegistry'; +import { planMetadataToRow } from '../../subscription/domain/PostgresPlanRepository'; // ── Minimal DataLoader-compatible interface ─────────────────────────────────── // Install: npm i dataloader @types/dataloader @@ -143,6 +145,15 @@ export async function createPlanLoader(pool: Pool): Promise(fn: (keys: readonly K[]) => Promise>) => IDataLoader; }; + const planCache = getPlanCacheService(); + + if (planCache) { + return new DataLoader(async (ids) => { + const plans = await Promise.all(ids.map((id) => planCache.getPlan(id))); + return plans.map((plan) => (plan ? planMetadataToRow(plan) : null)); + }); + } + return new DataLoader(async (ids) => { const result = await pool.query( `SELECT id, name, price, currency, billing_cycle AS "billingCycle" diff --git a/backend/graphql/resolvers.ts b/backend/graphql/resolvers.ts index a727feb3..5a47ea72 100644 --- a/backend/graphql/resolvers.ts +++ b/backend/graphql/resolvers.ts @@ -13,6 +13,8 @@ import { Pool } from '../shared/db/connectionPool'; import { DataLoaderContext } from './dataloaders'; +import { getPlanCacheService } from '../subscription/planCacheRegistry'; +import { planMetadataToRow } from '../subscription/domain/PostgresPlanRepository'; // ── Cursor helpers ──────────────────────────────────────────────────────────── @@ -271,6 +273,23 @@ export const resolvers = { ) => { const first = Math.min(args.first ?? 50, 100); const decoded = args.after ? decodeCursor(args.after) : null; + const planCache = getPlanCacheService(); + + if (planCache) { + let rows = (await planCache.getActivePlans()).map(planMetadataToRow); + if (decoded) { + const idx = rows.findIndex((r) => r.id > decoded.id); + rows = idx >= 0 ? rows.slice(idx) : []; + } + const hasNextPage = rows.length > first; + const page = hasNextPage ? rows.slice(0, first) : rows; + const edges = page.map((row) => ({ + cursor: encodeCursor(String(row.id), String(row.id)), + node: row, + })); + return { edges, pageInfo: buildPageInfo(edges, hasNextPage, decoded !== null) }; + } + let whereSql = ''; const params: unknown[] = []; diff --git a/backend/migrations/003_plans_cache_columns.sql b/backend/migrations/003_plans_cache_columns.sql new file mode 100644 index 00000000..88ec9370 --- /dev/null +++ b/backend/migrations/003_plans_cache_columns.sql @@ -0,0 +1,11 @@ +-- Plan metadata columns for Redis cache layer (idempotent). +-- Run before using PostgresPlanRepository write paths. + +ALTER TABLE plans ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE plans ADD COLUMN IF NOT EXISTS limits JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE plans ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE plans ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE plans ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE plans ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +CREATE INDEX IF NOT EXISTS idx_plans_is_active ON plans (is_active) WHERE is_active = true; diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 00000000..74c1c814 --- /dev/null +++ b/backend/server.ts @@ -0,0 +1,216 @@ +/** + * SubTrackr backend HTTP server. + * + * Bootstraps: + * - PostgreSQL connection pool + * - Redis plan metadata cache + cache warming on deploy + * - GraphQL API at POST /graphql + * - Plan REST API at /plans/* + * - Prometheus plan cache metrics at GET /metrics/plan-cache + * + * Start locally: + * docker compose up -d redis postgres + * npm run server:start + */ + +import http from 'node:http'; +import { URL } from 'node:url'; +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { createHandler } from 'graphql-http/lib/use/node'; + +import { typeDefs } from './graphql/schema'; +import { resolvers } from './graphql/resolvers'; +import { createLoaderContext } from './graphql/dataloaders'; +import { closePool, getPool, type Pool } from './shared/db/connectionPool'; +import { createNullRedisClient } from './shared/cache/NullRedisClient'; +import { + bootstrapPlanCache, + shutdownPlanCache, + type PlanCacheBootstrap, +} from './subscription/bootstrap'; +import { PlanCacheService } from './subscription/domain/PlanCacheService'; +import { PostgresPlanRepository } from './subscription/domain/PostgresPlanRepository'; +import { setPlanCacheService } from './subscription/planCacheRegistry'; +import { createPlanController } from './subscription/controller/planController'; + +export interface StartServerOptions { + port?: number; + host?: string; + pool?: Pool; + /** Pre-built plan cache bootstrap (used in tests). */ + planBootstrap?: PlanCacheBootstrap; + /** When true, binds to port (default). Set false in tests. */ + listen?: boolean; +} + +export interface RunningServer { + server: http.Server; + pool: Pool; + planBootstrap: PlanCacheBootstrap; + port: number; + shutdown: () => Promise; +} + +async function ensurePlanCache(pool: Pool): Promise { + const bootstrapped = await bootstrapPlanCache({ pool, warmOnStart: true }); + if (bootstrapped) { + return bootstrapped; + } + + console.warn('[Server] Redis unavailable — running plan cache in DB-only fallback mode'); + const repository = new PostgresPlanRepository(pool); + const nullRedis = createNullRedisClient(); + const planCache = new PlanCacheService(nullRedis, repository); + setPlanCacheService(planCache); + return { planCache, redis: nullRedis, repository }; +} + +async function readJsonBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return {}; + return JSON.parse(raw) as unknown; +} + +function sendJson( + res: http.ServerResponse, + status: number, + body: unknown, +): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function matchPlanId(pathname: string): string | null { + const match = pathname.match(/^\/plans\/([^/]+)$/); + return match?.[1] ?? null; +} + +export async function startServer(options: StartServerOptions = {}): Promise { + const pool = options.pool ?? (await getPool()); + const planBootstrap = options.planBootstrap ?? (await ensurePlanCache(pool)); + const planController = createPlanController({ planCache: planBootstrap.planCache }); + + const schema = makeExecutableSchema({ typeDefs, resolvers }); + const graphqlHandler = createHandler({ + schema, + context: async () => ({ + pool, + loaders: await createLoaderContext(pool), + }), + }); + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const { pathname } = url; + const method = req.method ?? 'GET'; + + try { + if (pathname === '/health' && method === 'GET') { + const cacheHealthy = await planBootstrap.planCache.isHealthy(); + sendJson(res, 200, { + status: 'ok', + planCache: cacheHealthy ? 'redis' : 'degraded', + }); + return; + } + + if (pathname === '/metrics/plan-cache' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); + res.end(planBootstrap.planCache.prometheusMetrics()); + return; + } + + if (pathname === '/graphql' && (method === 'POST' || method === 'GET')) { + const [handled] = await graphqlHandler(req, res); + if (!handled) { + sendJson(res, 404, { error: 'GraphQL handler could not process request' }); + } + return; + } + + const planId = matchPlanId(pathname); + + if (pathname === '/plans' && method === 'POST') { + const body = (await readJsonBody(req)) as Parameters[0]; + const result = await planController.createPlan(body); + sendJson(res, result.success ? 201 : (result.status ?? 400), result); + return; + } + + if (planId && method === 'GET') { + const result = await planController.getPlan(planId); + sendJson(res, result.success ? 200 : (result.status ?? 400), result); + return; + } + + if (planId && method === 'PATCH') { + const body = (await readJsonBody(req)) as Parameters[1]; + const result = await planController.updatePlan(planId, body); + sendJson(res, result.success ? 200 : (result.status ?? 400), result); + return; + } + + if (planId && method === 'DELETE') { + const result = await planController.deactivatePlan(planId); + sendJson(res, result.success ? 200 : (result.status ?? 400), result); + return; + } + + sendJson(res, 404, { error: 'Not found' }); + } catch (err) { + console.error('[Server] Request error:', err); + sendJson(res, 500, { error: 'Internal server error' }); + } + }); + + const port = options.port ?? Number(process.env.PORT ?? 3001); + const host = options.host ?? process.env.HOST ?? '0.0.0.0'; + + const shutdown = async (): Promise => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + await shutdownPlanCache(planBootstrap); + if (!options.pool) { + await closePool(); + } + }; + + if (options.listen !== false) { + await new Promise((resolve) => { + server.listen(port, host, () => { + console.info(`[Server] Listening on http://${host}:${port}`); + console.info(`[Server] GraphQL → POST /graphql`); + console.info(`[Server] Plans → /plans`); + console.info(`[Server] Metrics → GET /metrics/plan-cache`); + resolve(); + }); + }); + } + + const handleSignal = (signal: string) => { + console.info(`[Server] Received ${signal}, shutting down…`); + shutdown() + .then(() => process.exit(0)) + .catch((err) => { + console.error('[Server] Shutdown error:', err); + process.exit(1); + }); + }; + + process.once('SIGTERM', () => handleSignal('SIGTERM')); + process.once('SIGINT', () => handleSignal('SIGINT')); + + return { server, pool, planBootstrap, port, shutdown }; +} + +if (require.main === module) { + startServer().catch((err) => { + console.error('[Server] Failed to start:', err); + process.exit(1); + }); +} diff --git a/backend/services/container.ts b/backend/services/container.ts index 8f358efa..af481bfa 100644 --- a/backend/services/container.ts +++ b/backend/services/container.ts @@ -29,6 +29,8 @@ import { PredictionService } from './analytics/predictionService'; import { RecommendationService } from './analytics/recommendationService'; import { RetentionService } from './analytics/retentionService'; import { oracleMonitorService } from './analytics/oracleMonitorService'; +import { getPlanCacheService } from '../subscription/planCacheRegistry'; +import type { PlanCacheService } from '../subscription/domain/PlanCacheService'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -216,3 +218,14 @@ container.bind('IPredictionService', () => new PredictionService()); container.bind('IRecommendationService', () => new RecommendationService()); container.bind('IRetentionService', () => new RetentionService()); container.register('IOracleMonitorService', oracleMonitorService); + +// ── Plan cache (requires bootstrapPlanCache() at startup) ───────────────────── +container.bind('IPlanCacheService', () => { + const svc = getPlanCacheService(); + if (!svc) { + throw new Error( + '[Container] IPlanCacheService not available. Call bootstrapPlanCache() during startup.', + ); + } + return svc as PlanCacheService; +}); diff --git a/backend/services/index.ts b/backend/services/index.ts index a11eec92..697c7b54 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -256,6 +256,21 @@ export type { export { BatchChargeService } from './batchChargeService'; export type { BatchChargeCandidate, BatchChargeOptions, BatchChargeResult } from './batchChargeService'; +// ── Plan metadata cache ─────────────────────────────────────────────────────── +export { bootstrapPlanCache, shutdownPlanCache } from '../subscription/bootstrap'; +export type { PlanCacheBootstrap, BootstrapPlanCacheOptions } from '../subscription/bootstrap'; +export { PlanCacheService } from '../subscription/domain/PlanCacheService'; +export type { PlanCacheConfig } from '../subscription/domain/PlanCacheService'; +export { PostgresPlanRepository, planMetadataToRow } from '../subscription/domain/PostgresPlanRepository'; +export { getPlanCacheService, setPlanCacheService } from '../subscription/planCacheRegistry'; +export { runPlanCacheWarming, cacheWarmingJob } from '../subscription/jobs/cacheWarming'; +export type { PlanMetadata, CreatePlanInput, UpdatePlanInput } from '../subscription/domain/types'; +export { createPlanController } from '../subscription/controller/planController'; +export { loadRedisConfig, redisConnectionUrl } from '../config/redis'; +export { createRedisClient, RedisCacheService, createNullRedisClient } from '../shared/cache'; +export { startServer } from '../server'; +export type { RunningServer, StartServerOptions } from '../server'; + // ── Idempotency (Issue #425) ───────────────────────────────────────────────── export { IdempotencyService, diff --git a/backend/services/shared/__tests__/subscriptionCacheService.test.ts b/backend/services/shared/__tests__/subscriptionCacheService.test.ts index c2d02f7f..678c64e1 100644 --- a/backend/services/shared/__tests__/subscriptionCacheService.test.ts +++ b/backend/services/shared/__tests__/subscriptionCacheService.test.ts @@ -2,9 +2,9 @@ import { SubscriptionCacheService, type RedisClient, type SubscriptionCacheConfig, -} from '../subscriptionCacheService'; -import type { Subscription } from '../../../src/types/subscription'; -import { SubscriptionCategory, BillingCycle } from '../../../src/types/subscription'; +} from '../../subscriptionCacheService'; +import type { Subscription } from '../../../../src/types/subscription'; +import { SubscriptionCategory, BillingCycle } from '../../../../src/types/subscription'; // ── Test doubles ────────────────────────────────────────────────────────────── diff --git a/backend/shared/cache/NullRedisClient.ts b/backend/shared/cache/NullRedisClient.ts new file mode 100644 index 00000000..a8903697 --- /dev/null +++ b/backend/shared/cache/NullRedisClient.ts @@ -0,0 +1,18 @@ +/** + * No-op Redis client for DB-only fallback when Redis is unavailable at startup. + */ + +import type { RedisClient } from './types'; + +export function createNullRedisClient(): RedisClient { + return { + get: async () => null, + set: async () => 'OK', + del: async () => 0, + keys: async () => [], + ping: async () => { + throw new Error('Redis not configured'); + }, + quit: async () => 'OK', + }; +} diff --git a/backend/shared/cache/RedisCacheService.ts b/backend/shared/cache/RedisCacheService.ts new file mode 100644 index 00000000..3450a8ec --- /dev/null +++ b/backend/shared/cache/RedisCacheService.ts @@ -0,0 +1,307 @@ +/** + * RedisCacheService — low-level distributed cache with single-flight protection, + * graceful degradation, and Prometheus metrics export. + */ + +import type { RedisCacheConfig, RedisCacheMetrics, RedisClient } from './types'; + +const DEFAULT_PREFIX = 'subtrackr:cache:'; +const DEFAULT_TTL = 3600; + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(index, sorted.length - 1))]; +} + +export class RedisCacheService { + private readonly prefix: string; + private readonly defaultTtl: number; + private readonly onDegradation?: RedisCacheConfig['onDegradation']; + + private hits = 0; + private misses = 0; + private writes = 0; + private invalidations = 0; + private errors = 0; + private degradations = 0; + private latencies: number[] = []; + private memoryUsageBytes = 0; + private readonly keySizes = new Map(); + private degraded = false; + + /** Single-flight map: only one loader runs per key on concurrent misses. */ + private readonly inflight = new Map>(); + + constructor( + private readonly redis: RedisClient, + config: RedisCacheConfig = {}, + ) { + this.prefix = config.keyPrefix ?? DEFAULT_PREFIX; + this.defaultTtl = config.defaultTtlSeconds ?? DEFAULT_TTL; + this.onDegradation = config.onDegradation; + } + + /** True after a Redis failure; skips further Redis reads until health recovers. */ + isDegraded(): boolean { + return this.degraded; + } + + // ── Public API ─────────────────────────────────────────────────────────────── + + /** + * Returns a cached JSON value or null on miss. + * When degraded, skips Redis and returns null immediately. + */ + async get(key: string): Promise { + if (this.degraded) { + return null; + } + + const fullKey = this.fullKey(key); + const start = Date.now(); + + try { + const value = await this.redis.get(fullKey); + this.recordLatency(Date.now() - start); + + if (value !== null) { + this.hits++; + return value; + } + + this.misses++; + return null; + } catch { + this.errors++; + this.enterDegraded('Redis get failed; returning cache miss', { key }); + return null; + } + } + + /** + * Cache-aside with single-flight: on miss, exactly one concurrent loader + * populates Redis while others await the same promise. + */ + async getOrLoad( + key: string, + loader: () => Promise, + ttlSeconds?: number, + ): Promise { + const cached = await this.get(key); + if (cached !== null) { + return cached; + } + + if (this.degraded) { + return loader(); + } + + const existing = this.inflight.get(key); + if (existing) { + return existing; + } + + const flight = this.loadAndSet(key, loader, ttlSeconds); + this.inflight.set(key, flight); + + try { + return await flight; + } finally { + this.inflight.delete(key); + } + } + + /** Stores a JSON-serializable string with TTL. Returns false when Redis is unavailable. */ + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (this.degraded) { + return false; + } + + const fullKey = this.fullKey(key); + const ttl = ttlSeconds ?? this.defaultTtl; + const start = Date.now(); + const newSize = Buffer.byteLength(value, 'utf8'); + + try { + await this.redis.set(fullKey, value, 'EX', ttl); + this.writes++; + const oldSize = this.keySizes.get(fullKey) ?? 0; + this.keySizes.set(fullKey, newSize); + this.memoryUsageBytes += newSize - oldSize; + this.recordLatency(Date.now() - start); + return true; + } catch { + this.errors++; + this.enterDegraded('Redis set failed; value not cached', { key }); + return false; + } + } + + async invalidate(key: string): Promise { + if (this.degraded) { + return; + } + + const fullKey = this.fullKey(key); + + try { + await this.redis.del(fullKey); + this.invalidations++; + this.releaseKeyMemory(fullKey); + } catch { + this.errors++; + this.enterDegraded('Redis invalidate failed', { key }); + } + } + + async invalidateAll(): Promise { + if (this.degraded) { + return; + } + + try { + const keys = await this.redis.keys(`${this.prefix}*`); + if (keys.length > 0) { + await this.redis.del(...keys); + this.invalidations += keys.length; + for (const fullKey of keys) { + this.releaseKeyMemory(fullKey); + } + } + } catch { + this.errors++; + this.enterDegraded('Redis invalidateAll failed'); + } + } + + getMetrics(): RedisCacheMetrics { + const sorted = [...this.latencies].sort((a, b) => a - b); + const total = this.hits + this.misses; + + return { + hits: this.hits, + misses: this.misses, + writes: this.writes, + invalidations: this.invalidations, + errors: this.errors, + degradations: this.degradations, + hitRatio: total === 0 ? NaN : this.hits / total, + latencyMs: { + p50: percentile(sorted, 50), + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + }, + memoryUsageBytes: this.memoryUsageBytes, + }; + } + + resetMetrics(): void { + this.hits = 0; + this.misses = 0; + this.writes = 0; + this.invalidations = 0; + this.errors = 0; + this.degradations = 0; + this.latencies = []; + this.memoryUsageBytes = 0; + this.keySizes.clear(); + } + + prometheusMetrics(namespace = 'subtrackr_plan_cache'): string { + const m = this.getMetrics(); + const lines = [ + `# HELP ${namespace}_hits_total Cache hits`, + `# TYPE ${namespace}_hits_total counter`, + `${namespace}_hits_total ${m.hits}`, + `# HELP ${namespace}_misses_total Cache misses`, + `# TYPE ${namespace}_misses_total counter`, + `${namespace}_misses_total ${m.misses}`, + `# HELP ${namespace}_hit_ratio Cache hit ratio`, + `# TYPE ${namespace}_hit_ratio gauge`, + `${namespace}_hit_ratio ${Number.isNaN(m.hitRatio) ? 0 : m.hitRatio}`, + `# HELP ${namespace}_latency_ms Cache operation latency percentiles`, + `# TYPE ${namespace}_latency_ms summary`, + `${namespace}_latency_ms{quantile="0.5"} ${m.latencyMs.p50}`, + `${namespace}_latency_ms{quantile="0.95"} ${m.latencyMs.p95}`, + `${namespace}_latency_ms{quantile="0.99"} ${m.latencyMs.p99}`, + `# HELP ${namespace}_memory_usage_bytes Approximate cached payload bytes`, + `# TYPE ${namespace}_memory_usage_bytes gauge`, + `${namespace}_memory_usage_bytes ${m.memoryUsageBytes}`, + `# HELP ${namespace}_degradations_total Redis degradation events`, + `# TYPE ${namespace}_degradations_total counter`, + `${namespace}_degradations_total ${m.degradations}`, + ]; + return lines.join('\n'); + } + + async isHealthy(): Promise { + try { + const response = await this.redis.ping(); + if (response === 'PONG') { + this.degraded = false; + return true; + } + return false; + } catch { + this.enterDegraded('Redis ping failed'); + return false; + } + } + + // ── Private ────────────────────────────────────────────────────────────────── + + private fullKey(key: string): string { + return `${this.prefix}${key}`; + } + + private async loadAndSet( + key: string, + loader: () => Promise, + ttlSeconds?: number, + ): Promise { + try { + const value = await loader(); + if (value !== null) { + await this.set(key, value, ttlSeconds); + } + return value; + } catch { + this.errors++; + return null; + } + } + + private releaseKeyMemory(fullKey: string): void { + const size = this.keySizes.get(fullKey) ?? 0; + if (size > 0) { + this.memoryUsageBytes = Math.max(0, this.memoryUsageBytes - size); + this.keySizes.delete(fullKey); + } + } + + private enterDegraded(message: string, context?: Record): void { + if (!this.degraded) { + this.degraded = true; + this.degradations++; + } + this.warnDegradation(message, context); + } + + private recordLatency(ms: number): void { + this.latencies.push(ms); + if (this.latencies.length > 10_000) { + this.latencies.shift(); + } + } + + private warnDegradation(message: string, context?: Record): void { + if (this.onDegradation) { + this.onDegradation(message, context); + } else { + console.warn(`[RedisCacheService] ${message}`, context ?? {}); + } + } +} + +export type { RedisClient, RedisCacheMetrics, RedisCacheConfig } from './types'; diff --git a/backend/shared/cache/__tests__/RedisCacheService.test.ts b/backend/shared/cache/__tests__/RedisCacheService.test.ts new file mode 100644 index 00000000..a8884acc --- /dev/null +++ b/backend/shared/cache/__tests__/RedisCacheService.test.ts @@ -0,0 +1,178 @@ +import { RedisCacheService, type RedisClient } from '../RedisCacheService'; + +class FakeRedis implements RedisClient { + private store = new Map(); + public failReads = false; + public failWrites = false; + public getCalls = 0; + + async get(key: string): Promise { + this.getCalls++; + if (this.failReads) throw new Error('Redis down'); + const entry = this.store.get(key); + if (!entry || Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.value; + } + + async set(key: string, value: string, _mode: 'EX', ttl: number): Promise<'OK'> { + if (this.failWrites) throw new Error('Redis down'); + this.store.set(key, { value, expiresAt: Date.now() + ttl * 1000 }); + return 'OK'; + } + + async del(...keys: string[]): Promise { + if (this.failWrites) throw new Error('Redis down'); + let count = 0; + for (const k of keys) { + if (this.store.delete(k)) count++; + } + return count; + } + + async keys(pattern: string): Promise { + const prefix = pattern.replace(/\*$/, ''); + return [...this.store.keys()].filter((k) => k.startsWith(prefix)); + } + + async ping(): Promise { + if (this.failReads) throw new Error('Redis down'); + return 'PONG'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } + + seed(key: string, value: string): void { + this.store.set(key, { value, expiresAt: Date.now() + 3_600_000 }); + } +} + +describe('RedisCacheService', () => { + let redis: FakeRedis; + let svc: RedisCacheService; + let degradationWarnings: string[]; + + beforeEach(() => { + redis = new FakeRedis(); + degradationWarnings = []; + svc = new RedisCacheService(redis, { + keyPrefix: 'test:', + defaultTtlSeconds: 60, + onDegradation: (msg) => degradationWarnings.push(msg), + }); + }); + + it('returns null on cache miss', async () => { + expect(await svc.get('missing')).toBeNull(); + const metrics = svc.getMetrics(); + expect(metrics.misses).toBe(1); + expect(metrics.hits).toBe(0); + }); + + it('stores and retrieves values', async () => { + await svc.set('plan-1', '{"id":"plan-1"}'); + expect(await svc.get('plan-1')).toBe('{"id":"plan-1"}'); + expect(svc.getMetrics().hits).toBe(1); + }); + + it('invalidates a single key', async () => { + await svc.set('plan-1', 'value'); + await svc.invalidate('plan-1'); + expect(await svc.get('plan-1')).toBeNull(); + expect(svc.getMetrics().invalidations).toBe(1); + }); + + it('invalidates all keys under prefix', async () => { + await svc.set('a', '1'); + await svc.set('b', '2'); + await svc.invalidateAll(); + expect(await svc.get('a')).toBeNull(); + expect(await svc.get('b')).toBeNull(); + }); + + it('getOrLoad runs loader only once on concurrent misses (single-flight)', async () => { + let loadCount = 0; + const loader = async () => { + loadCount++; + await new Promise((r) => setTimeout(r, 20)); + return 'loaded'; + }; + + const results = await Promise.all([ + svc.getOrLoad('sf-key', loader), + svc.getOrLoad('sf-key', loader), + svc.getOrLoad('sf-key', loader), + ]); + + expect(results).toEqual(['loaded', 'loaded', 'loaded']); + expect(loadCount).toBe(1); + }); + + it('degrades gracefully when Redis read fails', async () => { + redis.failReads = true; + expect(await svc.get('x')).toBeNull(); + expect(svc.getMetrics().degradations).toBe(1); + expect(degradationWarnings.length).toBeGreaterThan(0); + }); + + it('degrades gracefully when Redis write fails', async () => { + redis.failWrites = true; + expect(await svc.set('x', 'y')).toBe(false); + expect(svc.getMetrics().degradations).toBe(1); + }); + + it('set returns true on success', async () => { + expect(await svc.set('ok', 'value')).toBe(true); + }); + + it('decrements memory usage on invalidate', async () => { + await svc.set('mem-key', 'hello'); + expect(svc.getMetrics().memoryUsageBytes).toBe(5); + await svc.invalidate('mem-key'); + expect(svc.getMetrics().memoryUsageBytes).toBe(0); + }); + + it('skips Redis reads after entering degraded mode', async () => { + redis.failReads = true; + await svc.get('first'); + expect(svc.isDegraded()).toBe(true); + + redis.failReads = false; + const callsBefore = redis.getCalls; + await svc.get('second'); + expect(redis.getCalls).toBe(callsBefore); + }); + + it('exports Prometheus metrics with hit ratio and latency', async () => { + await svc.set('p', 'v'); + await svc.get('p'); + await svc.get('missing'); + + const output = svc.prometheusMetrics('test_cache'); + expect(output).toContain('test_cache_hits_total 1'); + expect(output).toContain('test_cache_misses_total 1'); + expect(output).toContain('test_cache_hit_ratio'); + expect(output).toContain('test_cache_latency_ms{quantile="0.5"}'); + expect(output).toContain('test_cache_memory_usage_bytes'); + expect(output).toContain('test_cache_degradations_total'); + }); + + it('reports healthy when ping succeeds', async () => { + expect(await svc.isHealthy()).toBe(true); + }); + + it('reports unhealthy when ping fails', async () => { + redis.failReads = true; + expect(await svc.isHealthy()).toBe(false); + }); + + it('resetMetrics clears counters', async () => { + await svc.get('missing'); + svc.resetMetrics(); + expect(svc.getMetrics().misses).toBe(0); + }); +}); diff --git a/backend/shared/cache/createRedisClient.ts b/backend/shared/cache/createRedisClient.ts new file mode 100644 index 00000000..19b3538d --- /dev/null +++ b/backend/shared/cache/createRedisClient.ts @@ -0,0 +1,54 @@ +/** + * Creates an ioredis-backed RedisClient for the backend cache layer. + * Uses dynamic import so the mobile bundle never loads ioredis. + */ + +import { loadRedisConfig } from '../../config/redis'; +import type { RedisClient } from './types'; + +export interface IORedisLike { + get(key: string): Promise; + set(key: string, value: string, expiryMode: string, time: number): Promise; + del(...keys: string[]): Promise; + keys(pattern: string): Promise; + ping(): Promise; + quit(): Promise; +} + +/** Wraps an ioredis instance in the minimal RedisClient interface. */ +export function wrapIORedis(client: IORedisLike): RedisClient { + return { + get: (key) => client.get(key), + set: (key, value, mode, time) => client.set(key, value, mode, time), + del: (...keys) => client.del(...keys), + keys: (pattern) => client.keys(pattern), + ping: () => client.ping(), + quit: () => client.quit(), + }; +} + +/** + * Connect to Redis using environment configuration. + * Throws when ioredis is not installed or connection fails. + */ +export async function createRedisClient(): Promise { + const config = loadRedisConfig(); + + const ioredisModule = (await import('ioredis')) as { + default: new (url: string, options?: Record) => IORedisLike; + }; + + const Redis = ioredisModule.default; + const client = new Redis({ + host: config.host, + port: config.port, + password: config.password, + db: config.db, + connectTimeout: config.connectTimeoutMs, + maxRetriesPerRequest: 1, + lazyConnect: true, + }); + + await client.ping(); + return wrapIORedis(client); +} diff --git a/backend/shared/cache/index.ts b/backend/shared/cache/index.ts new file mode 100644 index 00000000..8ba1197b --- /dev/null +++ b/backend/shared/cache/index.ts @@ -0,0 +1,5 @@ +export { RedisCacheService } from './RedisCacheService'; +export { createRedisClient, wrapIORedis } from './createRedisClient'; +export { createNullRedisClient } from './NullRedisClient'; +export type { IORedisLike } from './createRedisClient'; +export type { RedisClient, RedisCacheMetrics, RedisCacheConfig } from './types'; diff --git a/backend/shared/cache/types.ts b/backend/shared/cache/types.ts new file mode 100644 index 00000000..c737a123 --- /dev/null +++ b/backend/shared/cache/types.ts @@ -0,0 +1,40 @@ +/** + * Minimal Redis client interface for cache services. + * Compatible with ioredis, node-redis, and test doubles. + */ + +export interface RedisClient { + get(key: string): Promise; + set(key: string, value: string, expiryMode: 'EX', time: number): Promise; + del(...keys: string[]): Promise; + keys(pattern: string): Promise; + ping(): Promise; + quit(): Promise; +} + +export interface RedisCacheMetrics { + hits: number; + misses: number; + writes: number; + invalidations: number; + errors: number; + degradations: number; + /** hits / (hits + misses). NaN when no reads yet. */ + hitRatio: number; + latencyMs: { + p50: number; + p95: number; + p99: number; + }; + /** Approximate serialized payload bytes currently tracked in metrics. */ + memoryUsageBytes: number; +} + +export interface RedisCacheConfig { + /** Key prefix for namespacing. */ + keyPrefix?: string; + /** Default TTL in seconds when not overridden per entry. */ + defaultTtlSeconds?: number; + /** Optional warning logger for Redis degradation events. */ + onDegradation?: (message: string, context?: Record) => void; +} diff --git a/backend/subscription/__tests__/bootstrap.test.ts b/backend/subscription/__tests__/bootstrap.test.ts new file mode 100644 index 00000000..2134ec8a --- /dev/null +++ b/backend/subscription/__tests__/bootstrap.test.ts @@ -0,0 +1,90 @@ +import { bootstrapPlanCache, shutdownPlanCache } from '../bootstrap'; +import { PlanCacheService } from '../domain/PlanCacheService'; +import { InMemoryPlanRepository } from '../domain/PlanRepository'; +import { getPlanCacheService, setPlanCacheService } from '../planCacheRegistry'; +import type { RedisClient } from '../../shared/cache/types'; +import type { PlanMetadata } from '../domain/types'; + +class FakeRedis implements RedisClient { + async get(): Promise { + return null; + } + async set(): Promise<'OK'> { + return 'OK'; + } + async del(): Promise { + return 0; + } + async keys(): Promise { + return []; + } + async ping(): Promise { + return 'PONG'; + } + async quit(): Promise<'OK'> { + return 'OK'; + } +} + +const seed: PlanMetadata = { + id: 'plan-1', + name: 'Basic', + price: 10, + currency: 'USD', + billingCycle: 'monthly', + features: [], + limits: {}, + isActive: true, + metadata: {}, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}; + +describe('bootstrapPlanCache', () => { + afterEach(async () => { + setPlanCacheService(null); + }); + + it('registers PlanCacheService globally', async () => { + const result = await bootstrapPlanCache({ + redis: new FakeRedis(), + repository: new InMemoryPlanRepository([seed]), + warmOnStart: false, + }); + + expect(result).not.toBeNull(); + expect(getPlanCacheService()).toBeInstanceOf(PlanCacheService); + await shutdownPlanCache(result); + }); + + it('bootstraps even when Redis warming is skipped due to unhealthy connection', async () => { + const brokenRedis: RedisClient = { + get: async () => { + throw new Error('down'); + }, + set: async () => { + throw new Error('down'); + }, + del: async () => { + throw new Error('down'); + }, + keys: async () => { + throw new Error('down'); + }, + ping: async () => { + throw new Error('down'); + }, + quit: async () => 'OK', + }; + + const result = await bootstrapPlanCache({ + redis: brokenRedis, + repository: new InMemoryPlanRepository([seed]), + warmOnStart: true, + }); + + expect(result).not.toBeNull(); + expect(getPlanCacheService()).not.toBeNull(); + await shutdownPlanCache(result); + }); +}); diff --git a/backend/subscription/bootstrap.ts b/backend/subscription/bootstrap.ts new file mode 100644 index 00000000..784ccd57 --- /dev/null +++ b/backend/subscription/bootstrap.ts @@ -0,0 +1,65 @@ +/** + * Bootstrap plan metadata cache on service startup / deploy. + */ + +import type { Pool } from '../shared/db/connectionPool'; +import type { RedisClient } from '../shared/cache/types'; +import { createRedisClient } from '../shared/cache/createRedisClient'; +import { PlanCacheService } from './domain/PlanCacheService'; +import { PostgresPlanRepository } from './domain/PostgresPlanRepository'; +import { InMemoryPlanRepository } from './domain/PlanRepository'; +import type { IPlanRepository } from './domain/PlanRepository'; +import { runPlanCacheWarming } from './jobs/cacheWarming'; +import { setPlanCacheService } from './planCacheRegistry'; + +export interface PlanCacheBootstrap { + planCache: PlanCacheService; + redis: RedisClient; + repository: IPlanRepository; +} + +export interface BootstrapPlanCacheOptions { + pool?: Pool; + repository?: IPlanRepository; + redis?: RedisClient; + /** Run cache warming after init (default: true). */ + warmOnStart?: boolean; +} + +/** + * Initialize Redis plan cache and optionally warm it from the database. + * Registers the instance globally for GraphQL loaders. + */ +export async function bootstrapPlanCache( + options: BootstrapPlanCacheOptions = {}, +): Promise { + const warmOnStart = options.warmOnStart ?? true; + + try { + const redis = options.redis ?? (await createRedisClient()); + const repository = + options.repository ?? + (options.pool ? new PostgresPlanRepository(options.pool) : new InMemoryPlanRepository()); + + const planCache = new PlanCacheService(redis, repository); + setPlanCacheService(planCache); + + if (warmOnStart) { + await runPlanCacheWarming(planCache); + } + + return { planCache, redis, repository }; + } catch (err) { + console.warn('[PlanCache] Bootstrap failed — plan reads will use database directly:', err); + setPlanCacheService(null); + return null; + } +} + +/** Tear down the plan cache on shutdown. */ +export async function shutdownPlanCache(bootstrap: PlanCacheBootstrap | null): Promise { + setPlanCacheService(null); + if (bootstrap?.redis) { + await bootstrap.redis.quit(); + } +} diff --git a/backend/subscription/controller/__tests__/planController.test.ts b/backend/subscription/controller/__tests__/planController.test.ts new file mode 100644 index 00000000..83c4bc38 --- /dev/null +++ b/backend/subscription/controller/__tests__/planController.test.ts @@ -0,0 +1,118 @@ +import { createPlanController } from '../planController'; +import { PlanCacheService } from '../../domain/PlanCacheService'; +import { InMemoryPlanRepository } from '../../domain/PlanRepository'; +import type { RedisClient } from '../../../shared/cache/types'; +import type { PlanMetadata } from '../../domain/types'; + +class FakeRedis implements RedisClient { + private store = new Map(); + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async set(key: string, value: string, _mode: 'EX', _ttl: number): Promise<'OK'> { + this.store.set(key, value); + return 'OK'; + } + + async del(...keys: string[]): Promise { + let n = 0; + for (const k of keys) { + if (this.store.delete(k)) n++; + } + return n; + } + + async keys(pattern: string): Promise { + const prefix = pattern.replace(/\*$/, ''); + return [...this.store.keys()].filter((k) => k.startsWith(prefix)); + } + + async ping(): Promise { + return 'PONG'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } +} + +const seedPlan: PlanMetadata = { + id: 'plan-1', + name: 'Starter', + price: 5, + currency: 'USD', + billingCycle: 'monthly', + features: [], + limits: {}, + isActive: true, + metadata: {}, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}; + +describe('planController', () => { + let controller: ReturnType; + let planCache: PlanCacheService; + + beforeEach(() => { + InMemoryPlanRepository.resetIdCounter(); + const repo = new InMemoryPlanRepository([seedPlan]); + planCache = new PlanCacheService(new FakeRedis(), repo); + controller = createPlanController({ planCache }); + }); + + it('getPlan returns active plan', async () => { + const res = await controller.getPlan('plan-1'); + expect(res.success).toBe(true); + expect((res as { data: PlanMetadata }).data.name).toBe('Starter'); + }); + + it('getPlan returns 404 for missing plan', async () => { + const res = await controller.getPlan('missing'); + expect(res.success).toBe(false); + expect(res.status).toBe(404); + }); + + it('createPlan writes through to cache', async () => { + const res = await controller.createPlan({ + name: 'Growth', + price: 25, + currency: 'USD', + billingCycle: 'monthly', + }); + expect(res.success).toBe(true); + const created = (res as { data: PlanMetadata }).data; + const cached = await planCache.getPlan(created.id); + expect(cached?.name).toBe('Growth'); + }); + + it('updatePlan invalidates stale cache via write-through', async () => { + await planCache.getPlan('plan-1'); + const res = await controller.updatePlan('plan-1', { price: 7.5 }); + expect(res.success).toBe(true); + const cached = await planCache.getPlan('plan-1'); + expect(cached?.price).toBe(7.5); + }); + + it('deactivatePlan marks plan inactive and invalidates cache', async () => { + await planCache.getPlan('plan-1'); + const res = await controller.deactivatePlan('plan-1'); + expect(res.success).toBe(true); + + const inactiveRes = await controller.getPlan('plan-1'); + expect(inactiveRes.success).toBe(false); + expect(inactiveRes.status).toBe(409); + }); + + it('rejects invalid create body', async () => { + const res = await controller.createPlan({ + name: '', + price: -1, + currency: '', + billingCycle: '', + }); + expect(res.success).toBe(false); + }); +}); diff --git a/backend/subscription/controller/planController.ts b/backend/subscription/controller/planController.ts new file mode 100644 index 00000000..e3fabd1f --- /dev/null +++ b/backend/subscription/controller/planController.ts @@ -0,0 +1,99 @@ +/** + * Plan REST API handlers (framework-agnostic). + * + * Endpoints: + * GET /plans/:id – get plan metadata (cache-backed) + * POST /plans – create plan (write-through cache) + * PATCH /plans/:id – update plan (write-through cache) + * DELETE /plans/:id – deactivate plan (invalidate cache) + */ + +import type { PlanCacheService } from '../domain/PlanCacheService'; +import type { CreatePlanInput, UpdatePlanInput } from '../domain/types'; + +export interface PlanControllerDeps { + planCache: PlanCacheService; +} + +function ok(data: unknown) { + return { success: true, data }; +} + +function err(message: string, status = 400) { + return { success: false, error: { message }, status }; +} + +export function createPlanController(deps: PlanControllerDeps) { + const { planCache } = deps; + + return { + /** GET /plans/:id */ + async getPlan(id: string) { + if (!id?.trim()) { + return err('Plan id is required'); + } + + const plan = await planCache.getPlan(id); + if (!plan) { + return err('Plan not found', 404); + } + if (!plan.isActive) { + return err('Plan is inactive', 409); + } + + return ok(plan); + }, + + /** POST /plans */ + async createPlan(body: CreatePlanInput) { + if (!body?.name?.trim()) { + return err('Body must include "name"'); + } + if (typeof body.price !== 'number' || body.price < 0) { + return err('Body must include valid "price"'); + } + if (!body.currency?.trim() || !body.billingCycle?.trim()) { + return err('Body must include "currency" and "billingCycle"'); + } + + const plan = await planCache.writeThroughCreate(body); + return ok(plan); + }, + + /** PATCH /plans/:id */ + async updatePlan(id: string, body: UpdatePlanInput) { + if (!id?.trim()) { + return err('Plan id is required'); + } + if (!body || Object.keys(body).length === 0) { + return err('Body must include at least one field to update'); + } + if (body.price !== undefined && (typeof body.price !== 'number' || body.price < 0)) { + return err('Invalid "price"'); + } + + const plan = await planCache.writeThroughUpdate(id, body); + if (!plan) { + return err('Plan not found', 404); + } + + return ok(plan); + }, + + /** DELETE /plans/:id — soft-deactivate */ + async deactivatePlan(id: string) { + if (!id?.trim()) { + return err('Plan id is required'); + } + + const plan = await planCache.writeThroughDeactivate(id); + if (!plan) { + return err('Plan not found', 404); + } + + return ok(plan); + }, + }; +} + +export type PlanController = ReturnType; diff --git a/backend/subscription/domain/PlanCacheService.ts b/backend/subscription/domain/PlanCacheService.ts new file mode 100644 index 00000000..f965c6a1 --- /dev/null +++ b/backend/subscription/domain/PlanCacheService.ts @@ -0,0 +1,238 @@ +/** + * PlanCacheService — Redis-backed distributed cache for subscription plan metadata. + * + * Read path: Redis → (on miss, single-flight) database + * Write path: database → Redis (write-through on mutations) + * TTL: 1 hour default, overridable via plan.metadata.cacheTTL + */ + +import { RedisCacheService } from '../../shared/cache/RedisCacheService'; +import type { RedisClient } from '../../shared/cache/types'; +import { DEFAULT_REDIS_CONFIG, type RedisConfig } from '../../config/redis'; +import type { IPlanRepository } from './PlanRepository'; +import type { PlanMetadata } from './types'; + +export interface PlanCacheConfig { + keyPrefix?: string; + defaultTtlSeconds?: number; + onDegradation?: (message: string, context?: Record) => void; +} + +const PLAN_KEY_PREFIX = 'subtrackr:plan:'; +const ACTIVE_LIST_KEY = 'active-list'; + +export class PlanCacheService { + private readonly cache: RedisCacheService; + private readonly defaultTtl: number; + private readonly planPrefix: string; + /** Single-flight: one DB load per plan id on concurrent cache misses. */ + private readonly inflight = new Map>(); + private readonly activeListInflight = new Map>(); + + constructor( + redis: RedisClient, + private readonly repository: IPlanRepository, + config: PlanCacheConfig = {}, + redisConfig: Pick = DEFAULT_REDIS_CONFIG, + ) { + this.defaultTtl = config.defaultTtlSeconds ?? redisConfig.defaultTtlSeconds; + this.planPrefix = config.keyPrefix ?? PLAN_KEY_PREFIX; + this.cache = new RedisCacheService(redis, { + keyPrefix: this.planPrefix, + defaultTtlSeconds: this.defaultTtl, + onDegradation: config.onDegradation, + }); + } + + private planKey(id: string): string { + return `id:${id}`; + } + + private resolveTtl(plan: PlanMetadata): number { + const override = plan.metadata?.cacheTTL; + if (typeof override === 'number' && override > 0) { + return override; + } + return this.defaultTtl; + } + + /** + * Returns plan metadata by ID. + * When Redis is degraded, queries the database directly. + */ + async getPlan(id: string): Promise { + if (!this.cache.isDegraded()) { + const cached = await this.cache.get(this.planKey(id)); + if (cached !== null) { + return JSON.parse(cached) as PlanMetadata; + } + } + + const existing = this.inflight.get(id); + if (existing) { + return existing; + } + + const flight = this.loadPlanFromDatabase(id); + this.inflight.set(id, flight); + + try { + return await flight; + } finally { + this.inflight.delete(id); + } + } + + /** + * Returns all active plans, using a cached list when available. + */ + async getActivePlans(): Promise { + if (!this.cache.isDegraded()) { + const cached = await this.cache.get(ACTIVE_LIST_KEY); + if (cached !== null) { + return JSON.parse(cached) as PlanMetadata[]; + } + } + + const existing = this.activeListInflight.get(ACTIVE_LIST_KEY); + if (existing) { + return existing; + } + + const flight = this.loadActivePlansFromDatabase(); + this.activeListInflight.set(ACTIVE_LIST_KEY, flight); + + try { + return await flight; + } finally { + this.activeListInflight.delete(ACTIVE_LIST_KEY); + } + } + + private async loadPlanFromDatabase(id: string): Promise { + const plan = await this.repository.findById(id); + if (plan?.isActive) { + await this.setPlan(plan); + } + return plan; + } + + private async loadActivePlansFromDatabase(): Promise { + const plans = await this.repository.findAllActive(); + if (plans.length > 0 && !this.cache.isDegraded()) { + await this.cache.set(ACTIVE_LIST_KEY, JSON.stringify(plans), this.defaultTtl); + for (const plan of plans) { + await this.setPlan(plan); + } + } + return plans; + } + + /** Writes plan metadata to Redis with plan-specific TTL. Returns false when Redis is down. */ + async setPlan(plan: PlanMetadata): Promise { + return this.cache.set( + this.planKey(plan.id), + JSON.stringify(plan), + this.resolveTtl(plan), + ); + } + + async invalidatePlan(id: string): Promise { + await this.cache.invalidate(this.planKey(id)); + await this.invalidateActiveList(); + } + + async invalidateAll(): Promise { + await this.cache.invalidateAll(); + } + + private async invalidateActiveList(): Promise { + await this.cache.invalidate(ACTIVE_LIST_KEY); + } + + async writeThroughUpdate( + id: string, + input: Parameters[1], + ): Promise { + const persisted = await this.repository.update(id, input); + if (persisted) { + if (persisted.isActive) { + await this.setPlan(persisted); + } else { + await this.invalidatePlan(id); + } + await this.invalidateActiveList(); + } else { + await this.invalidatePlan(id); + } + return persisted; + } + + async writeThroughCreate( + input: Parameters[0], + ): Promise { + const persisted = await this.repository.create(input); + await this.setPlan(persisted); + await this.invalidateActiveList(); + return persisted; + } + + async writeThroughDeactivate(id: string): Promise { + const persisted = await this.repository.deactivate(id); + if (persisted) { + await this.invalidatePlan(id); + await this.invalidateActiveList(); + } + return persisted; + } + + async warmActivePlans(): Promise<{ warmed: number; errors: number }> { + let warmed = 0; + let errors = 0; + + const healthy = await this.cache.isHealthy(); + if (!healthy) { + return { warmed: 0, errors: 1 }; + } + + const plans = await this.repository.findAllActive(); + + for (const plan of plans) { + const ok = await this.setPlan(plan); + if (ok) { + warmed++; + } else { + errors++; + } + } + + if (plans.length > 0) { + const listOk = await this.cache.set( + ACTIVE_LIST_KEY, + JSON.stringify(plans), + this.defaultTtl, + ); + if (!listOk) { + errors++; + } + } + + return { warmed, errors }; + } + + getMetrics() { + return this.cache.getMetrics(); + } + + prometheusMetrics(): string { + return this.cache.prometheusMetrics('subtrackr_plan_cache'); + } + + async isHealthy(): Promise { + return this.cache.isHealthy(); + } + + isDegraded(): boolean { + return this.cache.isDegraded(); + } +} diff --git a/backend/subscription/domain/PlanRepository.ts b/backend/subscription/domain/PlanRepository.ts new file mode 100644 index 00000000..62c90857 --- /dev/null +++ b/backend/subscription/domain/PlanRepository.ts @@ -0,0 +1,86 @@ +/** + * Plan persistence layer — database access for plan metadata. + */ + +import type { CreatePlanInput, PlanMetadata, UpdatePlanInput } from './types'; + +export interface IPlanRepository { + findById(id: string): Promise; + findAllActive(): Promise; + create(input: CreatePlanInput): Promise; + update(id: string, input: UpdatePlanInput): Promise; + deactivate(id: string): Promise; +} + +let idCounter = 0; + +function nextId(): string { + idCounter += 1; + return `plan-${idCounter}`; +} + +/** In-memory repository for tests and local development. */ +export class InMemoryPlanRepository implements IPlanRepository { + private readonly plans = new Map(); + + constructor(seed: PlanMetadata[] = []) { + for (const plan of seed) { + this.plans.set(plan.id, { ...plan }); + } + } + + async findById(id: string): Promise { + const plan = this.plans.get(id); + return plan ? { ...plan } : null; + } + + async findAllActive(): Promise { + return [...this.plans.values()] + .filter((p) => p.isActive) + .map((p) => ({ ...p })); + } + + async create(input: CreatePlanInput): Promise { + const now = new Date().toISOString(); + const plan: PlanMetadata = { + id: nextId(), + name: input.name, + price: input.price, + currency: input.currency, + billingCycle: input.billingCycle, + features: input.features ?? [], + limits: input.limits ?? {}, + isActive: true, + metadata: input.metadata ?? {}, + createdAt: now, + updatedAt: now, + }; + this.plans.set(plan.id, plan); + return { ...plan }; + } + + async update(id: string, input: UpdatePlanInput): Promise { + const existing = this.plans.get(id); + if (!existing) return null; + + const updated: PlanMetadata = { + ...existing, + ...input, + features: input.features ?? existing.features, + limits: input.limits ?? existing.limits, + metadata: input.metadata ? { ...existing.metadata, ...input.metadata } : existing.metadata, + updatedAt: new Date().toISOString(), + }; + this.plans.set(id, updated); + return { ...updated }; + } + + async deactivate(id: string): Promise { + return this.update(id, { isActive: false }); + } + + /** Test helper: reset ID counter between test files. */ + static resetIdCounter(): void { + idCounter = 0; + } +} diff --git a/backend/subscription/domain/PostgresPlanRepository.ts b/backend/subscription/domain/PostgresPlanRepository.ts new file mode 100644 index 00000000..21ca02a4 --- /dev/null +++ b/backend/subscription/domain/PostgresPlanRepository.ts @@ -0,0 +1,144 @@ +/** + * PostgreSQL plan repository — reads/writes plan metadata from the `plans` table. + */ + +import type { Pool } from '../../shared/db/connectionPool'; +import type { CreatePlanInput, PlanMetadata, PlanLimits, PlanMetadataConfig, UpdatePlanInput } from './types'; +import type { IPlanRepository } from './PlanRepository'; + +interface PlanDbRow { + id: string; + name: string; + price: number | string; + currency: string; + billingCycle: string; + features?: string[] | null; + limits?: PlanLimits | null; + metadata?: PlanMetadataConfig | null; + isActive?: boolean | null; + createdAt?: string | null; + updatedAt?: string | null; +} + +const SELECT_COLUMNS = ` + id, + name, + price, + currency, + billing_cycle AS "billingCycle", + COALESCE(features, '[]'::jsonb) AS features, + COALESCE(limits, '{}'::jsonb) AS limits, + COALESCE(metadata, '{}'::jsonb) AS metadata, + COALESCE(is_active, true) AS "isActive", + COALESCE(created_at, NOW())::text AS "createdAt", + COALESCE(updated_at, NOW())::text AS "updatedAt" +`; + +function rowToPlan(row: PlanDbRow): PlanMetadata { + return { + id: row.id, + name: row.name, + price: Number(row.price), + currency: row.currency, + billingCycle: row.billingCycle, + features: Array.isArray(row.features) ? row.features : [], + limits: row.limits ?? {}, + isActive: row.isActive ?? true, + metadata: row.metadata ?? {}, + createdAt: row.createdAt ?? new Date().toISOString(), + updatedAt: row.updatedAt ?? new Date().toISOString(), + }; +} + +export class PostgresPlanRepository implements IPlanRepository { + constructor(private readonly pool: Pool) {} + + async findById(id: string): Promise { + const result = await this.pool.query( + `SELECT ${SELECT_COLUMNS} FROM plans WHERE id = $1`, + [id], + ); + return result.rows[0] ? rowToPlan(result.rows[0]) : null; + } + + async findAllActive(): Promise { + const result = await this.pool.query( + `SELECT ${SELECT_COLUMNS} FROM plans WHERE COALESCE(is_active, true) = true ORDER BY id`, + ); + return result.rows.map(rowToPlan); + } + + async create(input: CreatePlanInput): Promise { + const result = await this.pool.query( + `INSERT INTO plans (name, price, currency, billing_cycle, features, limits, metadata, is_active) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb, true) + RETURNING ${SELECT_COLUMNS}`, + [ + input.name, + input.price, + input.currency, + input.billingCycle, + JSON.stringify(input.features ?? []), + JSON.stringify(input.limits ?? {}), + JSON.stringify(input.metadata ?? {}), + ], + ); + return rowToPlan(result.rows[0]); + } + + async update(id: string, input: UpdatePlanInput): Promise { + const existing = await this.findById(id); + if (!existing) return null; + + const merged: PlanMetadata = { + ...existing, + ...input, + features: input.features ?? existing.features, + limits: input.limits ?? existing.limits, + metadata: input.metadata ? { ...existing.metadata, ...input.metadata } : existing.metadata, + updatedAt: new Date().toISOString(), + }; + + const result = await this.pool.query( + `UPDATE plans + SET name = $2, price = $3, currency = $4, billing_cycle = $5, + features = $6::jsonb, limits = $7::jsonb, metadata = $8::jsonb, + is_active = $9, updated_at = NOW() + WHERE id = $1 + RETURNING ${SELECT_COLUMNS}`, + [ + id, + merged.name, + merged.price, + merged.currency, + merged.billingCycle, + JSON.stringify(merged.features), + JSON.stringify(merged.limits), + JSON.stringify(merged.metadata), + merged.isActive, + ], + ); + return result.rows[0] ? rowToPlan(result.rows[0]) : null; + } + + async deactivate(id: string): Promise { + return this.update(id, { isActive: false }); + } +} + +/** Maps PlanMetadata to the GraphQL PlanRow shape. */ +export function planMetadataToRow(plan: PlanMetadata): { + id: string; + name: string; + price: number; + currency: string; + billingCycle: string; +} { + return { + id: plan.id, + name: plan.name, + price: plan.price, + currency: plan.currency, + billingCycle: plan.billingCycle, + }; +} diff --git a/backend/subscription/domain/__tests__/PlanCacheService.test.ts b/backend/subscription/domain/__tests__/PlanCacheService.test.ts new file mode 100644 index 00000000..bf870e5a --- /dev/null +++ b/backend/subscription/domain/__tests__/PlanCacheService.test.ts @@ -0,0 +1,228 @@ +import { RedisCacheService, type RedisClient } from '../../../shared/cache/RedisCacheService'; +import { PlanCacheService } from '../PlanCacheService'; +import { InMemoryPlanRepository } from '../PlanRepository'; +import type { PlanMetadata } from '../types'; + +class FakeRedis implements RedisClient { + private store = new Map(); + public available = true; + public getCalls = 0; + + async get(key: string): Promise { + this.getCalls++; + if (!this.available) throw new Error('Redis down'); + return this.store.get(key) ?? null; + } + + async set(key: string, value: string, _mode: 'EX', _ttl: number): Promise<'OK'> { + if (!this.available) throw new Error('Redis down'); + this.store.set(key, value); + return 'OK'; + } + + async del(...keys: string[]): Promise { + if (!this.available) throw new Error('Redis down'); + let n = 0; + for (const k of keys) { + if (this.store.delete(k)) n++; + } + return n; + } + + async keys(pattern: string): Promise { + const prefix = pattern.replace(/\*$/, ''); + return [...this.store.keys()].filter((k) => k.startsWith(prefix)); + } + + async ping(): Promise { + if (!this.available) throw new Error('Redis down'); + return 'PONG'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } +} + +const makePlan = (overrides: Partial = {}): PlanMetadata => ({ + id: 'plan-basic', + name: 'Basic', + price: 9.99, + currency: 'USD', + billingCycle: 'monthly', + features: ['feature-a'], + limits: { maxSubscriptions: 10 }, + isActive: true, + metadata: {}, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + ...overrides, +}); + +describe('PlanCacheService', () => { + let redis: FakeRedis; + let repository: InMemoryPlanRepository; + let svc: PlanCacheService; + let dbReads: number; + + beforeEach(() => { + InMemoryPlanRepository.resetIdCounter(); + redis = new FakeRedis(); + const seed = makePlan(); + repository = new InMemoryPlanRepository([seed]); + dbReads = 0; + const trackedRepo = { + findById: async (id: string) => { + dbReads++; + return repository.findById(id); + }, + findAllActive: () => repository.findAllActive(), + create: (input: Parameters[0]) => repository.create(input), + update: (id: string, input: Parameters[1]) => + repository.update(id, input), + deactivate: (id: string) => repository.deactivate(id), + }; + svc = new PlanCacheService(redis, trackedRepo, { defaultTtlSeconds: 3600 }); + }); + + it('getPlan loads from DB on cache miss and caches result', async () => { + const plan = await svc.getPlan('plan-basic'); + expect(plan?.name).toBe('Basic'); + expect(dbReads).toBe(1); + + dbReads = 0; + const cached = await svc.getPlan('plan-basic'); + expect(cached?.name).toBe('Basic'); + expect(dbReads).toBe(0); + expect(svc.getMetrics().hits).toBeGreaterThan(0); + }); + + it('getPlan uses single-flight for concurrent misses', async () => { + let concurrentReads = 0; + const slowRepo = { + findById: async (id: string) => { + concurrentReads++; + await new Promise((r) => setTimeout(r, 30)); + return repository.findById(id); + }, + findAllActive: () => repository.findAllActive(), + create: (input: Parameters[0]) => repository.create(input), + update: (id: string, input: Parameters[1]) => + repository.update(id, input), + deactivate: (id: string) => repository.deactivate(id), + }; + + const svc2 = new PlanCacheService(redis, slowRepo, { defaultTtlSeconds: 3600 }); + await Promise.all([ + svc2.getPlan('plan-basic'), + svc2.getPlan('plan-basic'), + svc2.getPlan('plan-basic'), + ]); + + expect(concurrentReads).toBe(1); + }); + + it('setPlan and invalidatePlan work correctly', async () => { + const plan = makePlan({ id: 'plan-pro', name: 'Pro' }); + await svc.setPlan(plan); + await svc.invalidatePlan('plan-pro'); + + const key = 'subtrackr:plan:id:plan-pro'; + expect(await redis.get(key)).toBeNull(); + }); + + it('invalidateAll clears all plan keys', async () => { + await svc.setPlan(makePlan({ id: 'p1' })); + await svc.setPlan(makePlan({ id: 'p2' })); + await svc.invalidateAll(); + expect(await redis.get('subtrackr:plan:id:p1')).toBeNull(); + expect(await redis.get('subtrackr:plan:id:p2')).toBeNull(); + }); + + it('writeThroughUpdate persists to DB and refreshes cache', async () => { + const updated = await svc.writeThroughUpdate('plan-basic', { price: 19.99 }); + expect(updated?.price).toBe(19.99); + + const fromDb = await repository.findById('plan-basic'); + expect(fromDb?.price).toBe(19.99); + + const cached = await svc.getPlan('plan-basic'); + expect(cached?.price).toBe(19.99); + }); + + it('writeThroughDeactivate removes plan from active cache', async () => { + await svc.getPlan('plan-basic'); + await svc.writeThroughDeactivate('plan-basic'); + + const fromDb = await repository.findById('plan-basic'); + expect(fromDb?.isActive).toBe(false); + }); + + it('uses per-plan cacheTTL from metadata when set', async () => { + const plan = makePlan({ metadata: { cacheTTL: 120 } }); + const setSpy = jest.spyOn(RedisCacheService.prototype, 'set'); + await svc.setPlan(plan); + expect(setSpy).toHaveBeenCalledWith('id:plan-basic', expect.any(String), 120); + setSpy.mockRestore(); + }); + + it('warmActivePlans pre-loads active plans', async () => { + await repository.create({ + name: 'Enterprise', + price: 99, + currency: 'USD', + billingCycle: 'yearly', + }); + + const result = await svc.warmActivePlans(); + expect(result.warmed).toBeGreaterThanOrEqual(2); + expect(result.errors).toBe(0); + }); + + it('falls back to DB when Redis is unavailable', async () => { + redis.available = false; + const plan = await svc.getPlan('plan-basic'); + expect(plan?.id).toBe('plan-basic'); + }); + + it('exports Prometheus metrics', () => { + const output = svc.prometheusMetrics(); + expect(output).toContain('subtrackr_plan_cache_hits_total'); + expect(output).toContain('subtrackr_plan_cache_latency_ms'); + }); + + it('returns null for unknown plan', async () => { + expect(await svc.getPlan('does-not-exist')).toBeNull(); + }); + + it('does not cache inactive plans on read', async () => { + const inactive = makePlan({ id: 'inactive-1', isActive: false }); + const repo = new InMemoryPlanRepository([inactive]); + const localRedis = new FakeRedis(); + const localSvc = new PlanCacheService(localRedis, repo); + + const plan = await localSvc.getPlan('inactive-1'); + expect(plan?.isActive).toBe(false); + expect(localRedis.getCalls).toBe(1); + expect(await localRedis.get('subtrackr:plan:id:inactive-1')).toBeNull(); + }); + + it('getActivePlans caches the active plan list', async () => { + const first = await svc.getActivePlans(); + expect(first.length).toBeGreaterThanOrEqual(1); + + const findAllSpy = jest.spyOn(repository, 'findAllActive'); + const second = await svc.getActivePlans(); + expect(second.length).toBe(first.length); + expect(findAllSpy).not.toHaveBeenCalled(); + findAllSpy.mockRestore(); + }); + + it('warmActivePlans records errors when Redis set fails', async () => { + await svc.isHealthy(); + redis.available = false; + const result = await svc.warmActivePlans(); + expect(result.warmed).toBe(0); + expect(result.errors).toBeGreaterThan(0); + }); +}); diff --git a/backend/subscription/domain/__tests__/PlanRepository.test.ts b/backend/subscription/domain/__tests__/PlanRepository.test.ts new file mode 100644 index 00000000..45869922 --- /dev/null +++ b/backend/subscription/domain/__tests__/PlanRepository.test.ts @@ -0,0 +1,25 @@ +import { InMemoryPlanRepository } from '../PlanRepository'; + +describe('InMemoryPlanRepository metadata merge', () => { + beforeEach(() => { + InMemoryPlanRepository.resetIdCounter(); + }); + + it('merges metadata fields on partial update', async () => { + const repo = new InMemoryPlanRepository(); + const created = await repo.create({ + name: 'Pro', + price: 20, + currency: 'USD', + billingCycle: 'monthly', + metadata: { cacheTTL: 3600, tier: 'pro' }, + }); + + const updated = await repo.update(created.id, { + metadata: { cacheTTL: 120 }, + }); + + expect(updated?.metadata.cacheTTL).toBe(120); + expect(updated?.metadata.tier).toBe('pro'); + }); +}); diff --git a/backend/subscription/domain/__tests__/PostgresPlanRepository.test.ts b/backend/subscription/domain/__tests__/PostgresPlanRepository.test.ts new file mode 100644 index 00000000..ea702b9d --- /dev/null +++ b/backend/subscription/domain/__tests__/PostgresPlanRepository.test.ts @@ -0,0 +1,44 @@ +import { PostgresPlanRepository } from '../PostgresPlanRepository'; +import type { Pool } from '../../../shared/db/connectionPool'; + +describe('PostgresPlanRepository', () => { + it('maps database rows to PlanMetadata', async () => { + const pool = { + query: jest.fn(async () => ({ + rows: [ + { + id: 'plan-1', + name: 'Starter', + price: '9.99', + currency: 'USD', + billingCycle: 'monthly', + features: ['a'], + limits: { maxUsers: 5 }, + metadata: { cacheTTL: 600 }, + isActive: true, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ], + rowCount: 1, + })), + } as unknown as Pool; + + const repo = new PostgresPlanRepository(pool); + const plan = await repo.findById('plan-1'); + + expect(plan).toEqual({ + id: 'plan-1', + name: 'Starter', + price: 9.99, + currency: 'USD', + billingCycle: 'monthly', + features: ['a'], + limits: { maxUsers: 5 }, + metadata: { cacheTTL: 600 }, + isActive: true, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + }); +}); diff --git a/backend/subscription/domain/index.ts b/backend/subscription/domain/index.ts new file mode 100644 index 00000000..3ca1156d --- /dev/null +++ b/backend/subscription/domain/index.ts @@ -0,0 +1,12 @@ +export type { + PlanMetadata, + PlanLimits, + PlanMetadataConfig, + CreatePlanInput, + UpdatePlanInput, +} from './types'; +export type { IPlanRepository } from './PlanRepository'; +export { InMemoryPlanRepository } from './PlanRepository'; +export { PostgresPlanRepository, planMetadataToRow } from './PostgresPlanRepository'; +export { PlanCacheService } from './PlanCacheService'; +export type { PlanCacheConfig } from './PlanCacheService'; diff --git a/backend/subscription/domain/types.ts b/backend/subscription/domain/types.ts new file mode 100644 index 00000000..7d4f72f1 --- /dev/null +++ b/backend/subscription/domain/types.ts @@ -0,0 +1,51 @@ +/** + * Subscription plan metadata domain types. + */ + +export interface PlanLimits { + maxSubscriptions?: number; + maxUsers?: number; + maxApiCallsPerMonth?: number; + storageGb?: number; +} + +export interface PlanMetadataConfig { + /** Per-plan cache TTL override in seconds. */ + cacheTTL?: number; + [key: string]: unknown; +} + +export interface PlanMetadata { + id: string; + name: string; + price: number; + currency: string; + billingCycle: string; + features: string[]; + limits: PlanLimits; + isActive: boolean; + metadata: PlanMetadataConfig; + createdAt: string; + updatedAt: string; +} + +export interface CreatePlanInput { + name: string; + price: number; + currency: string; + billingCycle: string; + features?: string[]; + limits?: PlanLimits; + metadata?: PlanMetadataConfig; +} + +export interface UpdatePlanInput { + name?: string; + price?: number; + currency?: string; + billingCycle?: string; + features?: string[]; + limits?: PlanLimits; + metadata?: PlanMetadataConfig; + isActive?: boolean; +} diff --git a/backend/subscription/jobs/__tests__/cacheWarming.test.ts b/backend/subscription/jobs/__tests__/cacheWarming.test.ts new file mode 100644 index 00000000..251955da --- /dev/null +++ b/backend/subscription/jobs/__tests__/cacheWarming.test.ts @@ -0,0 +1,95 @@ +import { runPlanCacheWarming } from '../cacheWarming'; +import { PlanCacheService } from '../../domain/PlanCacheService'; +import { InMemoryPlanRepository } from '../../domain/PlanRepository'; +import type { RedisClient } from '../../../shared/cache/types'; +import type { PlanMetadata } from '../../domain/types'; + +class FakeRedis implements RedisClient { + public healthy = true; + private store = new Map(); + + async get(key: string): Promise { + if (!this.healthy) throw new Error('down'); + return this.store.get(key) ?? null; + } + + async set(key: string, value: string, _mode: 'EX', _ttl: number): Promise<'OK'> { + if (!this.healthy) throw new Error('down'); + this.store.set(key, value); + return 'OK'; + } + + async del(...keys: string[]): Promise { + let n = 0; + for (const k of keys) { + if (this.store.delete(k)) n++; + } + return n; + } + + async keys(pattern: string): Promise { + const prefix = pattern.replace(/\*$/, ''); + return [...this.store.keys()].filter((k) => k.startsWith(prefix)); + } + + async ping(): Promise { + if (!this.healthy) throw new Error('down'); + return 'PONG'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } +} + +const activePlan: PlanMetadata = { + id: 'plan-a', + name: 'A', + price: 1, + currency: 'USD', + billingCycle: 'monthly', + features: [], + limits: {}, + isActive: true, + metadata: {}, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}; + +describe('cacheWarming job', () => { + it('warms active plans on deploy', async () => { + const redis = new FakeRedis(); + const repo = new InMemoryPlanRepository([activePlan]); + const planCache = new PlanCacheService(redis, repo); + + const result = await runPlanCacheWarming(planCache); + expect(result.skipped).toBe(false); + expect(result.warmed).toBe(1); + expect(result.errors).toBe(0); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('skips warming when Redis is unhealthy', async () => { + const redis = new FakeRedis(); + redis.healthy = false; + const repo = new InMemoryPlanRepository([activePlan]); + const planCache = new PlanCacheService(redis, repo); + + const result = await runPlanCacheWarming(planCache); + expect(result.skipped).toBe(true); + expect(result.reason).toBe('Redis unavailable'); + expect(result.warmed).toBe(0); + }); + + it('invokes onComplete callback', async () => { + const redis = new FakeRedis(); + const repo = new InMemoryPlanRepository([activePlan]); + const planCache = new PlanCacheService(redis, repo); + const onComplete = jest.fn(); + + await runPlanCacheWarming(planCache, { onComplete }); + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ warmed: 1, skipped: false }), + ); + }); +}); diff --git a/backend/subscription/jobs/cacheWarming.ts b/backend/subscription/jobs/cacheWarming.ts new file mode 100644 index 00000000..d633460a --- /dev/null +++ b/backend/subscription/jobs/cacheWarming.ts @@ -0,0 +1,63 @@ +/** + * Cache warming job — pre-loads active plan metadata into Redis on deploy. + * + * Invoke from application bootstrap or a deploy hook: + * await runPlanCacheWarming(planCacheService); + */ + +import type { PlanCacheService } from '../domain/PlanCacheService'; + +export interface CacheWarmingResult { + warmed: number; + errors: number; + durationMs: number; + skipped: boolean; + reason?: string; +} + +export interface CacheWarmingOptions { + /** Skip warming when Redis is unreachable (default: true). */ + skipWhenUnhealthy?: boolean; + onComplete?: (result: CacheWarmingResult) => void; +} + +/** + * Warms the plan metadata cache from the database. + * Designed to run once on service deploy / process startup. + */ +export async function runPlanCacheWarming( + planCache: PlanCacheService, + options: CacheWarmingOptions = {}, +): Promise { + const skipWhenUnhealthy = options.skipWhenUnhealthy ?? true; + const start = Date.now(); + + if (skipWhenUnhealthy) { + const healthy = await planCache.isHealthy(); + if (!healthy) { + const result: CacheWarmingResult = { + warmed: 0, + errors: 0, + durationMs: Date.now() - start, + skipped: true, + reason: 'Redis unavailable', + }; + options.onComplete?.(result); + return result; + } + } + + const { warmed, errors } = await planCache.warmActivePlans(); + const result: CacheWarmingResult = { + warmed, + errors, + durationMs: Date.now() - start, + skipped: false, + }; + + options.onComplete?.(result); + return result; +} + +/** Cron-friendly alias matching technical scope naming. */ +export const cacheWarmingJob = runPlanCacheWarming; diff --git a/backend/subscription/planCacheRegistry.ts b/backend/subscription/planCacheRegistry.ts new file mode 100644 index 00000000..e075fd37 --- /dev/null +++ b/backend/subscription/planCacheRegistry.ts @@ -0,0 +1,16 @@ +/** + * Registry for the shared PlanCacheService instance. + * GraphQL loaders and resolvers use this when the cache has been bootstrapped. + */ + +import type { PlanCacheService } from './domain/PlanCacheService'; + +let planCacheInstance: PlanCacheService | null = null; + +export function setPlanCacheService(service: PlanCacheService | null): void { + planCacheInstance = service; +} + +export function getPlanCacheService(): PlanCacheService | null { + return planCacheInstance; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d7e3f1a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +# Local development services for SubTrackr backend. +# +# Usage: +# docker compose up -d +# npm run server:start +# +# Environment (optional .env): +# DB_HOST=localhost DB_PORT=5432 DB_NAME=subtrackr DB_USER=postgres DB_PASSWORD=postgres +# REDIS_HOST=localhost REDIS_PORT=6379 + +services: + redis: + image: redis:7-alpine + container_name: subtrackr-redis + ports: + - '${REDIS_PORT:-6379}:6379' + command: ['redis-server', '--save', '', '--appendonly', 'no'] + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - redis-data:/data + + postgres: + image: postgres:16-alpine + container_name: subtrackr-postgres + ports: + - '${DB_PORT:-5432}:5432' + environment: + POSTGRES_DB: ${DB_NAME:-subtrackr} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-subtrackr}'] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + redis-data: + postgres-data: diff --git a/package-lock.json b/package-lock.json index b59c73b5..2f070f58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "hasInstallScript": true, "dependencies": { + "@graphql-tools/schema": "^10.0.31", "@react-native-async-storage/async-storage": "2.1.2", "@react-native-community/datetimepicker": "^9.1.0", "@react-native-community/netinfo": "11.4.1", @@ -32,7 +33,11 @@ "expo-linking": "~7.1.7", "expo-notifications": "^0.31.5", "expo-status-bar": "~2.2.3", + "graphql": "^16.13.2", + "graphql-http": "^1.22.4", "i18next": "^26.0.8", + "ioredis": "^5.11.1", + "pg": "^8.16.0", "react": "19.2.5", "react-i18next": "^17.0.6", "react-native": "0.85.2", @@ -78,7 +83,6 @@ "eslint": "^8.57.0", "eslint-config-expo": "^7.0.0", "eslint-plugin-prettier": "^5.1.3", - "graphql": "^16.13.2", "husky": "^9.1.7", "jest": "^29.7.0", "jest-circus": "^30.3.0", @@ -89,6 +93,7 @@ "semantic-release": "^24.2.9", "size-limit": "^11.1.4", "ts-jest": "^29.4.11", + "tsx": "^4.20.3", "typechain": "^8.3.2", "typescript": "~5.8.3" } @@ -2012,6 +2017,448 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3472,6 +3919,75 @@ "dev": true, "license": "MIT" }, + "node_modules/@graphql-tools/merge": { + "version": "9.1.9", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.9.tgz", + "integrity": "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@graphql-tools/schema": { + "version": "10.0.33", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.33.tgz", + "integrity": "sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.1.9", + "@graphql-tools/utils": "^11.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@graphql-tools/utils": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", + "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@graphql-typed-document-node/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", @@ -3563,6 +4079,12 @@ "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", "license": "MIT" }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -11780,6 +12302,24 @@ "tslib": "1.14.1" } }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@wix-pilot/core": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@wix-pilot/core/-/core-3.4.2.tgz", @@ -14200,6 +14740,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -14911,6 +15460,24 @@ "node-fetch": "^2.7.0" } }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/cross-inspect/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -15396,6 +15963,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -16637,6 +17213,48 @@ "node": ">=0.12" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -19284,12 +19902,26 @@ "version": "16.13.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", - "dev": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-http": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/graphql-http/-/graphql-http-1.22.4.tgz", + "integrity": "sha512-OC3ucK988teMf+Ak/O+ZJ0N2ukcgrEurypp8ePyJFWq83VzwRAmHxxr+XxrMpxO/FIwI4a7m/Fzv3tWGJv0wPA==", + "license": "MIT", + "workspaces": [ + "implementations/**/*" + ], + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/graphql-request": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz", @@ -20527,6 +21159,28 @@ "fp-ts": "^1.0.0" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -29909,6 +30563,95 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -30346,6 +31089,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -31831,6 +32613,27 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reduce-flatten": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz", @@ -34109,6 +34912,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -35684,6 +36493,25 @@ "integrity": "sha512-Tyrf5mxF8Ofs1tNoxA13lFeZ2Zrbd6cKbuH3V+MQ5sb6DtBj5FjrXVsRWT8YvNAQTqNoz66dz1WsbigI22aEnw==", "license": "MIT" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 6c4a0d73..9af20444 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "load:test:billing": "k6 run load-tests/run.js --env SCENARIO=billing", "load:test:user": "k6 run load-tests/run.js --env SCENARIO=user", "load:test:contract": "k6 run load-tests/run.js --env SCENARIO=contract", + "server:start": "npx tsx backend/server.ts", + "server:dev": "npx tsx watch backend/server.ts", "e2e:build-ios": "detox build -c ios.sim.release", "e2e:test-ios": "detox test -c ios.sim.release", "e2e:test-ios:parallel": "detox test -c ios.sim.release --workers 2", @@ -77,6 +79,10 @@ "bcryptjs": "^3.0.3", "ethers": "^5.8.0", "expo": "~53.0.20", + "@graphql-tools/schema": "^10.0.31", + "graphql": "^16.13.2", + "graphql-http": "^1.22.4", + "pg": "^8.16.0", "expo-application": "~6.1.5", "expo-clipboard": "~7.1.5", "expo-dev-client": "~5.2.4", @@ -85,6 +91,7 @@ "expo-notifications": "^0.31.5", "expo-status-bar": "~2.2.3", "i18next": "^26.0.8", + "ioredis": "^5.11.1", "react": "19.2.5", "react-i18next": "^17.0.6", "react-native": "0.85.2", @@ -101,8 +108,6 @@ }, "devDependencies": { "@babel/core": "^7.29.0", - "babel-plugin-module-resolver": "^5.0.2", - "babel-plugin-transform-remove-console": "^6.3.0", "@commitlint/cli": "^20.5.2", "@commitlint/config-conventional": "^20.5.0", "@config-plugins/detox": "^11.0.0", @@ -125,12 +130,13 @@ "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "babel-plugin-module-resolver": "^5.0.2", + "babel-plugin-transform-remove-console": "^6.3.0", "cross-env": "^10.1.0", "detox": "^20.51.0", "eslint": "^8.57.0", "eslint-config-expo": "^7.0.0", "eslint-plugin-prettier": "^5.1.3", - "graphql": "^16.13.2", "husky": "^9.1.7", "jest": "^29.7.0", "jest-circus": "^30.3.0", @@ -141,6 +147,7 @@ "semantic-release": "^24.2.9", "size-limit": "^11.1.4", "ts-jest": "^29.4.11", + "tsx": "^4.20.3", "typechain": "^8.3.2", "typescript": "~5.8.3" },