@theihtisham/mcp-server-firebase 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +79 -0
  5. package/dist/services/firebase.d.ts +14 -0
  6. package/dist/services/firebase.js +163 -0
  7. package/dist/tools/auth.d.ts +3 -0
  8. package/dist/tools/auth.js +346 -0
  9. package/dist/tools/firestore.d.ts +3 -0
  10. package/dist/tools/firestore.js +802 -0
  11. package/dist/tools/functions.d.ts +3 -0
  12. package/dist/tools/functions.js +168 -0
  13. package/dist/tools/index.d.ts +10 -0
  14. package/dist/tools/index.js +30 -0
  15. package/dist/tools/messaging.d.ts +3 -0
  16. package/dist/tools/messaging.js +296 -0
  17. package/dist/tools/realtime-db.d.ts +4 -0
  18. package/dist/tools/realtime-db.js +271 -0
  19. package/dist/tools/storage.d.ts +3 -0
  20. package/dist/tools/storage.js +279 -0
  21. package/dist/tools/types.d.ts +11 -0
  22. package/dist/tools/types.js +3 -0
  23. package/dist/utils/cache.d.ts +16 -0
  24. package/dist/utils/cache.js +75 -0
  25. package/dist/utils/errors.d.ts +15 -0
  26. package/dist/utils/errors.js +94 -0
  27. package/dist/utils/index.d.ts +5 -0
  28. package/dist/utils/index.js +37 -0
  29. package/dist/utils/pagination.d.ts +28 -0
  30. package/dist/utils/pagination.js +75 -0
  31. package/dist/utils/validation.d.ts +22 -0
  32. package/dist/utils/validation.js +172 -0
  33. package/package.json +53 -0
  34. package/src/index.ts +94 -0
  35. package/src/services/firebase.ts +140 -0
  36. package/src/tools/auth.ts +375 -0
  37. package/src/tools/firestore.ts +931 -0
  38. package/src/tools/functions.ts +189 -0
  39. package/src/tools/index.ts +24 -0
  40. package/src/tools/messaging.ts +324 -0
  41. package/src/tools/realtime-db.ts +307 -0
  42. package/src/tools/storage.ts +314 -0
  43. package/src/tools/types.ts +10 -0
  44. package/src/utils/cache.ts +82 -0
  45. package/src/utils/errors.ts +110 -0
  46. package/src/utils/index.ts +4 -0
  47. package/src/utils/pagination.ts +105 -0
  48. package/src/utils/validation.ts +212 -0
  49. package/tests/cache.test.ts +139 -0
  50. package/tests/errors.test.ts +132 -0
  51. package/tests/firebase-service.test.ts +46 -0
  52. package/tests/pagination.test.ts +26 -0
  53. package/tests/tools.test.ts +226 -0
  54. package/tests/validation.test.ts +216 -0
  55. package/tsconfig.json +26 -0
  56. package/vitest.config.ts +15 -0
