@umituz/react-native-firebase 1.13.3 → 1.13.5
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.
- package/package.json +5 -8
- package/src/firestore/__tests__/BaseRepository.test.ts +133 -0
- package/src/firestore/__tests__/QueryDeduplicationMiddleware.test.ts +147 -0
- package/src/firestore/__tests__/mocks/react-native-firebase.ts +23 -0
- package/src/firestore/__tests__/setup.ts +36 -0
- package/src/firestore/domain/constants/QuotaLimits.ts +97 -0
- package/src/firestore/domain/entities/QuotaMetrics.ts +28 -0
- package/src/firestore/domain/entities/RequestLog.ts +30 -0
- package/src/firestore/domain/errors/FirebaseFirestoreError.ts +52 -0
- package/src/firestore/domain/services/QuotaCalculator.ts +70 -0
- package/src/firestore/index.ts +174 -0
- package/src/firestore/infrastructure/config/FirestoreClient.ts +181 -0
- package/src/firestore/infrastructure/config/initializers/FirebaseFirestoreInitializer.ts +46 -0
- package/src/firestore/infrastructure/middleware/QueryDeduplicationMiddleware.ts +153 -0
- package/src/firestore/infrastructure/middleware/QuotaTrackingMiddleware.ts +165 -0
- package/src/firestore/infrastructure/repositories/BasePaginatedRepository.ts +90 -0
- package/src/firestore/infrastructure/repositories/BaseQueryRepository.ts +80 -0
- package/src/firestore/infrastructure/repositories/BaseRepository.ts +147 -0
- package/src/firestore/infrastructure/services/QuotaMonitorService.ts +108 -0
- package/src/firestore/infrastructure/services/RequestLoggerService.ts +139 -0
- package/src/firestore/types/pagination.types.ts +60 -0
- package/src/firestore/utils/dateUtils.ts +31 -0
- package/src/firestore/utils/document-mapper.helper.ts +145 -0
- package/src/firestore/utils/pagination.helper.ts +93 -0
- package/src/firestore/utils/query-builder.ts +188 -0
- package/src/firestore/utils/quota-error-detector.util.ts +100 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-firebase",
|
|
3
|
-
"version": "1.13.
|
|
4
|
-
"description": "Unified Firebase package for React Native apps - Centralized initialization and core services (Auth, Analytics, Crashlytics).",
|
|
3
|
+
"version": "1.13.5",
|
|
4
|
+
"description": "Unified Firebase package for React Native apps - Centralized initialization and core services (Auth, Firestore, Analytics, Crashlytics).",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
"firebase-core",
|
|
18
18
|
"auth",
|
|
19
19
|
"authentication",
|
|
20
|
+
"firestore",
|
|
21
|
+
"database",
|
|
22
|
+
"repository",
|
|
20
23
|
"analytics",
|
|
21
24
|
"crashlytics",
|
|
22
25
|
"ddd",
|
|
@@ -29,18 +32,12 @@
|
|
|
29
32
|
"url": "https://github.com/umituz/react-native-firebase"
|
|
30
33
|
},
|
|
31
34
|
"peerDependencies": {
|
|
32
|
-
"@react-native-firebase/analytics": ">=18.0.0",
|
|
33
|
-
"@react-native-firebase/app": ">=18.0.0",
|
|
34
|
-
"@react-native-firebase/crashlytics": ">=18.0.0",
|
|
35
35
|
"expo-apple-authentication": ">=6.0.0",
|
|
36
36
|
"firebase": ">=10.0.0",
|
|
37
37
|
"react": ">=18.2.0",
|
|
38
38
|
"react-native": ">=0.74.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@react-native-firebase/analytics": "^19.0.0",
|
|
42
|
-
"@react-native-firebase/app": "^19.0.0",
|
|
43
|
-
"@react-native-firebase/crashlytics": "^19.0.0",
|
|
44
41
|
"@types/react": "~19.1.10",
|
|
45
42
|
"firebase": "^12.6.0",
|
|
46
43
|
"react": "19.1.0",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for BaseRepository
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
6
|
+
import { BaseRepository } from '../infrastructure/repositories/BaseRepository';
|
|
7
|
+
import { getFirestore, resetFirestoreClient } from '../infrastructure/config/FirestoreClient';
|
|
8
|
+
|
|
9
|
+
// Mock Firestore client
|
|
10
|
+
jest.mock('../infrastructure/config/FirestoreClient', () => ({
|
|
11
|
+
getFirestore: jest.fn(),
|
|
12
|
+
resetFirestoreClient: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const mockGetFirestore = getFirestore as jest.MockedFunction<typeof getFirestore>;
|
|
16
|
+
const mockResetFirestoreClient = resetFirestoreClient as jest.MockedFunction<typeof resetFirestoreClient>;
|
|
17
|
+
|
|
18
|
+
describe('BaseRepository', () => {
|
|
19
|
+
let repository: BaseRepository;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
repository = new BaseRepository();
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
repository.destroy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('getDb', () => {
|
|
31
|
+
it('should return Firestore instance when available', () => {
|
|
32
|
+
const mockFirestore = {} as any;
|
|
33
|
+
mockGetFirestore.mockReturnValue(mockFirestore);
|
|
34
|
+
|
|
35
|
+
const result = repository.getDb();
|
|
36
|
+
expect(result).toBe(mockFirestore);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return null when Firestore is not available', () => {
|
|
40
|
+
mockGetFirestore.mockReturnValue(null);
|
|
41
|
+
|
|
42
|
+
const result = repository.getDb();
|
|
43
|
+
expect(result).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return null when repository is destroyed', () => {
|
|
47
|
+
repository.destroy();
|
|
48
|
+
const result = repository.getDb();
|
|
49
|
+
expect(result).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('getDbOrThrow', () => {
|
|
54
|
+
it('should return Firestore instance when available', () => {
|
|
55
|
+
const mockFirestore = {} as any;
|
|
56
|
+
mockGetFirestore.mockReturnValue(mockFirestore);
|
|
57
|
+
|
|
58
|
+
const result = repository.getDbOrThrow();
|
|
59
|
+
expect(result).toBe(mockFirestore);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should throw error when Firestore is not available', () => {
|
|
63
|
+
mockGetFirestore.mockReturnValue(null);
|
|
64
|
+
|
|
65
|
+
expect(() => repository.getDbOrThrow()).toThrow(
|
|
66
|
+
'Firestore is not initialized. Please initialize Firebase App first.'
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('isDbInitialized', () => {
|
|
72
|
+
it('should return true when Firestore is available', () => {
|
|
73
|
+
mockGetFirestore.mockReturnValue({} as any);
|
|
74
|
+
|
|
75
|
+
const result = repository.isDbInitialized();
|
|
76
|
+
expect(result).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should return false when Firestore is not available', () => {
|
|
80
|
+
mockGetFirestore.mockReturnValue(null);
|
|
81
|
+
|
|
82
|
+
const result = repository.isDbInitialized();
|
|
83
|
+
expect(result).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return false when getFirestore throws', () => {
|
|
87
|
+
mockGetFirestore.mockImplementation(() => {
|
|
88
|
+
throw new Error('Test error');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = repository.isDbInitialized();
|
|
92
|
+
expect(result).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('executeWithQuotaHandling', () => {
|
|
97
|
+
it('should execute operation successfully', async () => {
|
|
98
|
+
const mockOperation = jest.fn().mockResolvedValue('success');
|
|
99
|
+
const result = await repository.executeWithQuotaHandling(mockOperation);
|
|
100
|
+
expect(result).toBe('success');
|
|
101
|
+
expect(mockOperation).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle quota errors', async () => {
|
|
105
|
+
const quotaError = new Error('Quota exceeded');
|
|
106
|
+
const mockOperation = jest.fn().mockRejectedValue(quotaError);
|
|
107
|
+
|
|
108
|
+
// Mock quota error detection
|
|
109
|
+
jest.spyOn(repository, 'isQuotaError').mockReturnValue(true);
|
|
110
|
+
jest.spyOn(repository, 'handleQuotaError').mockImplementation(() => {
|
|
111
|
+
throw new Error('Quota error handled');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(repository.executeWithQuotaHandling(mockOperation)).rejects.toThrow('Quota error handled');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should re-throw non-quota errors', async () => {
|
|
118
|
+
const regularError = new Error('Regular error');
|
|
119
|
+
const mockOperation = jest.fn().mockRejectedValue(regularError);
|
|
120
|
+
|
|
121
|
+
jest.spyOn(repository, 'isQuotaError').mockReturnValue(false);
|
|
122
|
+
|
|
123
|
+
await expect(repository.executeWithQuotaHandling(mockOperation)).rejects.toThrow('Regular error');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('destroy', () => {
|
|
128
|
+
it('should mark repository as destroyed', () => {
|
|
129
|
+
repository.destroy();
|
|
130
|
+
expect(repository.getDb()).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for QueryDeduplicationMiddleware
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
6
|
+
import { QueryDeduplicationMiddleware } from '../infrastructure/middleware/QueryDeduplicationMiddleware';
|
|
7
|
+
|
|
8
|
+
describe('QueryDeduplicationMiddleware', () => {
|
|
9
|
+
let middleware: QueryDeduplicationMiddleware;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
middleware = new QueryDeduplicationMiddleware();
|
|
13
|
+
jest.useFakeTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
middleware.destroy();
|
|
18
|
+
jest.useRealTimers();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('deduplicate', () => {
|
|
22
|
+
it('should execute query immediately if not pending', async () => {
|
|
23
|
+
const queryFn = jest.fn().mockResolvedValue('result');
|
|
24
|
+
const queryKey = {
|
|
25
|
+
collection: 'test',
|
|
26
|
+
filters: 'field == value',
|
|
27
|
+
limit: 10,
|
|
28
|
+
orderBy: 'createdAt',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = await middleware.deduplicate(queryKey, queryFn);
|
|
32
|
+
expect(result).toBe('result');
|
|
33
|
+
expect(queryFn).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should deduplicate identical queries within window', async () => {
|
|
37
|
+
const queryFn = jest.fn().mockResolvedValue('result');
|
|
38
|
+
const queryKey = {
|
|
39
|
+
collection: 'test',
|
|
40
|
+
filters: 'field == value',
|
|
41
|
+
limit: 10,
|
|
42
|
+
orderBy: 'createdAt',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const promise1 = middleware.deduplicate(queryKey, queryFn);
|
|
46
|
+
const promise2 = middleware.deduplicate(queryKey, queryFn);
|
|
47
|
+
|
|
48
|
+
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
49
|
+
expect(result1).toBe('result');
|
|
50
|
+
expect(result2).toBe('result');
|
|
51
|
+
expect(queryFn).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should execute new query after deduplication window', async () => {
|
|
55
|
+
const queryFn = jest.fn().mockResolvedValue('result');
|
|
56
|
+
const queryKey = {
|
|
57
|
+
collection: 'test',
|
|
58
|
+
filters: 'field == value',
|
|
59
|
+
limit: 10,
|
|
60
|
+
orderBy: 'createdAt',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
await middleware.deduplicate(queryKey, queryFn);
|
|
64
|
+
expect(queryFn).toHaveBeenCalledTimes(1);
|
|
65
|
+
|
|
66
|
+
// Advance time beyond deduplication window
|
|
67
|
+
jest.advanceTimersByTime(1100);
|
|
68
|
+
|
|
69
|
+
await middleware.deduplicate(queryKey, queryFn);
|
|
70
|
+
expect(queryFn).toHaveBeenCalledTimes(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should handle different query keys separately', async () => {
|
|
74
|
+
const queryFn = jest.fn().mockResolvedValue('result');
|
|
75
|
+
const queryKey1 = {
|
|
76
|
+
collection: 'test1',
|
|
77
|
+
filters: 'field == value',
|
|
78
|
+
limit: 10,
|
|
79
|
+
orderBy: 'createdAt',
|
|
80
|
+
};
|
|
81
|
+
const queryKey2 = {
|
|
82
|
+
collection: 'test2',
|
|
83
|
+
filters: 'field == value',
|
|
84
|
+
limit: 10,
|
|
85
|
+
orderBy: 'createdAt',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await middleware.deduplicate(queryKey1, queryFn);
|
|
89
|
+
await middleware.deduplicate(queryKey2, queryFn);
|
|
90
|
+
|
|
91
|
+
expect(queryFn).toHaveBeenCalledTimes(2);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('cleanup', () => {
|
|
96
|
+
it('should clean up expired queries automatically', async () => {
|
|
97
|
+
const queryFn = jest.fn().mockResolvedValue('result');
|
|
98
|
+
const queryKey = {
|
|
99
|
+
collection: 'test',
|
|
100
|
+
filters: 'field == value',
|
|
101
|
+
limit: 10,
|
|
102
|
+
orderBy: 'createdAt',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await middleware.deduplicate(queryKey, queryFn);
|
|
106
|
+
expect(middleware.getPendingCount()).toBe(0);
|
|
107
|
+
|
|
108
|
+
// Advance time to trigger cleanup
|
|
109
|
+
jest.advanceTimersByTime(6000);
|
|
110
|
+
expect(middleware.getPendingCount()).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('clear', () => {
|
|
115
|
+
it('should clear all pending queries', async () => {
|
|
116
|
+
const queryFn = jest.fn(() => new Promise(() => {})); // Never resolves
|
|
117
|
+
const queryKey = {
|
|
118
|
+
collection: 'test',
|
|
119
|
+
filters: 'field == value',
|
|
120
|
+
limit: 10,
|
|
121
|
+
orderBy: 'createdAt',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
middleware.deduplicate(queryKey, queryFn);
|
|
125
|
+
expect(middleware.getPendingCount()).toBe(1);
|
|
126
|
+
|
|
127
|
+
middleware.clear();
|
|
128
|
+
expect(middleware.getPendingCount()).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('destroy', () => {
|
|
133
|
+
it('should cleanup resources and clear queries', () => {
|
|
134
|
+
const queryKey = {
|
|
135
|
+
collection: 'test',
|
|
136
|
+
filters: 'field == value',
|
|
137
|
+
limit: 10,
|
|
138
|
+
orderBy: 'createdAt',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
middleware.deduplicate(queryKey, () => Promise.resolve('result'));
|
|
142
|
+
middleware.destroy();
|
|
143
|
+
|
|
144
|
+
expect(middleware.getPendingCount()).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for @umituz/react-native-firebase
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class FirebaseError extends Error {
|
|
6
|
+
code: string;
|
|
7
|
+
originalError?: unknown;
|
|
8
|
+
|
|
9
|
+
constructor(message: string, code?: string, originalError?: unknown) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'FirebaseError';
|
|
12
|
+
this.code = code || 'UNKNOWN';
|
|
13
|
+
this.originalError = originalError;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getFirebaseApp(): any {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function initializeFirebase(): any {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest setup file
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Mock __DEV__ for tests
|
|
6
|
+
export {};
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
var __DEV__: boolean | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (typeof (global as any).__DEV__ === 'undefined') {
|
|
13
|
+
(global as any).__DEV__ = true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Mock console methods to avoid noise in tests
|
|
17
|
+
const originalConsole = { ...console };
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// Restore console methods before each test
|
|
21
|
+
Object.assign(console, originalConsole);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Set up global test utilities
|
|
25
|
+
global.mockFirestore = () => ({
|
|
26
|
+
collection: jest.fn(),
|
|
27
|
+
doc: jest.fn(),
|
|
28
|
+
runTransaction: jest.fn(),
|
|
29
|
+
batch: jest.fn(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
global.mockFirebaseError = (code: string, message: string) => {
|
|
33
|
+
const error = new Error(message) as any;
|
|
34
|
+
error.code = code;
|
|
35
|
+
return error;
|
|
36
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Limit Constants
|
|
3
|
+
* Domain layer - Default Firestore quota limits
|
|
4
|
+
*
|
|
5
|
+
* General-purpose constants for any app using Firestore
|
|
6
|
+
* Based on Firestore free tier and pricing documentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Firestore free tier daily limits
|
|
11
|
+
* https://firebase.google.com/docs/firestore/quotas
|
|
12
|
+
*/
|
|
13
|
+
export const FREE_TIER_LIMITS = {
|
|
14
|
+
/**
|
|
15
|
+
* Daily read operations (documents)
|
|
16
|
+
* Free tier: 50,000 reads/day
|
|
17
|
+
*/
|
|
18
|
+
DAILY_READS: 50_000,
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Daily write operations (documents)
|
|
22
|
+
* Free tier: 20,000 writes/day
|
|
23
|
+
*/
|
|
24
|
+
DAILY_WRITES: 20_000,
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Daily delete operations (documents)
|
|
28
|
+
* Free tier: 20,000 deletes/day
|
|
29
|
+
*/
|
|
30
|
+
DAILY_DELETES: 20_000,
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Stored data (GB)
|
|
34
|
+
* Free tier: 1 GB
|
|
35
|
+
*/
|
|
36
|
+
STORAGE_GB: 1,
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Quota warning thresholds (percentage of limit)
|
|
41
|
+
* Apps can use these to show warnings before hitting limits
|
|
42
|
+
*/
|
|
43
|
+
export const QUOTA_THRESHOLDS = {
|
|
44
|
+
/**
|
|
45
|
+
* Warning threshold (80% of limit)
|
|
46
|
+
* Show warning to user
|
|
47
|
+
*/
|
|
48
|
+
WARNING: 0.8,
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Critical threshold (95% of limit)
|
|
52
|
+
* Show critical alert, consider disabling features
|
|
53
|
+
*/
|
|
54
|
+
CRITICAL: 0.95,
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Emergency threshold (98% of limit)
|
|
58
|
+
* Disable non-essential features
|
|
59
|
+
*/
|
|
60
|
+
EMERGENCY: 0.98,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Calculate quota usage percentage
|
|
65
|
+
* @param current - Current usage count
|
|
66
|
+
* @param limit - Total limit
|
|
67
|
+
* @returns Percentage (0-1)
|
|
68
|
+
*/
|
|
69
|
+
export function calculateQuotaUsage(current: number, limit: number): number {
|
|
70
|
+
return Math.min(1, current / limit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if quota threshold is reached
|
|
75
|
+
* @param current - Current usage count
|
|
76
|
+
* @param limit - Total limit
|
|
77
|
+
* @param threshold - Threshold to check (0-1)
|
|
78
|
+
* @returns true if threshold is reached
|
|
79
|
+
*/
|
|
80
|
+
export function isQuotaThresholdReached(
|
|
81
|
+
current: number,
|
|
82
|
+
limit: number,
|
|
83
|
+
threshold: number,
|
|
84
|
+
): boolean {
|
|
85
|
+
const usage = calculateQuotaUsage(current, limit);
|
|
86
|
+
return usage >= threshold;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get remaining quota
|
|
91
|
+
* @param current - Current usage count
|
|
92
|
+
* @param limit - Total limit
|
|
93
|
+
* @returns Remaining quota count
|
|
94
|
+
*/
|
|
95
|
+
export function getRemainingQuota(current: number, limit: number): number {
|
|
96
|
+
return Math.max(0, limit - current);
|
|
97
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Metrics Entity
|
|
3
|
+
* Domain entity for tracking Firestore quota usage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface QuotaMetrics {
|
|
7
|
+
readCount: number;
|
|
8
|
+
writeCount: number;
|
|
9
|
+
deleteCount: number;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface QuotaLimits {
|
|
14
|
+
dailyReadLimit: number;
|
|
15
|
+
dailyWriteLimit: number;
|
|
16
|
+
dailyDeleteLimit: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface QuotaStatus {
|
|
20
|
+
metrics: QuotaMetrics;
|
|
21
|
+
limits: QuotaLimits;
|
|
22
|
+
readPercentage: number;
|
|
23
|
+
writePercentage: number;
|
|
24
|
+
deletePercentage: number;
|
|
25
|
+
isNearLimit: boolean;
|
|
26
|
+
isOverLimit: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request Log Entity
|
|
3
|
+
* Domain entity for tracking Firestore requests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type RequestType = 'read' | 'write' | 'delete' | 'listener';
|
|
7
|
+
|
|
8
|
+
export interface RequestLog {
|
|
9
|
+
id: string;
|
|
10
|
+
type: RequestType;
|
|
11
|
+
collection: string;
|
|
12
|
+
documentId?: string;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
duration?: number;
|
|
15
|
+
success: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
cached: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RequestStats {
|
|
21
|
+
totalRequests: number;
|
|
22
|
+
readRequests: number;
|
|
23
|
+
writeRequests: number;
|
|
24
|
+
deleteRequests: number;
|
|
25
|
+
listenerRequests: number;
|
|
26
|
+
cachedRequests: number;
|
|
27
|
+
failedRequests: number;
|
|
28
|
+
averageDuration: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firebase Firestore Domain Errors
|
|
3
|
+
*
|
|
4
|
+
* Domain-Driven Design: Error types for Firestore operations
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { FirebaseError } from '../../../domain/errors/FirebaseError';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Firestore Error
|
|
11
|
+
* Thrown when Firestore operations fail
|
|
12
|
+
*/
|
|
13
|
+
export class FirebaseFirestoreError extends FirebaseError {
|
|
14
|
+
constructor(message: string, originalError?: unknown) {
|
|
15
|
+
super(message, 'FIRESTORE_ERROR', originalError);
|
|
16
|
+
this.name = 'FirebaseFirestoreError';
|
|
17
|
+
Object.setPrototypeOf(this, FirebaseFirestoreError.prototype);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Firestore Initialization Error
|
|
23
|
+
* Thrown when Firestore fails to initialize
|
|
24
|
+
*/
|
|
25
|
+
export class FirebaseFirestoreInitializationError extends FirebaseFirestoreError {
|
|
26
|
+
constructor(message: string, originalError?: unknown) {
|
|
27
|
+
super(message, originalError);
|
|
28
|
+
this.name = 'FirebaseFirestoreInitializationError';
|
|
29
|
+
Object.setPrototypeOf(this, FirebaseFirestoreInitializationError.prototype);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Firestore Quota Error
|
|
35
|
+
* Thrown when Firebase quota limits are exceeded
|
|
36
|
+
*
|
|
37
|
+
* Firebase quota limits:
|
|
38
|
+
* - Free tier: 50K reads/day, 20K writes/day, 20K deletes/day
|
|
39
|
+
* - Blaze plan: Pay as you go, higher limits
|
|
40
|
+
*
|
|
41
|
+
* This error is NOT retryable - quota won't increase by retrying
|
|
42
|
+
*/
|
|
43
|
+
export class FirebaseFirestoreQuotaError extends FirebaseFirestoreError {
|
|
44
|
+
constructor(message: string, originalError?: unknown) {
|
|
45
|
+
super(message, originalError);
|
|
46
|
+
this.name = 'FirebaseFirestoreQuotaError';
|
|
47
|
+
(this as any).isQuotaError = true;
|
|
48
|
+
(this as any).code = 'resource-exhausted';
|
|
49
|
+
Object.setPrototypeOf(this, FirebaseFirestoreQuotaError.prototype);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota Calculator Service
|
|
3
|
+
* Domain service for calculating quota usage and status
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { QuotaMetrics, QuotaLimits, QuotaStatus } from '../entities/QuotaMetrics';
|
|
7
|
+
import { FREE_TIER_LIMITS } from '../constants/QuotaLimits';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default quota limits (Firebase Spark Plan)
|
|
11
|
+
* Can be overridden via configuration
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_QUOTA_LIMITS: QuotaLimits = {
|
|
14
|
+
dailyReadLimit: FREE_TIER_LIMITS.DAILY_READS,
|
|
15
|
+
dailyWriteLimit: FREE_TIER_LIMITS.DAILY_WRITES,
|
|
16
|
+
dailyDeleteLimit: FREE_TIER_LIMITS.DAILY_DELETES,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class QuotaCalculator {
|
|
20
|
+
/**
|
|
21
|
+
* Calculate quota status from metrics and limits
|
|
22
|
+
*/
|
|
23
|
+
static calculateStatus(
|
|
24
|
+
metrics: QuotaMetrics,
|
|
25
|
+
limits: QuotaLimits = DEFAULT_QUOTA_LIMITS,
|
|
26
|
+
): QuotaStatus {
|
|
27
|
+
const readPercentage = (metrics.readCount / limits.dailyReadLimit) * 100;
|
|
28
|
+
const writePercentage = (metrics.writeCount / limits.dailyWriteLimit) * 100;
|
|
29
|
+
const deletePercentage = (metrics.deleteCount / limits.dailyDeleteLimit) * 100;
|
|
30
|
+
|
|
31
|
+
const isNearLimit =
|
|
32
|
+
readPercentage >= 80 ||
|
|
33
|
+
writePercentage >= 80 ||
|
|
34
|
+
deletePercentage >= 80;
|
|
35
|
+
|
|
36
|
+
const isOverLimit =
|
|
37
|
+
readPercentage >= 100 ||
|
|
38
|
+
writePercentage >= 100 ||
|
|
39
|
+
deletePercentage >= 100;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
metrics,
|
|
43
|
+
limits,
|
|
44
|
+
readPercentage,
|
|
45
|
+
writePercentage,
|
|
46
|
+
deletePercentage,
|
|
47
|
+
isNearLimit,
|
|
48
|
+
isOverLimit,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get default quota limits
|
|
54
|
+
*/
|
|
55
|
+
static getDefaultLimits(): QuotaLimits {
|
|
56
|
+
return { ...DEFAULT_QUOTA_LIMITS };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if metrics are within limits
|
|
61
|
+
*/
|
|
62
|
+
static isWithinLimits(
|
|
63
|
+
metrics: QuotaMetrics,
|
|
64
|
+
limits: QuotaLimits = DEFAULT_QUOTA_LIMITS,
|
|
65
|
+
): boolean {
|
|
66
|
+
const status = this.calculateStatus(metrics, limits);
|
|
67
|
+
return !status.isOverLimit;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|