|
1 | 1 | import { Logger } from '@pgpmjs/logger'; |
2 | 2 | import { LRUCache } from 'lru-cache'; |
3 | | -import type { StorageModuleConfig } from './types'; |
| 3 | +import type { StorageModuleConfig, BucketConfig } from './types'; |
4 | 4 |
|
5 | 5 | const log = new Logger('graphile-presigned-url:cache'); |
6 | 6 |
|
@@ -125,9 +125,115 @@ export async function getStorageModuleConfig( |
125 | 125 | return config; |
126 | 126 | } |
127 | 127 |
|
| 128 | +// --- Bucket metadata cache --- |
| 129 | + |
| 130 | +/** |
| 131 | + * LRU cache for per-database bucket metadata. |
| 132 | + * |
| 133 | + * Buckets are essentially static config — created once and rarely changed. |
| 134 | + * Caching avoids a DB query on every requestUploadUrl call. The bucket |
| 135 | + * lookup in the plugin runs under RLS, but since AuthzEntityMembership |
| 136 | + * grants all org members access to all org buckets, and the cached data |
| 137 | + * is just config (mime types, size limits), bypassing RLS on cache hits |
| 138 | + * is safe. The important RLS is on the files table (INSERT/UPDATE), |
| 139 | + * which is never cached. |
| 140 | + * |
| 141 | + * Keys: `bucket:${databaseId}:${bucketKey}` |
| 142 | + * TTL: same as storage module cache (5min dev / 1hr prod) |
| 143 | + */ |
| 144 | +const bucketCache = new LRUCache<string, BucketConfig>({ |
| 145 | + max: 500, // many buckets across many databases |
| 146 | + ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS, |
| 147 | + updateAgeOnGet: true, |
| 148 | +}); |
| 149 | + |
| 150 | +/** |
| 151 | + * Resolve bucket metadata for a given database + bucket key, using the LRU cache. |
| 152 | + * |
| 153 | + * On cache miss, queries the bucket table (RLS-enforced via pgSettings on |
| 154 | + * the pgClient). On cache hit, returns the cached metadata directly. |
| 155 | + * |
| 156 | + * @param pgClient - A pg client from the Graphile context |
| 157 | + * @param storageConfig - The resolved StorageModuleConfig for this database |
| 158 | + * @param databaseId - The metaschema database UUID (used as cache key prefix) |
| 159 | + * @param bucketKey - The bucket key (e.g., "public", "private") |
| 160 | + * @returns BucketConfig or null if the bucket doesn't exist / isn't accessible |
| 161 | + */ |
| 162 | +export async function getBucketConfig( |
| 163 | + pgClient: { query: (sql: string, params: unknown[]) => Promise<{ rows: unknown[] }> }, |
| 164 | + storageConfig: StorageModuleConfig, |
| 165 | + databaseId: string, |
| 166 | + bucketKey: string, |
| 167 | +): Promise<BucketConfig | null> { |
| 168 | + const cacheKey = `bucket:${databaseId}:${bucketKey}`; |
| 169 | + const cached = bucketCache.get(cacheKey); |
| 170 | + if (cached) { |
| 171 | + return cached; |
| 172 | + } |
| 173 | + |
| 174 | + log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`); |
| 175 | + |
| 176 | + const result = await pgClient.query( |
| 177 | + `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size |
| 178 | + FROM ${storageConfig.bucketsQualifiedName} |
| 179 | + WHERE key = $1 |
| 180 | + LIMIT 1`, |
| 181 | + [bucketKey], |
| 182 | + ); |
| 183 | + |
| 184 | + if (result.rows.length === 0) { |
| 185 | + return null; |
| 186 | + } |
| 187 | + |
| 188 | + const row = result.rows[0] as { |
| 189 | + id: string; |
| 190 | + key: string; |
| 191 | + type: string; |
| 192 | + is_public: boolean; |
| 193 | + owner_id: string; |
| 194 | + allowed_mime_types: string[] | null; |
| 195 | + max_file_size: number | null; |
| 196 | + }; |
| 197 | + |
| 198 | + const config: BucketConfig = { |
| 199 | + id: row.id, |
| 200 | + key: row.key, |
| 201 | + type: row.type as BucketConfig['type'], |
| 202 | + is_public: row.is_public, |
| 203 | + owner_id: row.owner_id, |
| 204 | + allowed_mime_types: row.allowed_mime_types, |
| 205 | + max_file_size: row.max_file_size, |
| 206 | + }; |
| 207 | + |
| 208 | + bucketCache.set(cacheKey, config); |
| 209 | + log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`); |
| 210 | + |
| 211 | + return config; |
| 212 | +} |
| 213 | + |
128 | 214 | /** |
129 | | - * Clear the storage module cache (useful for testing or schema changes). |
| 215 | + * Clear the storage module cache AND bucket cache. |
| 216 | + * Useful for testing or schema changes. |
130 | 217 | */ |
131 | 218 | export function clearStorageModuleCache(): void { |
132 | 219 | storageModuleCache.clear(); |
| 220 | + bucketCache.clear(); |
| 221 | +} |
| 222 | + |
| 223 | +/** |
| 224 | + * Clear cached bucket entries for a specific database. |
| 225 | + * Useful when bucket config changes are detected. |
| 226 | + */ |
| 227 | +export function clearBucketCache(databaseId?: string): void { |
| 228 | + if (!databaseId) { |
| 229 | + bucketCache.clear(); |
| 230 | + return; |
| 231 | + } |
| 232 | + // Evict all entries for this database |
| 233 | + const prefix = `bucket:${databaseId}:`; |
| 234 | + for (const key of bucketCache.keys()) { |
| 235 | + if (key.startsWith(prefix)) { |
| 236 | + bucketCache.delete(key); |
| 237 | + } |
| 238 | + } |
133 | 239 | } |
0 commit comments