@geekmidas/audit 9.0.2 → 10.0.0-alpha.1
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 +2 -2
- package/package.json +8 -5
- package/CHANGELOG.md +0 -101
- package/TECHNICAL.md +0 -937
- package/src/Auditor.ts +0 -157
- package/src/DefaultAuditor.ts +0 -141
- package/src/__tests__/CacheAuditStorage.spec.ts +0 -382
- package/src/__tests__/DefaultAuditor.spec.ts +0 -529
- package/src/__tests__/InMemoryAuditStorage.spec.ts +0 -317
- package/src/__tests__/KnexAuditStorage.integration.spec.ts +0 -739
- package/src/__tests__/KyselyAuditStorage.integration.spec.ts +0 -518
- package/src/__tests__/KyselyAuditStorage.spec.ts +0 -443
- package/src/cache.ts +0 -263
- package/src/index.ts +0 -23
- package/src/knex.ts +0 -430
- package/src/kysely.ts +0 -424
- package/src/memory.ts +0 -50
- package/src/storage.ts +0 -203
- package/src/types.ts +0 -154
- package/tsconfig.json +0 -9
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { type AuditLogTable, KyselyAuditStorage } from '../kysely';
|
|
3
|
-
import type { AuditRecord } from '../types';
|
|
4
|
-
|
|
5
|
-
// Mock Kysely database
|
|
6
|
-
function createMockDb() {
|
|
7
|
-
const rows: AuditLogTable[] = [];
|
|
8
|
-
|
|
9
|
-
const insertBuilder = {
|
|
10
|
-
values: vi.fn().mockReturnThis(),
|
|
11
|
-
execute: vi.fn(async () => {
|
|
12
|
-
return [];
|
|
13
|
-
}),
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const selectBuilder = {
|
|
17
|
-
selectAll: vi.fn().mockReturnThis(),
|
|
18
|
-
select: vi.fn().mockReturnThis(),
|
|
19
|
-
where: vi.fn().mockReturnThis(),
|
|
20
|
-
orderBy: vi.fn().mockReturnThis(),
|
|
21
|
-
limit: vi.fn().mockReturnThis(),
|
|
22
|
-
offset: vi.fn().mockReturnThis(),
|
|
23
|
-
execute: vi.fn(async () => rows),
|
|
24
|
-
executeTakeFirst: vi.fn(async () => ({ count: rows.length })),
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const db = {
|
|
28
|
-
insertInto: vi.fn(() => insertBuilder),
|
|
29
|
-
selectFrom: vi.fn(() => selectBuilder),
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
db,
|
|
34
|
-
insertBuilder,
|
|
35
|
-
selectBuilder,
|
|
36
|
-
rows,
|
|
37
|
-
addRow: (row: AuditLogTable) => rows.push(row),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('KyselyAuditStorage', () => {
|
|
42
|
-
let mockDb: ReturnType<typeof createMockDb>;
|
|
43
|
-
let storage: KyselyAuditStorage<{ audit_logs: AuditLogTable }>;
|
|
44
|
-
|
|
45
|
-
beforeEach(() => {
|
|
46
|
-
mockDb = createMockDb();
|
|
47
|
-
storage = new KyselyAuditStorage({
|
|
48
|
-
db: mockDb.db as any,
|
|
49
|
-
tableName: 'audit_logs',
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe('write', () => {
|
|
54
|
-
it('should write records to database', async () => {
|
|
55
|
-
const records: AuditRecord[] = [
|
|
56
|
-
{
|
|
57
|
-
id: 'audit-1',
|
|
58
|
-
type: 'user.created',
|
|
59
|
-
operation: 'INSERT',
|
|
60
|
-
table: 'users',
|
|
61
|
-
entityId: 'user-123',
|
|
62
|
-
payload: { email: 'test@example.com' },
|
|
63
|
-
timestamp: new Date('2024-01-01T00:00:00Z'),
|
|
64
|
-
actor: { id: 'admin-1', type: 'admin' },
|
|
65
|
-
metadata: { requestId: 'req-123' },
|
|
66
|
-
},
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
await storage.write(records);
|
|
70
|
-
|
|
71
|
-
expect(mockDb.db.insertInto).toHaveBeenCalledWith('audit_logs');
|
|
72
|
-
expect(mockDb.insertBuilder.values).toHaveBeenCalledWith([
|
|
73
|
-
expect.objectContaining({
|
|
74
|
-
id: 'audit-1',
|
|
75
|
-
type: 'user.created',
|
|
76
|
-
operation: 'INSERT',
|
|
77
|
-
table: 'users',
|
|
78
|
-
entityId: 'user-123',
|
|
79
|
-
payload: { email: 'test@example.com' },
|
|
80
|
-
actorId: 'admin-1',
|
|
81
|
-
actorType: 'admin',
|
|
82
|
-
metadata: { requestId: 'req-123' },
|
|
83
|
-
}),
|
|
84
|
-
]);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('should use transaction when provided', async () => {
|
|
88
|
-
const mockTrx = {
|
|
89
|
-
insertInto: vi.fn(() => ({
|
|
90
|
-
values: vi.fn().mockReturnThis(),
|
|
91
|
-
execute: vi.fn(),
|
|
92
|
-
})),
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const records: AuditRecord[] = [
|
|
96
|
-
{
|
|
97
|
-
id: 'audit-1',
|
|
98
|
-
type: 'user.created',
|
|
99
|
-
operation: 'CUSTOM',
|
|
100
|
-
timestamp: new Date(),
|
|
101
|
-
},
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
await storage.write(records, mockTrx);
|
|
105
|
-
|
|
106
|
-
expect(mockTrx.insertInto).toHaveBeenCalledWith('audit_logs');
|
|
107
|
-
expect(mockDb.db.insertInto).not.toHaveBeenCalled();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('should do nothing for empty records', async () => {
|
|
111
|
-
await storage.write([]);
|
|
112
|
-
|
|
113
|
-
expect(mockDb.db.insertInto).not.toHaveBeenCalled();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('should handle complex entity IDs', async () => {
|
|
117
|
-
const records: AuditRecord[] = [
|
|
118
|
-
{
|
|
119
|
-
id: 'audit-1',
|
|
120
|
-
type: 'relation.created',
|
|
121
|
-
operation: 'INSERT',
|
|
122
|
-
entityId: { userId: 'u1', roleId: 'r1' },
|
|
123
|
-
timestamp: new Date(),
|
|
124
|
-
},
|
|
125
|
-
];
|
|
126
|
-
|
|
127
|
-
await storage.write(records);
|
|
128
|
-
|
|
129
|
-
expect(mockDb.insertBuilder.values).toHaveBeenCalledWith([
|
|
130
|
-
expect.objectContaining({
|
|
131
|
-
entityId: JSON.stringify({ userId: 'u1', roleId: 'r1' }),
|
|
132
|
-
}),
|
|
133
|
-
]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('should store extra actor properties', async () => {
|
|
137
|
-
const records: AuditRecord[] = [
|
|
138
|
-
{
|
|
139
|
-
id: 'audit-1',
|
|
140
|
-
type: 'user.created',
|
|
141
|
-
operation: 'CUSTOM',
|
|
142
|
-
timestamp: new Date(),
|
|
143
|
-
actor: {
|
|
144
|
-
id: 'user-1',
|
|
145
|
-
type: 'user',
|
|
146
|
-
email: 'user@example.com',
|
|
147
|
-
role: 'admin',
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
];
|
|
151
|
-
|
|
152
|
-
await storage.write(records);
|
|
153
|
-
|
|
154
|
-
expect(mockDb.insertBuilder.values).toHaveBeenCalledWith([
|
|
155
|
-
expect.objectContaining({
|
|
156
|
-
actorId: 'user-1',
|
|
157
|
-
actorType: 'user',
|
|
158
|
-
actorData: {
|
|
159
|
-
email: 'user@example.com',
|
|
160
|
-
role: 'admin',
|
|
161
|
-
},
|
|
162
|
-
}),
|
|
163
|
-
]);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('should generate ID with nanoid when autoId is false (default)', async () => {
|
|
167
|
-
const records: AuditRecord[] = [
|
|
168
|
-
{
|
|
169
|
-
id: '', // Empty ID
|
|
170
|
-
type: 'user.created',
|
|
171
|
-
operation: 'CUSTOM',
|
|
172
|
-
timestamp: new Date(),
|
|
173
|
-
},
|
|
174
|
-
];
|
|
175
|
-
|
|
176
|
-
await storage.write(records);
|
|
177
|
-
|
|
178
|
-
const calledValues = mockDb.insertBuilder.values.mock.calls[0][0];
|
|
179
|
-
expect(calledValues[0].id).toBeDefined();
|
|
180
|
-
expect(calledValues[0].id.length).toBeGreaterThan(0);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('should use provided ID when autoId is false (default)', async () => {
|
|
184
|
-
const records: AuditRecord[] = [
|
|
185
|
-
{
|
|
186
|
-
id: 'my-custom-id',
|
|
187
|
-
type: 'user.created',
|
|
188
|
-
operation: 'CUSTOM',
|
|
189
|
-
timestamp: new Date(),
|
|
190
|
-
},
|
|
191
|
-
];
|
|
192
|
-
|
|
193
|
-
await storage.write(records);
|
|
194
|
-
|
|
195
|
-
expect(mockDb.insertBuilder.values).toHaveBeenCalledWith([
|
|
196
|
-
expect.objectContaining({
|
|
197
|
-
id: 'my-custom-id',
|
|
198
|
-
}),
|
|
199
|
-
]);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('should omit ID when autoId is true and no ID provided', async () => {
|
|
203
|
-
const storageAutoId = new KyselyAuditStorage({
|
|
204
|
-
db: mockDb.db as any,
|
|
205
|
-
tableName: 'audit_logs',
|
|
206
|
-
autoId: true,
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
const records: AuditRecord[] = [
|
|
210
|
-
{
|
|
211
|
-
id: '', // Empty ID - let database generate
|
|
212
|
-
type: 'user.created',
|
|
213
|
-
operation: 'CUSTOM',
|
|
214
|
-
timestamp: new Date(),
|
|
215
|
-
},
|
|
216
|
-
];
|
|
217
|
-
|
|
218
|
-
await storageAutoId.write(records);
|
|
219
|
-
|
|
220
|
-
const calledValues = mockDb.insertBuilder.values.mock.calls[0][0];
|
|
221
|
-
expect(calledValues[0].id).toBeUndefined();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('should omit ID even if provided when autoId is true', async () => {
|
|
225
|
-
const storageAutoId = new KyselyAuditStorage({
|
|
226
|
-
db: mockDb.db as any,
|
|
227
|
-
tableName: 'audit_logs',
|
|
228
|
-
autoId: true,
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
const records: AuditRecord[] = [
|
|
232
|
-
{
|
|
233
|
-
id: 'explicit-id', // This should be ignored when autoId is true
|
|
234
|
-
type: 'user.created',
|
|
235
|
-
operation: 'CUSTOM',
|
|
236
|
-
timestamp: new Date(),
|
|
237
|
-
},
|
|
238
|
-
];
|
|
239
|
-
|
|
240
|
-
await storageAutoId.write(records);
|
|
241
|
-
|
|
242
|
-
// When autoId is true, ID should always be omitted (let database generate)
|
|
243
|
-
const calledValues = mockDb.insertBuilder.values.mock.calls[0][0];
|
|
244
|
-
expect(calledValues[0].id).toBeUndefined();
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe('query', () => {
|
|
249
|
-
it('should return records from database', async () => {
|
|
250
|
-
mockDb.addRow({
|
|
251
|
-
id: 'audit-1',
|
|
252
|
-
type: 'user.created',
|
|
253
|
-
operation: 'INSERT',
|
|
254
|
-
table: 'users',
|
|
255
|
-
entityId: 'user-123',
|
|
256
|
-
oldValues: null,
|
|
257
|
-
newValues: JSON.stringify({ name: 'Test' }),
|
|
258
|
-
payload: JSON.stringify({ email: 'test@example.com' }),
|
|
259
|
-
timestamp: new Date('2024-01-01T00:00:00Z'),
|
|
260
|
-
actorId: 'admin-1',
|
|
261
|
-
actorType: 'admin',
|
|
262
|
-
actorData: null,
|
|
263
|
-
metadata: JSON.stringify({ requestId: 'req-123' }),
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
const results = await storage.query({});
|
|
267
|
-
|
|
268
|
-
expect(results).toHaveLength(1);
|
|
269
|
-
expect(results[0]).toMatchObject({
|
|
270
|
-
id: 'audit-1',
|
|
271
|
-
type: 'user.created',
|
|
272
|
-
operation: 'INSERT',
|
|
273
|
-
table: 'users',
|
|
274
|
-
entityId: 'user-123',
|
|
275
|
-
newValues: { name: 'Test' },
|
|
276
|
-
payload: { email: 'test@example.com' },
|
|
277
|
-
actor: { id: 'admin-1', type: 'admin' },
|
|
278
|
-
metadata: { requestId: 'req-123' },
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it('should apply type filter', async () => {
|
|
283
|
-
await storage.query({ type: 'user.created' });
|
|
284
|
-
|
|
285
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
286
|
-
'type',
|
|
287
|
-
'=',
|
|
288
|
-
'user.created',
|
|
289
|
-
);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('should apply type array filter', async () => {
|
|
293
|
-
await storage.query({ type: ['user.created', 'user.updated'] });
|
|
294
|
-
|
|
295
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith('type', 'in', [
|
|
296
|
-
'user.created',
|
|
297
|
-
'user.updated',
|
|
298
|
-
]);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it('should apply entity ID filter', async () => {
|
|
302
|
-
await storage.query({ entityId: 'user-123' });
|
|
303
|
-
|
|
304
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
305
|
-
'entityId',
|
|
306
|
-
'=',
|
|
307
|
-
'user-123',
|
|
308
|
-
);
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
it('should apply table filter', async () => {
|
|
312
|
-
await storage.query({ table: 'users' });
|
|
313
|
-
|
|
314
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
315
|
-
'table',
|
|
316
|
-
'=',
|
|
317
|
-
'users',
|
|
318
|
-
);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('should apply actor ID filter', async () => {
|
|
322
|
-
await storage.query({ actorId: 'admin-1' });
|
|
323
|
-
|
|
324
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
325
|
-
'actorId',
|
|
326
|
-
'=',
|
|
327
|
-
'admin-1',
|
|
328
|
-
);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('should apply date range filters', async () => {
|
|
332
|
-
const from = new Date('2024-01-01');
|
|
333
|
-
const to = new Date('2024-12-31');
|
|
334
|
-
|
|
335
|
-
await storage.query({ from, to });
|
|
336
|
-
|
|
337
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
338
|
-
'timestamp',
|
|
339
|
-
'>=',
|
|
340
|
-
from,
|
|
341
|
-
);
|
|
342
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
343
|
-
'timestamp',
|
|
344
|
-
'<=',
|
|
345
|
-
to,
|
|
346
|
-
);
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it('should apply pagination', async () => {
|
|
350
|
-
await storage.query({ limit: 10, offset: 20 });
|
|
351
|
-
|
|
352
|
-
expect(mockDb.selectBuilder.limit).toHaveBeenCalledWith(10);
|
|
353
|
-
expect(mockDb.selectBuilder.offset).toHaveBeenCalledWith(20);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it('should apply ordering', async () => {
|
|
357
|
-
await storage.query({ orderBy: 'type', orderDirection: 'asc' });
|
|
358
|
-
|
|
359
|
-
expect(mockDb.selectBuilder.orderBy).toHaveBeenCalledWith('type', 'asc');
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('should default to timestamp desc ordering', async () => {
|
|
363
|
-
await storage.query({});
|
|
364
|
-
|
|
365
|
-
expect(mockDb.selectBuilder.orderBy).toHaveBeenCalledWith(
|
|
366
|
-
'timestamp',
|
|
367
|
-
'desc',
|
|
368
|
-
);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it('should parse complex entity IDs', async () => {
|
|
372
|
-
mockDb.addRow({
|
|
373
|
-
id: 'audit-1',
|
|
374
|
-
type: 'relation.created',
|
|
375
|
-
operation: 'INSERT',
|
|
376
|
-
table: null,
|
|
377
|
-
entityId: JSON.stringify({ userId: 'u1', roleId: 'r1' }),
|
|
378
|
-
oldValues: null,
|
|
379
|
-
newValues: null,
|
|
380
|
-
payload: null,
|
|
381
|
-
timestamp: new Date(),
|
|
382
|
-
actorId: null,
|
|
383
|
-
actorType: null,
|
|
384
|
-
actorData: null,
|
|
385
|
-
metadata: null,
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
const results = await storage.query({});
|
|
389
|
-
|
|
390
|
-
expect(results[0].entityId).toEqual({ userId: 'u1', roleId: 'r1' });
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
it('should merge actor data', async () => {
|
|
394
|
-
mockDb.addRow({
|
|
395
|
-
id: 'audit-1',
|
|
396
|
-
type: 'user.created',
|
|
397
|
-
operation: 'CUSTOM',
|
|
398
|
-
table: null,
|
|
399
|
-
entityId: null,
|
|
400
|
-
oldValues: null,
|
|
401
|
-
newValues: null,
|
|
402
|
-
payload: null,
|
|
403
|
-
timestamp: new Date(),
|
|
404
|
-
actorId: 'user-1',
|
|
405
|
-
actorType: 'user',
|
|
406
|
-
actorData: JSON.stringify({ email: 'user@example.com' }),
|
|
407
|
-
metadata: null,
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
const results = await storage.query({});
|
|
411
|
-
|
|
412
|
-
expect(results[0].actor).toEqual({
|
|
413
|
-
id: 'user-1',
|
|
414
|
-
type: 'user',
|
|
415
|
-
email: 'user@example.com',
|
|
416
|
-
});
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
describe('count', () => {
|
|
421
|
-
it('should return count from database', async () => {
|
|
422
|
-
const count = await storage.count({});
|
|
423
|
-
|
|
424
|
-
expect(count).toBe(0);
|
|
425
|
-
expect(mockDb.selectBuilder.select).toHaveBeenCalled();
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it('should apply filters to count', async () => {
|
|
429
|
-
await storage.count({ type: 'user.created', actorId: 'admin-1' });
|
|
430
|
-
|
|
431
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
432
|
-
'type',
|
|
433
|
-
'=',
|
|
434
|
-
'user.created',
|
|
435
|
-
);
|
|
436
|
-
expect(mockDb.selectBuilder.where).toHaveBeenCalledWith(
|
|
437
|
-
'actorId',
|
|
438
|
-
'=',
|
|
439
|
-
'admin-1',
|
|
440
|
-
);
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
});
|
package/src/cache.ts
DELETED
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
import type { Cache } from '@geekmidas/cache';
|
|
2
|
-
import type { AuditQueryOptions, AuditStorage } from './storage';
|
|
3
|
-
import type { AuditableAction, AuditRecord } from './types';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Configuration for CacheAuditStorage.
|
|
7
|
-
*/
|
|
8
|
-
export interface CacheAuditStorageConfig {
|
|
9
|
-
/** Cache instance to use for storage */
|
|
10
|
-
cache: Cache;
|
|
11
|
-
/**
|
|
12
|
-
* Key prefix for audit records.
|
|
13
|
-
* Records are stored as `${prefix}:${id}`.
|
|
14
|
-
* @default 'audit'
|
|
15
|
-
*/
|
|
16
|
-
prefix?: string;
|
|
17
|
-
/**
|
|
18
|
-
* TTL (time-to-live) in seconds for audit records.
|
|
19
|
-
* If not set, records use the cache's default TTL.
|
|
20
|
-
*/
|
|
21
|
-
ttl?: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Cache-based audit storage implementation.
|
|
26
|
-
* Uses any @geekmidas/cache implementation for storage.
|
|
27
|
-
*
|
|
28
|
-
* Best suited for:
|
|
29
|
-
* - Development and testing
|
|
30
|
-
* - Temporary audit logs that don't need persistence
|
|
31
|
-
* - Applications with existing cache infrastructure
|
|
32
|
-
* - Distributed systems needing shared audit state (with Redis/Upstash)
|
|
33
|
-
*
|
|
34
|
-
* Note: Query performance may degrade with large numbers of records
|
|
35
|
-
* since filtering happens in memory after fetching all records.
|
|
36
|
-
*
|
|
37
|
-
* @template TAuditAction - Optional type parameter for type-safe audit actions.
|
|
38
|
-
*
|
|
39
|
-
* @example
|
|
40
|
-
* ```typescript
|
|
41
|
-
* import { CacheAuditStorage } from '@geekmidas/audit/cache';
|
|
42
|
-
* import { InMemoryCache } from '@geekmidas/cache/memory';
|
|
43
|
-
* import { UpstashCache } from '@geekmidas/cache/upstash';
|
|
44
|
-
*
|
|
45
|
-
* // With in-memory cache (development/testing)
|
|
46
|
-
* const storage = new CacheAuditStorage({
|
|
47
|
-
* cache: new InMemoryCache(),
|
|
48
|
-
* ttl: 86400, // 24 hours
|
|
49
|
-
* });
|
|
50
|
-
*
|
|
51
|
-
* // With Upstash Redis (production)
|
|
52
|
-
* const storage = new CacheAuditStorage({
|
|
53
|
-
* cache: new UpstashCache({
|
|
54
|
-
* url: process.env.UPSTASH_REDIS_URL,
|
|
55
|
-
* token: process.env.UPSTASH_REDIS_TOKEN,
|
|
56
|
-
* }),
|
|
57
|
-
* ttl: 604800, // 7 days
|
|
58
|
-
* });
|
|
59
|
-
* ```
|
|
60
|
-
*/
|
|
61
|
-
export class CacheAuditStorage<
|
|
62
|
-
TAuditAction extends AuditableAction<string, unknown> = AuditableAction<
|
|
63
|
-
string,
|
|
64
|
-
unknown
|
|
65
|
-
>,
|
|
66
|
-
> implements AuditStorage<TAuditAction>
|
|
67
|
-
{
|
|
68
|
-
private readonly cache: Cache;
|
|
69
|
-
private readonly prefix: string;
|
|
70
|
-
private readonly ttl?: number;
|
|
71
|
-
private readonly indexKey: string;
|
|
72
|
-
|
|
73
|
-
constructor(config: CacheAuditStorageConfig) {
|
|
74
|
-
this.cache = config.cache;
|
|
75
|
-
this.prefix = config.prefix ?? 'audit';
|
|
76
|
-
this.ttl = config.ttl;
|
|
77
|
-
this.indexKey = `${this.prefix}:__index__`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Write audit records to cache.
|
|
82
|
-
*/
|
|
83
|
-
async write(records: AuditRecord[]): Promise<void> {
|
|
84
|
-
if (records.length === 0) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Get existing index
|
|
89
|
-
const existingIds = (await this.cache.get<string[]>(this.indexKey)) ?? [];
|
|
90
|
-
const newIds: string[] = [];
|
|
91
|
-
|
|
92
|
-
// Write each record
|
|
93
|
-
for (const record of records) {
|
|
94
|
-
const key = this.getRecordKey(record.id);
|
|
95
|
-
// Serialize Date to ISO string for cache storage
|
|
96
|
-
const serialized = this.serializeRecord(record);
|
|
97
|
-
await this.cache.set(key, serialized, this.ttl);
|
|
98
|
-
newIds.push(record.id);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Update index with new IDs
|
|
102
|
-
const updatedIds = [...existingIds, ...newIds];
|
|
103
|
-
await this.cache.set(this.indexKey, updatedIds, this.ttl);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Query audit records from cache.
|
|
108
|
-
*/
|
|
109
|
-
async query(options: AuditQueryOptions): Promise<AuditRecord[]> {
|
|
110
|
-
const allRecords = await this.getAllRecords();
|
|
111
|
-
let results = this.applyFilters(allRecords, options);
|
|
112
|
-
|
|
113
|
-
// Ordering
|
|
114
|
-
const orderBy = options.orderBy ?? 'timestamp';
|
|
115
|
-
const orderDirection = options.orderDirection ?? 'desc';
|
|
116
|
-
results.sort((a, b) => {
|
|
117
|
-
const aValue = orderBy === 'timestamp' ? a.timestamp.getTime() : a.type;
|
|
118
|
-
const bValue = orderBy === 'timestamp' ? b.timestamp.getTime() : b.type;
|
|
119
|
-
if (aValue < bValue) return orderDirection === 'asc' ? -1 : 1;
|
|
120
|
-
if (aValue > bValue) return orderDirection === 'asc' ? 1 : -1;
|
|
121
|
-
return 0;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Pagination
|
|
125
|
-
if (options.offset !== undefined) {
|
|
126
|
-
results = results.slice(options.offset);
|
|
127
|
-
}
|
|
128
|
-
if (options.limit !== undefined) {
|
|
129
|
-
results = results.slice(0, options.limit);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return results;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Count audit records matching filters.
|
|
137
|
-
*/
|
|
138
|
-
async count(
|
|
139
|
-
options: Omit<AuditQueryOptions, 'limit' | 'offset'>,
|
|
140
|
-
): Promise<number> {
|
|
141
|
-
const allRecords = await this.getAllRecords();
|
|
142
|
-
return this.applyFilters(allRecords, options).length;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Get all stored records (for testing/debugging).
|
|
147
|
-
*/
|
|
148
|
-
async getRecords(): Promise<AuditRecord[]> {
|
|
149
|
-
return this.getAllRecords();
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Clear all stored records.
|
|
154
|
-
*/
|
|
155
|
-
async clear(): Promise<void> {
|
|
156
|
-
const ids = (await this.cache.get<string[]>(this.indexKey)) ?? [];
|
|
157
|
-
|
|
158
|
-
// Delete all records
|
|
159
|
-
for (const id of ids) {
|
|
160
|
-
await this.cache.delete(this.getRecordKey(id));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Delete index
|
|
164
|
-
await this.cache.delete(this.indexKey);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
private getRecordKey(id: string): string {
|
|
168
|
-
return `${this.prefix}:${id}`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private async getAllRecords(): Promise<AuditRecord[]> {
|
|
172
|
-
const ids = (await this.cache.get<string[]>(this.indexKey)) ?? [];
|
|
173
|
-
const records: AuditRecord[] = [];
|
|
174
|
-
const validIds: string[] = [];
|
|
175
|
-
|
|
176
|
-
for (const id of ids) {
|
|
177
|
-
const key = this.getRecordKey(id);
|
|
178
|
-
const serialized = await this.cache.get<SerializedAuditRecord>(key);
|
|
179
|
-
if (serialized) {
|
|
180
|
-
records.push(this.deserializeRecord(serialized));
|
|
181
|
-
validIds.push(id);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Clean up index if some records expired
|
|
186
|
-
if (validIds.length !== ids.length) {
|
|
187
|
-
await this.cache.set(this.indexKey, validIds, this.ttl);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return records;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
private serializeRecord(record: AuditRecord): SerializedAuditRecord {
|
|
194
|
-
return {
|
|
195
|
-
...record,
|
|
196
|
-
timestamp: record.timestamp.toISOString(),
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private deserializeRecord(serialized: SerializedAuditRecord): AuditRecord {
|
|
201
|
-
return {
|
|
202
|
-
...serialized,
|
|
203
|
-
timestamp: new Date(serialized.timestamp),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
private applyFilters(
|
|
208
|
-
records: AuditRecord[],
|
|
209
|
-
options: AuditQueryOptions,
|
|
210
|
-
): AuditRecord[] {
|
|
211
|
-
return records.filter((record) => {
|
|
212
|
-
// Type filter
|
|
213
|
-
if (options.type !== undefined) {
|
|
214
|
-
if (Array.isArray(options.type)) {
|
|
215
|
-
if (!options.type.includes(record.type)) return false;
|
|
216
|
-
} else {
|
|
217
|
-
if (record.type !== options.type) return false;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Entity ID filter
|
|
222
|
-
if (options.entityId !== undefined) {
|
|
223
|
-
const entityId =
|
|
224
|
-
typeof options.entityId === 'string'
|
|
225
|
-
? options.entityId
|
|
226
|
-
: JSON.stringify(options.entityId);
|
|
227
|
-
const recordEntityId =
|
|
228
|
-
typeof record.entityId === 'string'
|
|
229
|
-
? record.entityId
|
|
230
|
-
: JSON.stringify(record.entityId);
|
|
231
|
-
if (recordEntityId !== entityId) return false;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Table filter
|
|
235
|
-
if (options.table !== undefined) {
|
|
236
|
-
if (record.table !== options.table) return false;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Actor ID filter
|
|
240
|
-
if (options.actorId !== undefined) {
|
|
241
|
-
if (record.actor?.id !== options.actorId) return false;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Date range filters
|
|
245
|
-
if (options.from !== undefined) {
|
|
246
|
-
if (record.timestamp < options.from) return false;
|
|
247
|
-
}
|
|
248
|
-
if (options.to !== undefined) {
|
|
249
|
-
if (record.timestamp > options.to) return false;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return true;
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Serialized version of AuditRecord for cache storage.
|
|
259
|
-
* Dates are stored as ISO strings.
|
|
260
|
-
*/
|
|
261
|
-
type SerializedAuditRecord = Omit<AuditRecord, 'timestamp'> & {
|
|
262
|
-
timestamp: string;
|
|
263
|
-
};
|