@mostly-good-metrics/javascript 0.1.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.
- package/README.md +319 -0
- package/dist/cjs/client.js +416 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/index.js +65 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/logger.js +64 -0
- package/dist/cjs/logger.js.map +1 -0
- package/dist/cjs/network.js +192 -0
- package/dist/cjs/network.js.map +1 -0
- package/dist/cjs/storage.js +227 -0
- package/dist/cjs/storage.js.map +1 -0
- package/dist/cjs/types.js +70 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/utils.js +249 -0
- package/dist/cjs/utils.js.map +1 -0
- package/dist/esm/client.js +412 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/index.js +40 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/logger.js +55 -0
- package/dist/esm/logger.js.map +1 -0
- package/dist/esm/network.js +187 -0
- package/dist/esm/network.js.map +1 -0
- package/dist/esm/storage.js +221 -0
- package/dist/esm/storage.js.map +1 -0
- package/dist/esm/types.js +66 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/utils.js +236 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/types/client.d.ts +126 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/index.d.ts +34 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/logger.d.ts +37 -0
- package/dist/types/logger.d.ts.map +1 -0
- package/dist/types/network.d.ts +28 -0
- package/dist/types/network.d.ts.map +1 -0
- package/dist/types/storage.d.ts +76 -0
- package/dist/types/storage.d.ts.map +1 -0
- package/dist/types/types.d.ts +279 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/utils.d.ts +48 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/package.json +68 -0
- package/src/client.test.ts +346 -0
- package/src/client.ts +510 -0
- package/src/index.ts +79 -0
- package/src/logger.ts +63 -0
- package/src/network.ts +230 -0
- package/src/storage.test.ts +175 -0
- package/src/storage.ts +249 -0
- package/src/types.ts +347 -0
- package/src/utils.test.ts +239 -0
- package/src/utils.ts +315 -0
package/src/network.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { logger } from './logger';
|
|
2
|
+
import {
|
|
3
|
+
Constraints,
|
|
4
|
+
INetworkClient,
|
|
5
|
+
MGMError,
|
|
6
|
+
MGMEventsPayload,
|
|
7
|
+
ResolvedConfiguration,
|
|
8
|
+
SendResult,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
const EVENTS_ENDPOINT = '/v1/events';
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 60000; // 60 seconds
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compress data using gzip if available (browser CompressionStream API).
|
|
16
|
+
* Falls back to uncompressed data if compression is not available.
|
|
17
|
+
*/
|
|
18
|
+
async function compressIfNeeded(data: string): Promise<{ data: BodyInit; compressed: boolean }> {
|
|
19
|
+
const bytes = new TextEncoder().encode(data);
|
|
20
|
+
|
|
21
|
+
// Only compress if payload exceeds threshold
|
|
22
|
+
if (bytes.length < Constraints.COMPRESSION_THRESHOLD_BYTES) {
|
|
23
|
+
return { data, compressed: false };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check if CompressionStream is available (modern browsers)
|
|
27
|
+
if (typeof CompressionStream === 'undefined') {
|
|
28
|
+
logger.debug('CompressionStream not available, sending uncompressed');
|
|
29
|
+
return { data, compressed: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const stream = new Blob([bytes]).stream();
|
|
34
|
+
const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
|
|
35
|
+
const compressedBlob = await new Response(compressedStream).blob();
|
|
36
|
+
|
|
37
|
+
logger.debug(`Compressed payload from ${bytes.length} to ${compressedBlob.size} bytes`);
|
|
38
|
+
|
|
39
|
+
return { data: compressedBlob, compressed: true };
|
|
40
|
+
} catch (e) {
|
|
41
|
+
logger.warn('Failed to compress payload, sending uncompressed', e);
|
|
42
|
+
return { data, compressed: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default network client using the Fetch API.
|
|
48
|
+
*/
|
|
49
|
+
export class FetchNetworkClient implements INetworkClient {
|
|
50
|
+
private retryAfterTime: Date | null = null;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Send events to the MostlyGoodMetrics API.
|
|
54
|
+
*/
|
|
55
|
+
async sendEvents(payload: MGMEventsPayload, config: ResolvedConfiguration): Promise<SendResult> {
|
|
56
|
+
// Check rate limiting
|
|
57
|
+
if (this.isRateLimited()) {
|
|
58
|
+
const retryAfter = this.getRetryAfterTime();
|
|
59
|
+
const waitMs = retryAfter ? retryAfter.getTime() - Date.now() : 0;
|
|
60
|
+
logger.debug(`Rate limited, retry after ${Math.ceil(waitMs / 1000)}s`);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
error: new MGMError('RATE_LIMITED', 'Rate limited, please retry later', {
|
|
65
|
+
retryAfter: Math.ceil(waitMs / 1000),
|
|
66
|
+
}),
|
|
67
|
+
shouldRetry: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const url = `${config.baseURL}${EVENTS_ENDPOINT}`;
|
|
72
|
+
const jsonBody = JSON.stringify(payload);
|
|
73
|
+
const { data, compressed } = await compressIfNeeded(jsonBody);
|
|
74
|
+
|
|
75
|
+
const headers: Record<string, string> = {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'X-MGM-Key': config.apiKey,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (config.bundleId) {
|
|
81
|
+
headers['X-MGM-Bundle-Id'] = config.bundleId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (compressed) {
|
|
85
|
+
headers['Content-Encoding'] = 'gzip';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
logger.debug(`Sending ${payload.events.length} events to ${url}`);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
93
|
+
|
|
94
|
+
const response = await fetch(url, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers,
|
|
97
|
+
body: data,
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
clearTimeout(timeoutId);
|
|
102
|
+
|
|
103
|
+
return this.handleResponse(response);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e instanceof Error && e.name === 'AbortError') {
|
|
106
|
+
logger.warn('Request timed out');
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
error: new MGMError('NETWORK_ERROR', 'Request timed out'),
|
|
110
|
+
shouldRetry: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
logger.error('Network error', e);
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
error: new MGMError(
|
|
118
|
+
'NETWORK_ERROR',
|
|
119
|
+
e instanceof Error ? e.message : 'Unknown network error'
|
|
120
|
+
),
|
|
121
|
+
shouldRetry: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Handle the API response and return appropriate result.
|
|
128
|
+
*/
|
|
129
|
+
private handleResponse(response: Response): SendResult {
|
|
130
|
+
const statusCode = response.status;
|
|
131
|
+
|
|
132
|
+
// Success
|
|
133
|
+
if (statusCode === 204 || statusCode === 200) {
|
|
134
|
+
logger.debug('Events sent successfully');
|
|
135
|
+
return { success: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Rate limited
|
|
139
|
+
if (statusCode === 429) {
|
|
140
|
+
const retryAfterHeader = response.headers.get('Retry-After');
|
|
141
|
+
const retryAfterSeconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 60;
|
|
142
|
+
|
|
143
|
+
this.retryAfterTime = new Date(Date.now() + retryAfterSeconds * 1000);
|
|
144
|
+
|
|
145
|
+
logger.warn(`Rate limited, retry after ${retryAfterSeconds}s`);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
success: false,
|
|
149
|
+
error: new MGMError('RATE_LIMITED', 'Rate limited by server', {
|
|
150
|
+
retryAfter: retryAfterSeconds,
|
|
151
|
+
statusCode,
|
|
152
|
+
}),
|
|
153
|
+
shouldRetry: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Client errors (4xx) - don't retry, drop events
|
|
158
|
+
if (statusCode >= 400 && statusCode < 500) {
|
|
159
|
+
const errorType =
|
|
160
|
+
statusCode === 400
|
|
161
|
+
? 'BAD_REQUEST'
|
|
162
|
+
: statusCode === 401
|
|
163
|
+
? 'UNAUTHORIZED'
|
|
164
|
+
: statusCode === 403
|
|
165
|
+
? 'FORBIDDEN'
|
|
166
|
+
: 'BAD_REQUEST';
|
|
167
|
+
|
|
168
|
+
const errorMessage = `Server returned ${statusCode}`;
|
|
169
|
+
logger.error(errorMessage);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
error: new MGMError(errorType, errorMessage, { statusCode }),
|
|
174
|
+
shouldRetry: false, // Drop events on client errors
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Server errors (5xx) - retry later
|
|
179
|
+
if (statusCode >= 500) {
|
|
180
|
+
const errorMessage = `Server error: ${statusCode}`;
|
|
181
|
+
logger.warn(errorMessage);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
success: false,
|
|
185
|
+
error: new MGMError('SERVER_ERROR', errorMessage, { statusCode }),
|
|
186
|
+
shouldRetry: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Unexpected status code
|
|
191
|
+
logger.warn(`Unexpected status code: ${statusCode}`);
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
error: new MGMError('UNKNOWN_ERROR', `Unexpected status code: ${statusCode}`, {
|
|
195
|
+
statusCode,
|
|
196
|
+
}),
|
|
197
|
+
shouldRetry: true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check if currently rate limited.
|
|
203
|
+
*/
|
|
204
|
+
isRateLimited(): boolean {
|
|
205
|
+
if (!this.retryAfterTime) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (Date.now() >= this.retryAfterTime.getTime()) {
|
|
210
|
+
this.retryAfterTime = null;
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get the time when rate limiting expires.
|
|
219
|
+
*/
|
|
220
|
+
getRetryAfterTime(): Date | null {
|
|
221
|
+
return this.retryAfterTime;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Create the default network client.
|
|
227
|
+
*/
|
|
228
|
+
export function createDefaultNetworkClient(): INetworkClient {
|
|
229
|
+
return new FetchNetworkClient();
|
|
230
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { InMemoryEventStorage, LocalStorageEventStorage } from './storage';
|
|
2
|
+
import { MGMEvent } from './types';
|
|
3
|
+
|
|
4
|
+
const createMockEvent = (name: string): MGMEvent => ({
|
|
5
|
+
name,
|
|
6
|
+
timestamp: new Date().toISOString(),
|
|
7
|
+
platform: 'web',
|
|
8
|
+
environment: 'test',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('InMemoryEventStorage', () => {
|
|
12
|
+
let storage: InMemoryEventStorage;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
storage = new InMemoryEventStorage(100);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should store and retrieve events', async () => {
|
|
19
|
+
const event = createMockEvent('test_event');
|
|
20
|
+
await storage.store(event);
|
|
21
|
+
|
|
22
|
+
const events = await storage.fetchEvents(10);
|
|
23
|
+
expect(events).toHaveLength(1);
|
|
24
|
+
expect(events[0].name).toBe('test_event');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return correct event count', async () => {
|
|
28
|
+
expect(await storage.eventCount()).toBe(0);
|
|
29
|
+
|
|
30
|
+
await storage.store(createMockEvent('event1'));
|
|
31
|
+
expect(await storage.eventCount()).toBe(1);
|
|
32
|
+
|
|
33
|
+
await storage.store(createMockEvent('event2'));
|
|
34
|
+
expect(await storage.eventCount()).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should fetch events in FIFO order', async () => {
|
|
38
|
+
await storage.store(createMockEvent('first'));
|
|
39
|
+
await storage.store(createMockEvent('second'));
|
|
40
|
+
await storage.store(createMockEvent('third'));
|
|
41
|
+
|
|
42
|
+
const events = await storage.fetchEvents(2);
|
|
43
|
+
expect(events).toHaveLength(2);
|
|
44
|
+
expect(events[0].name).toBe('first');
|
|
45
|
+
expect(events[1].name).toBe('second');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should remove events correctly', async () => {
|
|
49
|
+
await storage.store(createMockEvent('first'));
|
|
50
|
+
await storage.store(createMockEvent('second'));
|
|
51
|
+
await storage.store(createMockEvent('third'));
|
|
52
|
+
|
|
53
|
+
await storage.removeEvents(2);
|
|
54
|
+
|
|
55
|
+
const events = await storage.fetchEvents(10);
|
|
56
|
+
expect(events).toHaveLength(1);
|
|
57
|
+
expect(events[0].name).toBe('third');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should clear all events', async () => {
|
|
61
|
+
await storage.store(createMockEvent('event1'));
|
|
62
|
+
await storage.store(createMockEvent('event2'));
|
|
63
|
+
|
|
64
|
+
await storage.clear();
|
|
65
|
+
|
|
66
|
+
expect(await storage.eventCount()).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should drop oldest events when exceeding max', async () => {
|
|
70
|
+
// Note: minimum is 100, so we need to store more than 100 events
|
|
71
|
+
const storage = new InMemoryEventStorage(100);
|
|
72
|
+
|
|
73
|
+
// Store 105 events
|
|
74
|
+
for (let i = 0; i < 105; i++) {
|
|
75
|
+
await storage.store(createMockEvent(`event${i}`));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Should have dropped to 100 (the max)
|
|
79
|
+
expect(await storage.eventCount()).toBe(100);
|
|
80
|
+
|
|
81
|
+
// First 5 events should have been dropped
|
|
82
|
+
const events = await storage.fetchEvents(3);
|
|
83
|
+
expect(events[0].name).toBe('event5');
|
|
84
|
+
expect(events[1].name).toBe('event6');
|
|
85
|
+
expect(events[2].name).toBe('event7');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should enforce minimum storage size', async () => {
|
|
89
|
+
// Even with 0 max, should use minimum (100)
|
|
90
|
+
const storage = new InMemoryEventStorage(0);
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < 150; i++) {
|
|
93
|
+
await storage.store(createMockEvent(`event${i}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Should have dropped to 100 (minimum)
|
|
97
|
+
expect(await storage.eventCount()).toBe(100);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('LocalStorageEventStorage', () => {
|
|
102
|
+
let storage: LocalStorageEventStorage;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
// Mock localStorage
|
|
106
|
+
const localStorageMock = (() => {
|
|
107
|
+
let store: Record<string, string> = {};
|
|
108
|
+
return {
|
|
109
|
+
getItem: jest.fn((key: string) => store[key] ?? null),
|
|
110
|
+
setItem: jest.fn((key: string, value: string) => {
|
|
111
|
+
store[key] = value;
|
|
112
|
+
}),
|
|
113
|
+
removeItem: jest.fn((key: string) => {
|
|
114
|
+
delete store[key];
|
|
115
|
+
}),
|
|
116
|
+
clear: jest.fn(() => {
|
|
117
|
+
store = {};
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
})();
|
|
121
|
+
|
|
122
|
+
Object.defineProperty(window, 'localStorage', {
|
|
123
|
+
value: localStorageMock,
|
|
124
|
+
writable: true,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
storage = new LocalStorageEventStorage(100);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
jest.clearAllMocks();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should store and retrieve events', async () => {
|
|
135
|
+
const event = createMockEvent('test_event');
|
|
136
|
+
await storage.store(event);
|
|
137
|
+
|
|
138
|
+
const events = await storage.fetchEvents(10);
|
|
139
|
+
expect(events).toHaveLength(1);
|
|
140
|
+
expect(events[0].name).toBe('test_event');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should persist events to localStorage', async () => {
|
|
144
|
+
const event = createMockEvent('persisted_event');
|
|
145
|
+
await storage.store(event);
|
|
146
|
+
|
|
147
|
+
expect(localStorage.setItem).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should return correct event count', async () => {
|
|
151
|
+
expect(await storage.eventCount()).toBe(0);
|
|
152
|
+
|
|
153
|
+
await storage.store(createMockEvent('event1'));
|
|
154
|
+
expect(await storage.eventCount()).toBe(1);
|
|
155
|
+
|
|
156
|
+
await storage.store(createMockEvent('event2'));
|
|
157
|
+
expect(await storage.eventCount()).toBe(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should clear events', async () => {
|
|
161
|
+
await storage.store(createMockEvent('event1'));
|
|
162
|
+
await storage.clear();
|
|
163
|
+
|
|
164
|
+
expect(await storage.eventCount()).toBe(0);
|
|
165
|
+
expect(localStorage.removeItem).toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should handle JSON parse errors gracefully', async () => {
|
|
169
|
+
(localStorage.getItem as jest.Mock).mockReturnValueOnce('invalid json');
|
|
170
|
+
|
|
171
|
+
// Should not throw and should return empty array
|
|
172
|
+
const events = await storage.fetchEvents(10);
|
|
173
|
+
expect(events).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { logger } from './logger';
|
|
2
|
+
import { Constraints, IEventStorage, MGMError, MGMEvent } from './types';
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'mostlygoodmetrics_events';
|
|
5
|
+
const USER_ID_KEY = 'mostlygoodmetrics_user_id';
|
|
6
|
+
const APP_VERSION_KEY = 'mostlygoodmetrics_app_version';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if we're running in a browser environment with localStorage available.
|
|
10
|
+
*/
|
|
11
|
+
function isLocalStorageAvailable(): boolean {
|
|
12
|
+
try {
|
|
13
|
+
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const testKey = '__mgm_test__';
|
|
17
|
+
localStorage.setItem(testKey, 'test');
|
|
18
|
+
localStorage.removeItem(testKey);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* In-memory event storage implementation.
|
|
27
|
+
* Used as a fallback when localStorage is not available,
|
|
28
|
+
* or for testing purposes.
|
|
29
|
+
*/
|
|
30
|
+
export class InMemoryEventStorage implements IEventStorage {
|
|
31
|
+
private events: MGMEvent[] = [];
|
|
32
|
+
private maxEvents: number;
|
|
33
|
+
|
|
34
|
+
constructor(maxEvents: number = Constraints.MIN_STORED_EVENTS) {
|
|
35
|
+
this.maxEvents = Math.max(maxEvents, Constraints.MIN_STORED_EVENTS);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async store(event: MGMEvent): Promise<void> {
|
|
39
|
+
this.events.push(event);
|
|
40
|
+
|
|
41
|
+
// Trim oldest events if we exceed the limit
|
|
42
|
+
if (this.events.length > this.maxEvents) {
|
|
43
|
+
const excess = this.events.length - this.maxEvents;
|
|
44
|
+
this.events.splice(0, excess);
|
|
45
|
+
logger.debug(`Dropped ${excess} oldest events due to storage limit`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async fetchEvents(limit: number): Promise<MGMEvent[]> {
|
|
50
|
+
return this.events.slice(0, limit);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async removeEvents(count: number): Promise<void> {
|
|
54
|
+
this.events.splice(0, count);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async eventCount(): Promise<number> {
|
|
58
|
+
return this.events.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async clear(): Promise<void> {
|
|
62
|
+
this.events = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Update the maximum number of stored events.
|
|
67
|
+
*/
|
|
68
|
+
setMaxEvents(maxEvents: number): void {
|
|
69
|
+
this.maxEvents = Math.max(maxEvents, Constraints.MIN_STORED_EVENTS);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* LocalStorage-based event storage implementation.
|
|
75
|
+
* Persists events across page reloads and browser restarts.
|
|
76
|
+
*/
|
|
77
|
+
export class LocalStorageEventStorage implements IEventStorage {
|
|
78
|
+
private maxEvents: number;
|
|
79
|
+
private events: MGMEvent[] | null = null;
|
|
80
|
+
|
|
81
|
+
constructor(maxEvents: number = Constraints.MIN_STORED_EVENTS) {
|
|
82
|
+
this.maxEvents = Math.max(maxEvents, Constraints.MIN_STORED_EVENTS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private loadEvents(): MGMEvent[] {
|
|
86
|
+
if (this.events !== null) {
|
|
87
|
+
return this.events;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
92
|
+
if (stored) {
|
|
93
|
+
this.events = JSON.parse(stored) as MGMEvent[];
|
|
94
|
+
} else {
|
|
95
|
+
this.events = [];
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
logger.warn('Failed to load events from localStorage', e);
|
|
99
|
+
this.events = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return this.events;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private saveEvents(): void {
|
|
106
|
+
try {
|
|
107
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.events ?? []));
|
|
108
|
+
} catch (e) {
|
|
109
|
+
logger.error('Failed to save events to localStorage', e);
|
|
110
|
+
throw new MGMError('STORAGE_ERROR', 'Failed to save events to localStorage');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async store(event: MGMEvent): Promise<void> {
|
|
115
|
+
const events = this.loadEvents();
|
|
116
|
+
events.push(event);
|
|
117
|
+
|
|
118
|
+
// Trim oldest events if we exceed the limit
|
|
119
|
+
if (events.length > this.maxEvents) {
|
|
120
|
+
const excess = events.length - this.maxEvents;
|
|
121
|
+
events.splice(0, excess);
|
|
122
|
+
logger.debug(`Dropped ${excess} oldest events due to storage limit`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.saveEvents();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async fetchEvents(limit: number): Promise<MGMEvent[]> {
|
|
129
|
+
const events = this.loadEvents();
|
|
130
|
+
return events.slice(0, limit);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async removeEvents(count: number): Promise<void> {
|
|
134
|
+
const events = this.loadEvents();
|
|
135
|
+
events.splice(0, count);
|
|
136
|
+
this.saveEvents();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async eventCount(): Promise<number> {
|
|
140
|
+
return this.loadEvents().length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async clear(): Promise<void> {
|
|
144
|
+
this.events = [];
|
|
145
|
+
try {
|
|
146
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
logger.warn('Failed to clear events from localStorage', e);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Update the maximum number of stored events.
|
|
154
|
+
*/
|
|
155
|
+
setMaxEvents(maxEvents: number): void {
|
|
156
|
+
this.maxEvents = Math.max(maxEvents, Constraints.MIN_STORED_EVENTS);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create the appropriate storage implementation based on the environment.
|
|
162
|
+
*/
|
|
163
|
+
export function createDefaultStorage(maxEvents: number): IEventStorage {
|
|
164
|
+
if (isLocalStorageAvailable()) {
|
|
165
|
+
logger.debug('Using LocalStorage for event persistence');
|
|
166
|
+
return new LocalStorageEventStorage(maxEvents);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
logger.debug('LocalStorage not available, using in-memory storage');
|
|
170
|
+
return new InMemoryEventStorage(maxEvents);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Persistence helpers for user ID and app version.
|
|
175
|
+
* These use localStorage when available, otherwise fall back to in-memory.
|
|
176
|
+
*/
|
|
177
|
+
class PersistenceManager {
|
|
178
|
+
private inMemoryUserId: string | null = null;
|
|
179
|
+
private inMemoryAppVersion: string | null = null;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the persisted user ID.
|
|
183
|
+
*/
|
|
184
|
+
getUserId(): string | null {
|
|
185
|
+
if (isLocalStorageAvailable()) {
|
|
186
|
+
return localStorage.getItem(USER_ID_KEY);
|
|
187
|
+
}
|
|
188
|
+
return this.inMemoryUserId;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Set the user ID (persists across sessions).
|
|
193
|
+
*/
|
|
194
|
+
setUserId(userId: string | null): void {
|
|
195
|
+
if (isLocalStorageAvailable()) {
|
|
196
|
+
if (userId) {
|
|
197
|
+
localStorage.setItem(USER_ID_KEY, userId);
|
|
198
|
+
} else {
|
|
199
|
+
localStorage.removeItem(USER_ID_KEY);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.inMemoryUserId = userId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get the persisted app version (for detecting updates).
|
|
207
|
+
*/
|
|
208
|
+
getAppVersion(): string | null {
|
|
209
|
+
if (isLocalStorageAvailable()) {
|
|
210
|
+
return localStorage.getItem(APP_VERSION_KEY);
|
|
211
|
+
}
|
|
212
|
+
return this.inMemoryAppVersion;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Set the app version.
|
|
217
|
+
*/
|
|
218
|
+
setAppVersion(version: string | null): void {
|
|
219
|
+
if (isLocalStorageAvailable()) {
|
|
220
|
+
if (version) {
|
|
221
|
+
localStorage.setItem(APP_VERSION_KEY, version);
|
|
222
|
+
} else {
|
|
223
|
+
localStorage.removeItem(APP_VERSION_KEY);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
this.inMemoryAppVersion = version;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if this is the first time the app has been opened.
|
|
231
|
+
* Uses localStorage to detect first-ever installation.
|
|
232
|
+
*/
|
|
233
|
+
isFirstLaunch(): boolean {
|
|
234
|
+
const FIRST_LAUNCH_KEY = 'mostlygoodmetrics_installed';
|
|
235
|
+
|
|
236
|
+
if (!isLocalStorageAvailable()) {
|
|
237
|
+
return false; // Can't reliably detect without persistence
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const hasLaunched = localStorage.getItem(FIRST_LAUNCH_KEY);
|
|
241
|
+
if (!hasLaunched) {
|
|
242
|
+
localStorage.setItem(FIRST_LAUNCH_KEY, 'true');
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const persistence = new PersistenceManager();
|