@@ -0,0 +1,82 @@
1
+ interface CacheEntry<T> {
2
+ value: T;
3
+ expiresAt: number;
4
+ }
5
+
6
+ export class LRUCache<T> {
7
+ private cache = new Map<string, CacheEntry<T>>();
8
+ private readonly maxSize: number;
9
+ private readonly defaultTtlMs: number;
10
+
11
+ constructor(maxSize = 200, defaultTtlMs = 5 * 60 * 1000) {
12
+ this.maxSize = maxSize;
13
+ this.defaultTtlMs = defaultTtlMs;
14
+ }
15
+
16
+ get(key: string): T | undefined {
17
+ const entry = this.cache.get(key);
18
+ if (!entry) return undefined;
19
+ if (Date.now() > entry.expiresAt) {
20
+ this.cache.delete(key);
21
+ return undefined;
22
+ }
23
+ // Move to end (most recently used)
24
+ this.cache.delete(key);
25
+ this.cache.set(key, entry);
26
+ return entry.value;
27
+ }
28
+
29
+ set(key: string, value: T, ttlMs?: number): void {
30
+ // Remove if exists to update position
31
+ this.cache.delete(key);
32
+ // Evict oldest entries if at capacity
33
+ while (this.cache.size >= this.maxSize) {
34
+ const firstKey = this.cache.keys().next().value;
35
+ if (firstKey !== undefined) {
36
+ this.cache.delete(firstKey);
37
+ }
38
+ }
39
+ this.cache.set(key, {
40
+ value,
41
+ expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
42
+ });
43
+ }
44
+
45
+ delete(key: string): boolean {
46
+ return this.cache.delete(key);
47
+ }
48
+
49
+ clear(): void {
50
+ this.cache.clear();
51
+ }
52
+
53
+ get size(): number {
54
+ return this.cache.size;
55
+ }
56
+
57
+ has(key: string): boolean {
58
+ const entry = this.cache.get(key);
59
+ if (!entry) return false;
60
+ if (Date.now() > entry.expiresAt) {
61
+ this.cache.delete(key);
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ // Invalidate keys matching a prefix (useful for collection-level cache invalidation)
68
+ invalidatePrefix(prefix: string): number {
69
+ let count = 0;
70
+ for (const key of this.cache.keys()) {
71
+ if (key.startsWith(prefix)) {
72
+ this.cache.delete(key);
73
+ count++;
74
+ }
75
+ }
76
+ return count;
77
+ }
78
+ }
79
+
80
+ // Singleton caches for the server
81
+ export const firestoreCache = new LRUCache<unknown>(500, 30_000); // 30s TTL for Firestore reads
82
+ export const schemaCache = new LRUCache<unknown>(50, 10 * 60 * 1000); // 10min TTL for schema inference
@@ -0,0 +1,110 @@
1
+ export interface FirebaseErrorContext {
2
+ service: string;
3
+ operation: string;
4
+ suggestion?: string;
5
+ details?: string;
6
+ }
7
+
8
+ export class FirebaseToolError extends Error {
9
+ public readonly context: FirebaseErrorContext;
10
+
11
+ constructor(message: string, context: FirebaseErrorContext) {
12
+ super(message);
13
+ this.name = 'FirebaseToolError';
14
+ this.context = context;
15
+ }
16
+
17
+ toStructuredMessage(): string {
18
+ const lines: string[] = [
19
+ `[${this.context.service}/${this.context.operation}] ${this.message}`,
20
+ ];
21
+ if (this.context.details) {
22
+ lines.push(` Details: ${this.context.details}`);
23
+ }
24
+ if (this.context.suggestion) {
25
+ lines.push(` Suggestion: ${this.context.suggestion}`);
26
+ }
27
+ return lines.join('\n');
28
+ }
29
+ }
30
+
31
+ export function handleFirebaseError(error: unknown, service: string, operation: string): never {
32
+ if (error instanceof FirebaseToolError) {
33
+ throw error;
34
+ }
35
+
36
+ const err = error as Error & { code?: string; status?: number };
37
+
38
+ let suggestion: string | undefined;
39
+ let details: string | undefined;
40
+
41
+ // Map common Firebase error codes to actionable suggestions
42
+ if (err.code) {
43
+ switch (err.code) {
44
+ case 'permission-denied':
45
+ case 'PERMISSION_DENIED':
46
+ suggestion = 'Check that your service account has the required IAM permissions for this operation.';
47
+ break;
48
+ case 'not-found':
49
+ case 'NOT_FOUND':
50
+ suggestion = 'Verify that the resource (document, collection, user, or file) exists and the path is correct.';
51
+ break;
52
+ case 'already-exists':
53
+ case 'ALREADY_EXISTS':
54
+ suggestion = 'A resource with this identifier already exists. Use update instead of create, or provide a different ID.';
55
+ break;
56
+ case 'invalid-argument':
57
+ case 'INVALID_ARGUMENT':
58
+ suggestion = 'Check that all required fields are provided and values are in the correct format.';
59
+ break;
60
+ case 'resource-exhausted':
61
+ case 'RESOURCE_EXHAUSTED':
62
+ suggestion = 'Firebase quota exceeded. Wait and retry, or upgrade your Firebase plan.';
63
+ break;
64
+ case 'unauthenticated':
65
+ case 'UNAUTHENTICATED':
66
+ suggestion = 'Service account authentication failed. Verify FIREBASE_SERVICE_ACCOUNT_PATH or FIREBASE_SERVICE_ACCOUNT_KEY is set correctly.';
67
+ break;
68
+ case 'unavailable':
69
+ case 'UNAVAILABLE':
70
+ suggestion = 'Firebase service is temporarily unavailable. Retry with exponential backoff.';
71
+ break;
72
+ case 'deadline-exceeded':
73
+ case 'DEADLINE_EXCEEDED':
74
+ suggestion = 'Operation timed out. Try reducing the data size or splitting into smaller batches.';
75
+ break;
76
+ default:
77
+ break;
78
+ }
79
+ }
80
+
81
+ details = err.message;
82
+
83
+ throw new FirebaseToolError(
84
+ err.message || 'An unknown error occurred.',
85
+ { service, operation, suggestion, details }
86
+ );
87
+ }
88
+
89
+ export function formatSuccess(data: unknown, operation?: string): string {
90
+ return JSON.stringify({
91
+ success: true,
92
+ ...(operation && { operation }),
93
+ data,
94
+ timestamp: new Date().toISOString(),
95
+ }, null, 2);
96
+ }
97
+
98
+ export function formatListResult(
99
+ items: unknown[],
100
+ pageToken?: string,
101
+ totalCount?: number
102
+ ): string {
103
+ return JSON.stringify({
104
+ success: true,
105
+ count: items.length,
106
+ ...(totalCount !== undefined && { totalCount }),
107
+ ...(pageToken && { nextPageToken: pageToken }),
108
+ items,
109
+ }, null, 2);
110
+ }
@@ -0,0 +1,4 @@
1
+ export { validateCollectionPath, validateDocumentPath, validateStoragePath, validateEmail, validateUid, validateLimit, validatePageSize, validateWhereField, validateOperator, sanitizeData, CollectionPathSchema, DocumentPathSchema, StoragePathSchema, UidSchema, EmailSchema, DataSchema, LimitSchema, PageSizeSchema, } from './validation.js';
2
+ export { FirebaseToolError, handleFirebaseError, formatSuccess, formatListResult, } from './errors.js';
3
+ export { LRUCache, firestoreCache, schemaCache } from './cache.js';
4
+ export { paginatedQuery, fetchAllDocuments, encodePageToken, decodePageToken, } from './pagination.js';
@@ -0,0 +1,105 @@
1
+ import { Query, DocumentSnapshot } from 'firebase-admin/firestore';
2
+ import { validatePageSize } from './validation.js';
3
+
4
+ export interface PaginationOptions {
5
+ pageSize: number;
6
+ pageToken?: string;
7
+ }
8
+
9
+ export interface PaginatedResult<T> {
10
+ items: T[];
11
+ nextPageToken?: string;
12
+ hasMore: boolean;
13
+ totalCount: number;
14
+ }
15
+
16
+ /**
17
+ * Execute a paginated Firestore query.
18
+ */
19
+ export async function paginatedQuery<T>(
20
+ baseQuery: Query,
21
+ options: PaginationOptions,
22
+ transform: (snap: DocumentSnapshot) => T
23
+ ): Promise<PaginatedResult<T>> {
24
+ const pageSize = validatePageSize(options.pageSize);
25
+ let query = baseQuery.limit(pageSize);
26
+ const allItems: T[] = [];
27
+ let lastDoc: DocumentSnapshot | undefined;
28
+
29
+ if (options.pageToken) {
30
+ const decodedToken = Buffer.from(options.pageToken, 'base64').toString('utf-8');
31
+ const lastDocRef = baseQuery.firestore.doc(decodedToken);
32
+ const lastDocSnap = await lastDocRef.get();
33
+ if (lastDocSnap.exists) {
34
+ query = query.startAfter(lastDocSnap);
35
+ }
36
+ }
37
+
38
+ const snapshot = await query.get();
39
+
40
+ for (const doc of snapshot.docs) {
41
+ allItems.push(transform(doc));
42
+ lastDoc = doc;
43
+ }
44
+
45
+ const hasMore = snapshot.size === pageSize;
46
+
47
+ let nextToken: string | undefined;
48
+ if (hasMore && lastDoc) {
49
+ nextToken = Buffer.from(lastDoc.ref.path).toString('base64');
50
+ }
51
+
52
+ return {
53
+ items: allItems,
54
+ nextPageToken: nextToken,
55
+ hasMore,
56
+ totalCount: allItems.length,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Fetch all documents from a collection with automatic pagination.
62
+ */
63
+ export async function fetchAllDocuments<T>(
64
+ baseQuery: Query,
65
+ transform: (snap: DocumentSnapshot) => T,
66
+ pageSize = 500,
67
+ maxTotal = 10000
68
+ ): Promise<T[]> {
69
+ let query = baseQuery.limit(pageSize);
70
+ const allItems: T[] = [];
71
+ let lastDoc: DocumentSnapshot | undefined;
72
+
73
+ while (allItems.length < maxTotal) {
74
+ if (lastDoc) {
75
+ query = baseQuery.limit(pageSize).startAfter(lastDoc);
76
+ }
77
+
78
+ const snapshot = await query.get();
79
+
80
+ for (const doc of snapshot.docs) {
81
+ allItems.push(transform(doc));
82
+ lastDoc = doc;
83
+ }
84
+
85
+ if (snapshot.size < pageSize) {
86
+ break;
87
+ }
88
+ }
89
+
90
+ return allItems;
91
+ }
92
+
93
+ /**
94
+ * Encode a document snapshot as a page token.
95
+ */
96
+ export function encodePageToken(docPath: string): string {
97
+ return Buffer.from(docPath).toString('base64');
98
+ }
99
+
100
+ /**
101
+ * Decode a page token back to a document path.
102
+ */
103
+ export function decodePageToken(token: string): string {
104
+ return Buffer.from(token, 'base64').toString('utf-8');
105
+ }
@@ -0,0 +1,212 @@
1
+ import { z } from 'zod';
2
+
3
+ // Shared validation schemas and input sanitization utilities
4
+
5
+ export const pathSegmentRegex = /^[a-zA-Z0-9_-]+$/;
6
+ export const collectionPathRegex = /^(?:[a-zA-Z0-9_-]+\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+|[a-zA-Z0-9_-]+)$/;
7
+
8
+ export function validateCollectionPath(path: string): string {
9
+ const trimmed = path.trim();
10
+ if (trimmed.length === 0) {
11
+ throw new Error('Collection path cannot be empty.');
12
+ }
13
+ if (trimmed.length > 1500) {
14
+ throw new Error('Collection path exceeds maximum length of 1500 characters.');
15
+ }
16
+ if (!collectionPathRegex.test(trimmed)) {
17
+ throw new Error(
18
+ `Invalid collection path: "${trimmed}". ` +
19
+ 'Path segments must contain only letters, numbers, hyphens, and underscores. ' +
20
+ 'Use forward slashes to separate segments (e.g., "users" or "users/uid123/posts").'
21
+ );
22
+ }
23
+ return trimmed;
24
+ }
25
+
26
+ export function validateDocumentPath(path: string): string {
27
+ const trimmed = path.trim();
28
+ if (trimmed.length === 0) {
29
+ throw new Error('Document path cannot be empty.');
30
+ }
31
+ if (trimmed.length > 1500) {
32
+ throw new Error('Document path exceeds maximum length of 1500 characters.');
33
+ }
34
+ const segments = trimmed.split('/');
35
+ if (segments.length % 2 !== 0) {
36
+ throw new Error(
37
+ `Invalid document path: "${trimmed}". ` +
38
+ 'Document paths must have an even number of segments (e.g., "users/uid123" or "users/uid123/posts/post1").'
39
+ );
40
+ }
41
+ for (const seg of segments) {
42
+ if (!pathSegmentRegex.test(seg)) {
43
+ throw new Error(
44
+ `Invalid segment "${seg}" in document path. ` +
45
+ 'Segments must contain only letters, numbers, hyphens, and underscores.'
46
+ );
47
+ }
48
+ }
49
+ return trimmed;
50
+ }
51
+
52
+ export function validateStoragePath(path: string): string {
53
+ const trimmed = path.trim();
54
+ if (trimmed.length === 0) {
55
+ throw new Error('Storage path cannot be empty.');
56
+ }
57
+ if (trimmed.startsWith('/')) {
58
+ throw new Error(
59
+ `Invalid storage path: "${trimmed}". ` +
60
+ 'Path must not start with "/" to prevent path traversal.'
61
+ );
62
+ }
63
+ if (trimmed.startsWith('..')) {
64
+ throw new Error(
65
+ `Invalid storage path: "${trimmed}". ` +
66
+ 'Path must not start with ".." to prevent path traversal.'
67
+ );
68
+ }
69
+ if (trimmed.includes('..')) {
70
+ throw new Error(
71
+ `Invalid storage path: "${trimmed}". ` +
72
+ 'Path must not contain ".." to prevent path traversal.'
73
+ );
74
+ }
75
+ return trimmed;
76
+ }
77
+
78
+ export function validateEmail(email: string): string {
79
+ const trimmed = email.trim().toLowerCase();
80
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
81
+ if (!emailRegex.test(trimmed)) {
82
+ throw new Error(
83
+ `Invalid email address: "${email}". ` +
84
+ 'Please provide a valid email (e.g., user@example.com).'
85
+ );
86
+ }
87
+ return trimmed;
88
+ }
89
+
90
+ export function validateUid(uid: string): string {
91
+ const trimmed = uid.trim();
92
+ if (trimmed.length === 0) {
93
+ throw new Error('UID cannot be empty.');
94
+ }
95
+ if (trimmed.length > 128) {
96
+ throw new Error('UID exceeds maximum length of 128 characters.');
97
+ }
98
+ return trimmed;
99
+ }
100
+
101
+ export function validateLimit(limit: number, max = 10000): number {
102
+ if (!Number.isInteger(limit) || limit < 1) {
103
+ throw new Error(`Limit must be a positive integer, got: ${limit}.`);
104
+ }
105
+ if (limit > max) {
106
+ throw new Error(`Limit exceeds maximum of ${max}. Use pagination to retrieve more results.`);
107
+ }
108
+ return limit;
109
+ }
110
+
111
+ export function validatePageSize(size: number, max = 1000): number {
112
+ if (!Number.isInteger(size) || size < 1) {
113
+ throw new Error(`Page size must be a positive integer, got: ${size}.`);
114
+ }
115
+ if (size > max) {
116
+ throw new Error(`Page size exceeds maximum of ${max}.`);
117
+ }
118
+ return size;
119
+ }
120
+
121
+ export function validateWhereField(field: string): string {
122
+ const trimmed = field.trim();
123
+ if (trimmed.length === 0) {
124
+ throw new Error('Where clause field cannot be empty.');
125
+ }
126
+ // Prevent NoSQL injection: disallow field paths that try to access internals
127
+ if (trimmed.startsWith('__') || trimmed.includes('$')) {
128
+ throw new Error(
129
+ `Invalid field name: "${trimmed}". ` +
130
+ 'Field names must not start with "__" or contain "$".'
131
+ );
132
+ }
133
+ return trimmed;
134
+ }
135
+
136
+ export function validateOperator(op: string): string {
137
+ const allowed = [
138
+ '==', '!=', '<', '<=', '>', '>=',
139
+ 'array-contains', 'array-contains-any', 'in', 'not-in',
140
+ ];
141
+ if (!allowed.includes(op)) {
142
+ throw new Error(
143
+ `Invalid operator: "${op}". ` +
144
+ `Allowed operators: ${allowed.join(', ')}.`
145
+ );
146
+ }
147
+ return op;
148
+ }
149
+
150
+ export function sanitizeData(data: unknown): unknown {
151
+ if (data === null || data === undefined) {
152
+ return data;
153
+ }
154
+ if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
155
+ return data;
156
+ }
157
+ if (data instanceof Date) {
158
+ return data;
159
+ }
160
+ if (Array.isArray(data)) {
161
+ return data.map(sanitizeData);
162
+ }
163
+ if (typeof data === 'object') {
164
+ const sanitized: Record<string, unknown> = {};
165
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
166
+ if (key.startsWith('$')) {
167
+ throw new Error(
168
+ `Invalid field key: "${key}". ` +
169
+ 'Keys must not start with "$" to prevent NoSQL injection.'
170
+ );
171
+ }
172
+ if (key.includes('.')) {
173
+ throw new Error(
174
+ `Invalid field key: "${key}". ` +
175
+ 'Keys must not contain "." to prevent NoSQL injection.'
176
+ );
177
+ }
178
+ sanitized[key] = sanitizeData(value);
179
+ }
180
+ return sanitized;
181
+ }
182
+ return data;
183
+ }
184
+
185
+ // Common Zod schemas for tool parameters
186
+ export const CollectionPathSchema = z.string().min(1).describe(
187
+ 'Firestore collection path (e.g., "users" or "users/uid123/orders")'
188
+ );
189
+
190
+ export const DocumentPathSchema = z.string().min(1).describe(
191
+ 'Firestore document path (e.g., "users/uid123" or "users/uid123/orders/order1")'
192
+ );
193
+
194
+ export const StoragePathSchema = z.string().min(1).describe(
195
+ 'Cloud Storage path (e.g., "images/photo.jpg")'
196
+ );
197
+
198
+ export const UidSchema = z.string().min(1).describe('Firebase Auth UID');
199
+
200
+ export const EmailSchema = z.string().email().describe('User email address');
201
+
202
+ export const DataSchema = z.record(z.unknown()).describe(
203
+ 'Document data as a JSON object'
204
+ );
205
+
206
+ export const LimitSchema = z.number().int().min(1).max(10000).optional().default(100).describe(
207
+ 'Maximum number of results to return (1-10000, default: 100)'
208
+ );
209
+
210
+ export const PageSizeSchema = z.number().int().min(1).max(1000).optional().default(100).describe(
211
+ 'Number of results per page (1-1000, default: 100)'
212
+ );
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { LRUCache } from '../src/utils/cache.js';
3
+
4
+ describe('LRUCache', () => {
5
+ let cache: LRUCache<string>;
6
+
7
+ beforeEach(() => {
8
+ cache = new LRUCache<string>(3, 1000); // max 3 entries, 1s TTL
9
+ });
10
+
11
+ it('stores and retrieves values', () => {
12
+ cache.set('a', 'value-a');
13
+ expect(cache.get('a')).toBe('value-a');
14
+ });
15
+
16
+ it('returns undefined for missing keys', () => {
17
+ expect(cache.get('nonexistent')).toBeUndefined();
18
+ });
19
+
20
+ it('evicts oldest entries when at capacity', () => {
21
+ cache.set('a', 'va');
22
+ cache.set('b', 'vb');
23
+ cache.set('c', 'vc');
24
+ cache.set('d', 'vd'); // should evict 'a'
25
+
26
+ expect(cache.get('a')).toBeUndefined();
27
+ expect(cache.get('b')).toBe('vb');
28
+ expect(cache.get('c')).toBe('vc');
29
+ expect(cache.get('d')).toBe('vd');
30
+ });
31
+
32
+ it('evicts least recently used entries', () => {
33
+ cache.set('a', 'va');
34
+ cache.set('b', 'vb');
35
+ cache.set('c', 'vc');
36
+
37
+ // Access 'a' to make it recently used
38
+ cache.get('a');
39
+
40
+ cache.set('d', 'vd'); // should evict 'b' (LRU)
41
+
42
+ expect(cache.get('a')).toBe('va');
43
+ expect(cache.get('b')).toBeUndefined();
44
+ expect(cache.get('c')).toBe('vc');
45
+ expect(cache.get('d')).toBe('vd');
46
+ });
47
+
48
+ it('expires entries after TTL', () => {
49
+ cache.set('a', 'va', 50); // 50ms TTL
50
+ expect(cache.get('a')).toBe('va');
51
+
52
+ return new Promise<void>((resolve) => {
53
+ setTimeout(() => {
54
+ expect(cache.get('a')).toBeUndefined();
55
+ resolve();
56
+ }, 100);
57
+ });
58
+ });
59
+
60
+ it('deletes entries', () => {
61
+ cache.set('a', 'va');
62
+ expect(cache.delete('a')).toBe(true);
63
+ expect(cache.get('a')).toBeUndefined();
64
+ expect(cache.delete('a')).toBe(false);
65
+ });
66
+
67
+ it('clears all entries', () => {
68
+ cache.set('a', 'va');
69
+ cache.set('b', 'vb');
70
+ cache.clear();
71
+ expect(cache.size).toBe(0);
72
+ expect(cache.get('a')).toBeUndefined();
73
+ });
74
+
75
+ it('reports correct size', () => {
76
+ expect(cache.size).toBe(0);
77
+ cache.set('a', 'va');
78
+ expect(cache.size).toBe(1);
79
+ cache.set('b', 'vb');
80
+ expect(cache.size).toBe(2);
81
+ });
82
+
83
+ it('checks has() correctly', () => {
84
+ cache.set('a', 'va');
85
+ expect(cache.has('a')).toBe(true);
86
+ expect(cache.has('b')).toBe(false);
87
+ });
88
+
89
+ it('has() returns false for expired entries', () => {
90
+ cache.set('a', 'va', 50);
91
+ expect(cache.has('a')).toBe(true);
92
+
93
+ return new Promise<void>((resolve) => {
94
+ setTimeout(() => {
95
+ expect(cache.has('a')).toBe(false);
96
+ resolve();
97
+ }, 100);
98
+ });
99
+ });
100
+
101
+ it('invalidates entries by prefix', () => {
102
+ cache.set('doc:users/a', 'va');
103
+ cache.set('doc:users/b', 'vb');
104
+ cache.set('doc:orders/1', 'vc');
105
+
106
+ const count = cache.invalidatePrefix('doc:users');
107
+ expect(count).toBe(2);
108
+ expect(cache.get('doc:users/a')).toBeUndefined();
109
+ expect(cache.get('doc:users/b')).toBeUndefined();
110
+ expect(cache.get('doc:orders/1')).toBe('vc');
111
+ });
112
+
113
+ it('updates existing key position', () => {
114
+ cache.set('a', 'va');
115
+ cache.set('b', 'vb');
116
+ cache.set('c', 'vc');
117
+
118
+ // Update 'a' to move it to end
119
+ cache.set('a', 'va-updated');
120
+
121
+ cache.set('d', 'vd'); // should evict 'b' (now LRU)
122
+
123
+ expect(cache.get('a')).toBe('va-updated');
124
+ expect(cache.get('b')).toBeUndefined();
125
+ });
126
+
127
+ it('allows custom TTL per entry', () => {
128
+ cache.set('short', 'vs', 50);
129
+ cache.set('long', 'vl', 5000);
130
+
131
+ return new Promise<void>((resolve) => {
132
+ setTimeout(() => {
133
+ expect(cache.get('short')).toBeUndefined();
134
+ expect(cache.get('long')).toBe('vl');
135
+ resolve();
136
+ }, 100);
137
+ });
138
+ });
139
+ });