@geekmidas/audit 2.1.0 → 2.2.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +74 -2
  3. package/dist/{Auditor-_Dn2dp8d.d.mts → Auditor-CW2NrfHS.d.mts} +30 -2
  4. package/dist/Auditor-CW2NrfHS.d.mts.map +1 -0
  5. package/dist/{Auditor-sW7YAJIA.d.cts → Auditor-DpBHXIJM.d.cts} +30 -2
  6. package/dist/Auditor-DpBHXIJM.d.cts.map +1 -0
  7. package/dist/Auditor.d.cts +1 -1
  8. package/dist/Auditor.d.mts +1 -1
  9. package/dist/{DefaultAuditor-BSYMwojG.d.cts → DefaultAuditor-DlJ1ENfx.d.mts} +2 -2
  10. package/dist/{DefaultAuditor-BSYMwojG.d.cts.map → DefaultAuditor-DlJ1ENfx.d.mts.map} +1 -1
  11. package/dist/{DefaultAuditor-CYE-uAry.d.mts → DefaultAuditor-GRFI86N_.d.cts} +2 -2
  12. package/dist/{DefaultAuditor-CYE-uAry.d.mts.map → DefaultAuditor-GRFI86N_.d.cts.map} +1 -1
  13. package/dist/DefaultAuditor.d.cts +2 -2
  14. package/dist/DefaultAuditor.d.mts +2 -2
  15. package/dist/{cache-2VI80Vao.d.mts → cache-Bih6HxY6.d.cts} +2 -2
  16. package/dist/{cache-2VI80Vao.d.mts.map → cache-Bih6HxY6.d.cts.map} +1 -1
  17. package/dist/{cache-CDHqXqcl.d.cts → cache-wET57t6d.d.mts} +2 -2
  18. package/dist/{cache-CDHqXqcl.d.cts.map → cache-wET57t6d.d.mts.map} +1 -1
  19. package/dist/cache.d.cts +2 -2
  20. package/dist/cache.d.mts +2 -2
  21. package/dist/index.d.cts +2 -2
  22. package/dist/index.d.mts +2 -2
  23. package/dist/knex.cjs +233 -0
  24. package/dist/knex.cjs.map +1 -0
  25. package/dist/knex.d.cts +178 -0
  26. package/dist/knex.d.cts.map +1 -0
  27. package/dist/knex.d.mts +178 -0
  28. package/dist/knex.d.mts.map +1 -0
  29. package/dist/knex.mjs +231 -0
  30. package/dist/knex.mjs.map +1 -0
  31. package/dist/kysely.cjs.map +1 -1
  32. package/dist/kysely.d.cts +1 -29
  33. package/dist/kysely.d.cts.map +1 -1
  34. package/dist/kysely.d.mts +1 -29
  35. package/dist/kysely.d.mts.map +1 -1
  36. package/dist/kysely.mjs.map +1 -1
  37. package/dist/memory.d.cts +2 -2
  38. package/dist/memory.d.mts +2 -2
  39. package/dist/storage.d.cts +2 -2
  40. package/dist/storage.d.mts +2 -2
  41. package/dist/types.d.cts +1 -1
  42. package/dist/types.d.mts +1 -1
  43. package/package.json +20 -5
  44. package/src/__tests__/KnexAuditStorage.integration.spec.ts +739 -0
  45. package/src/knex.ts +430 -0
  46. package/src/kysely.ts +6 -28
  47. package/src/storage.ts +28 -0
  48. package/dist/Auditor-_Dn2dp8d.d.mts.map +0 -1
  49. package/dist/Auditor-sW7YAJIA.d.cts.map +0 -1
@@ -0,0 +1,739 @@
1
+ import knexFactory, { type Knex } from 'knex';
2
+ import {
3
+ afterAll,
4
+ afterEach,
5
+ beforeAll,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ it,
10
+ } from 'vitest';
11
+ import { TEST_DATABASE_CONFIG } from '../../../testkit/test/globalSetup';
12
+ import { DefaultAuditor } from '../DefaultAuditor';
13
+ import {
14
+ type AuditLogTable,
15
+ KnexAuditStorage,
16
+ withAuditableTransaction,
17
+ } from '../knex';
18
+ import type { AuditableAction, AuditRecord } from '../types';
19
+
20
+ // Use unique table names to avoid conflicts with parallel tests
21
+ const AUDIT_TABLE = 'audit_knex_int_logs' as const;
22
+ const AUTO_ID_TABLE = 'audit_knex_int_auto' as const;
23
+ const USERS_TABLE = 'audit_knex_int_users' as const;
24
+
25
+ // Define test audit actions
26
+ type TestAuditAction =
27
+ | AuditableAction<'user.created', { userId: number; email: string }>
28
+ | AuditableAction<'user.updated', { userId: number; changes: string[] }>
29
+ | AuditableAction<'user.deleted', { userId: number }>;
30
+
31
+ function auditColumns(db: Knex, table: Knex.CreateTableBuilder): void {
32
+ table.string('type').notNullable();
33
+ table.string('operation').notNullable();
34
+ table.string('table');
35
+ table.string('entityId');
36
+ table.jsonb('oldValues');
37
+ table.jsonb('newValues');
38
+ table.jsonb('payload');
39
+ table.timestamp('timestamp').notNullable().defaultTo(db.fn.now());
40
+ table.string('actorId');
41
+ table.string('actorType');
42
+ table.jsonb('actorData');
43
+ table.jsonb('metadata');
44
+ }
45
+
46
+ describe('KnexAuditStorage Integration Tests', () => {
47
+ let db: Knex;
48
+ let storage: KnexAuditStorage;
49
+
50
+ beforeAll(async () => {
51
+ db = knexFactory({
52
+ client: 'pg',
53
+ connection: {
54
+ ...TEST_DATABASE_CONFIG,
55
+ database: 'postgres',
56
+ },
57
+ });
58
+
59
+ // Recreate tables so a crashed previous run cannot leak state
60
+ await db.schema.dropTableIfExists(AUDIT_TABLE);
61
+ await db.schema.dropTableIfExists(AUTO_ID_TABLE);
62
+ await db.schema.dropTableIfExists(USERS_TABLE);
63
+
64
+ // Audit table with client-supplied IDs
65
+ await db.schema.createTable(AUDIT_TABLE, (table) => {
66
+ table.string('id', 32).primary();
67
+ auditColumns(db, table);
68
+ });
69
+
70
+ // Audit table where the database generates IDs
71
+ await db.schema.createTable(AUTO_ID_TABLE, (table) => {
72
+ table.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
73
+ auditColumns(db, table);
74
+ });
75
+
76
+ // Users table for testing audit integration
77
+ await db.schema.createTable(USERS_TABLE, (table) => {
78
+ table.increments('id').primary();
79
+ table.string('name').notNullable();
80
+ table.string('email').notNullable().unique();
81
+ });
82
+
83
+ storage = new KnexAuditStorage({
84
+ db,
85
+ tableName: AUDIT_TABLE,
86
+ });
87
+ });
88
+
89
+ afterEach(async () => {
90
+ // Clean up data after each test
91
+ await db(AUDIT_TABLE).del();
92
+ await db(AUTO_ID_TABLE).del();
93
+ await db(USERS_TABLE).del();
94
+ });
95
+
96
+ afterAll(async () => {
97
+ // Drop tables and close connection
98
+ await db.schema.dropTableIfExists(AUDIT_TABLE);
99
+ await db.schema.dropTableIfExists(AUTO_ID_TABLE);
100
+ await db.schema.dropTableIfExists(USERS_TABLE);
101
+ await db.destroy();
102
+ });
103
+
104
+ describe('write', () => {
105
+ it('should write audit records to database', async () => {
106
+ const auditor = new DefaultAuditor<TestAuditAction>({
107
+ actor: { id: 'user-123', type: 'user' },
108
+ storage,
109
+ metadata: { requestId: 'req-456' },
110
+ });
111
+
112
+ auditor.audit('user.created', { userId: 1, email: 'test@example.com' });
113
+
114
+ await auditor.flush();
115
+
116
+ // Verify record was written
117
+ const records = await db<AuditLogTable>(AUDIT_TABLE).select('*');
118
+
119
+ expect(records).toHaveLength(1);
120
+ expect(records[0].type).toBe('user.created');
121
+ expect(records[0].operation).toBe('CUSTOM');
122
+ expect(records[0].actorId).toBe('user-123');
123
+ expect(records[0].actorType).toBe('user');
124
+ expect(records[0].payload).toEqual({
125
+ userId: 1,
126
+ email: 'test@example.com',
127
+ });
128
+ expect(records[0].metadata).toEqual({
129
+ requestId: 'req-456',
130
+ });
131
+ });
132
+
133
+ it('should write multiple audit records in batch', async () => {
134
+ const auditor = new DefaultAuditor<TestAuditAction>({
135
+ actor: { id: 'admin-1', type: 'admin' },
136
+ storage,
137
+ });
138
+
139
+ auditor.audit('user.created', { userId: 1, email: 'user1@example.com' });
140
+ auditor.audit('user.created', { userId: 2, email: 'user2@example.com' });
141
+ auditor.audit('user.updated', { userId: 1, changes: ['name'] });
142
+
143
+ await auditor.flush();
144
+
145
+ const records = await db<AuditLogTable>(AUDIT_TABLE)
146
+ .select('*')
147
+ .orderBy('timestamp', 'asc');
148
+
149
+ expect(records).toHaveLength(3);
150
+ expect(records.map((r) => r.type).sort()).toEqual([
151
+ 'user.created',
152
+ 'user.created',
153
+ 'user.updated',
154
+ ]);
155
+ });
156
+
157
+ it('should do nothing for empty records', async () => {
158
+ await storage.write([]);
159
+
160
+ expect(await storage.count({})).toBe(0);
161
+ });
162
+
163
+ it('should persist old and new values', async () => {
164
+ const auditor = new DefaultAuditor<TestAuditAction>({
165
+ actor: { id: 'admin-1', type: 'admin' },
166
+ storage,
167
+ });
168
+
169
+ auditor.audit(
170
+ 'user.updated',
171
+ { userId: 1, changes: ['name'] },
172
+ {
173
+ operation: 'UPDATE',
174
+ oldValues: { name: 'Old Name' },
175
+ newValues: { name: 'New Name' },
176
+ },
177
+ );
178
+
179
+ await auditor.flush();
180
+
181
+ const [record] = await storage.query({});
182
+ expect(record.operation).toBe('UPDATE');
183
+ expect(record.oldValues).toEqual({ name: 'Old Name' });
184
+ expect(record.newValues).toEqual({ name: 'New Name' });
185
+ });
186
+
187
+ it('should round-trip complex entity IDs', async () => {
188
+ const records: AuditRecord[] = [
189
+ {
190
+ id: 'audit-complex-1',
191
+ type: 'relation.created',
192
+ operation: 'INSERT',
193
+ entityId: { userId: 'u1', roleId: 'r1' },
194
+ timestamp: new Date(),
195
+ },
196
+ ];
197
+
198
+ await storage.write(records);
199
+
200
+ const [stored] = await db<AuditLogTable>(AUDIT_TABLE).select('*');
201
+ expect(stored.entityId).toBe(
202
+ JSON.stringify({ userId: 'u1', roleId: 'r1' }),
203
+ );
204
+
205
+ const [queried] = await storage.query({});
206
+ expect(queried.entityId).toEqual({ userId: 'u1', roleId: 'r1' });
207
+ });
208
+
209
+ it('should store extra actor properties', async () => {
210
+ const auditor = new DefaultAuditor<TestAuditAction>({
211
+ actor: {
212
+ id: 'user-1',
213
+ type: 'user',
214
+ email: 'user@example.com',
215
+ role: 'admin',
216
+ },
217
+ storage,
218
+ });
219
+
220
+ auditor.audit('user.created', { userId: 1, email: 'user@example.com' });
221
+ await auditor.flush();
222
+
223
+ const [stored] = await db<AuditLogTable>(AUDIT_TABLE).select('*');
224
+ expect(stored.actorId).toBe('user-1');
225
+ expect(stored.actorType).toBe('user');
226
+ expect(stored.actorData).toEqual({
227
+ email: 'user@example.com',
228
+ role: 'admin',
229
+ });
230
+
231
+ const [queried] = await storage.query({});
232
+ expect(queried.actor).toEqual({
233
+ id: 'user-1',
234
+ type: 'user',
235
+ email: 'user@example.com',
236
+ role: 'admin',
237
+ });
238
+ });
239
+
240
+ it('should use the provided record ID', async () => {
241
+ await storage.write([
242
+ {
243
+ id: 'my-custom-id',
244
+ type: 'user.created',
245
+ operation: 'CUSTOM',
246
+ timestamp: new Date(),
247
+ },
248
+ ]);
249
+
250
+ const [stored] = await db<AuditLogTable>(AUDIT_TABLE).select('*');
251
+ expect(stored.id).toBe('my-custom-id');
252
+ });
253
+
254
+ it('should generate an ID when the record has none', async () => {
255
+ await storage.write([
256
+ {
257
+ id: '', // Empty ID
258
+ type: 'user.created',
259
+ operation: 'CUSTOM',
260
+ timestamp: new Date(),
261
+ },
262
+ ]);
263
+
264
+ const [stored] = await db<AuditLogTable>(AUDIT_TABLE).select('*');
265
+ expect(stored.id).toBeTruthy();
266
+ expect(stored.id.length).toBeGreaterThan(0);
267
+ });
268
+
269
+ it('should write audit records within transaction', async () => {
270
+ const auditor = new DefaultAuditor<TestAuditAction>({
271
+ actor: { id: 'user-123', type: 'user' },
272
+ storage,
273
+ });
274
+
275
+ await db.transaction(async (trx) => {
276
+ // Insert user
277
+ const [user] = await trx(USERS_TABLE)
278
+ .insert({ name: 'Test User', email: 'test@example.com' })
279
+ .returning('*');
280
+
281
+ // Audit the creation
282
+ auditor.audit(
283
+ 'user.created',
284
+ { userId: user.id, email: user.email },
285
+ { entityId: String(user.id), table: 'users', operation: 'INSERT' },
286
+ );
287
+
288
+ // Flush within transaction
289
+ await auditor.flush(trx);
290
+ });
291
+
292
+ // Verify both user and audit record exist
293
+ const users = await db(USERS_TABLE).select('*');
294
+ const audits = await db<AuditLogTable>(AUDIT_TABLE).select('*');
295
+
296
+ expect(users).toHaveLength(1);
297
+ expect(audits).toHaveLength(1);
298
+ expect(audits[0].entityId).toBe(String(users[0].id));
299
+ expect(audits[0].table).toBe('users');
300
+ expect(audits[0].operation).toBe('INSERT');
301
+ });
302
+
303
+ it('should rollback audit records when transaction fails', async () => {
304
+ const auditor = new DefaultAuditor<TestAuditAction>({
305
+ actor: { id: 'user-123', type: 'user' },
306
+ storage,
307
+ });
308
+
309
+ const transactionPromise = db.transaction(async (trx) => {
310
+ // Insert user
311
+ const [user] = await trx(USERS_TABLE)
312
+ .insert({ name: 'Rollback User', email: 'rollback@example.com' })
313
+ .returning('*');
314
+
315
+ // Audit the creation
316
+ auditor.audit('user.created', { userId: user.id, email: user.email });
317
+
318
+ // Flush within transaction
319
+ await auditor.flush(trx);
320
+
321
+ // Throw error to rollback
322
+ throw new Error('Transaction should rollback');
323
+ });
324
+
325
+ await expect(transactionPromise).rejects.toThrow(
326
+ 'Transaction should rollback',
327
+ );
328
+
329
+ // Verify both user and audit record were rolled back
330
+ const users = await db(USERS_TABLE).select('*');
331
+ const audits = await db(AUDIT_TABLE).select('*');
332
+
333
+ expect(users).toHaveLength(0);
334
+ expect(audits).toHaveLength(0);
335
+ });
336
+ });
337
+
338
+ describe('autoId', () => {
339
+ it('should let the database generate IDs when autoId is true', async () => {
340
+ const autoIdStorage = new KnexAuditStorage({
341
+ db,
342
+ tableName: AUTO_ID_TABLE,
343
+ autoId: true,
344
+ });
345
+
346
+ const auditor = new DefaultAuditor<TestAuditAction>({
347
+ actor: { id: 'user-1', type: 'user' },
348
+ storage: autoIdStorage,
349
+ });
350
+
351
+ auditor.audit('user.created', { userId: 1, email: 'auto@example.com' });
352
+ await auditor.flush();
353
+
354
+ const records = await db<AuditLogTable>(AUTO_ID_TABLE).select('*');
355
+ expect(records).toHaveLength(1);
356
+ expect(records[0].id).toMatch(/^[0-9a-f-]{36}$/);
357
+ });
358
+
359
+ it('should ignore a supplied ID when autoId is true', async () => {
360
+ const autoIdStorage = new KnexAuditStorage({
361
+ db,
362
+ tableName: AUTO_ID_TABLE,
363
+ autoId: true,
364
+ });
365
+
366
+ await autoIdStorage.write([
367
+ {
368
+ id: 'explicit-id', // Not a UUID - would fail if it reached the insert
369
+ type: 'user.created',
370
+ operation: 'CUSTOM',
371
+ timestamp: new Date(),
372
+ },
373
+ ]);
374
+
375
+ const records = await db<AuditLogTable>(AUTO_ID_TABLE).select('*');
376
+ expect(records).toHaveLength(1);
377
+ expect(records[0].id).not.toBe('explicit-id');
378
+ expect(records[0].id).toMatch(/^[0-9a-f-]{36}$/);
379
+ });
380
+ });
381
+
382
+ describe('getDatabase', () => {
383
+ it('should return the knex instance', () => {
384
+ expect(storage.getDatabase()).toBe(db);
385
+ });
386
+ });
387
+
388
+ describe('withAuditableTransaction', () => {
389
+ it('should commit user and audits together', async () => {
390
+ const auditor = new DefaultAuditor<TestAuditAction>({
391
+ actor: { id: 'admin-1', type: 'admin' },
392
+ storage,
393
+ });
394
+
395
+ const created = await withAuditableTransaction(
396
+ db,
397
+ auditor,
398
+ async (trx) => {
399
+ const [user] = await trx(USERS_TABLE)
400
+ .insert({ name: 'Atomic User', email: 'atomic@example.com' })
401
+ .returning('*');
402
+
403
+ auditor.audit(
404
+ 'user.created',
405
+ { userId: user.id, email: user.email },
406
+ { entityId: String(user.id), table: 'users', operation: 'INSERT' },
407
+ );
408
+
409
+ return user;
410
+ },
411
+ );
412
+
413
+ const audits = await db<AuditLogTable>(AUDIT_TABLE).select('*');
414
+
415
+ expect(created.email).toBe('atomic@example.com');
416
+ expect(audits).toHaveLength(1);
417
+ expect(audits[0].entityId).toBe(String(created.id));
418
+ });
419
+
420
+ it('should roll back audits when the callback throws', async () => {
421
+ const auditor = new DefaultAuditor<TestAuditAction>({
422
+ actor: { id: 'admin-1', type: 'admin' },
423
+ storage,
424
+ });
425
+
426
+ await expect(
427
+ withAuditableTransaction(db, auditor, async (trx) => {
428
+ await trx(USERS_TABLE).insert({
429
+ name: 'Doomed User',
430
+ email: 'doomed@example.com',
431
+ });
432
+
433
+ auditor.audit('user.created', {
434
+ userId: 1,
435
+ email: 'doomed@example.com',
436
+ });
437
+
438
+ throw new Error('rollback please');
439
+ }),
440
+ ).rejects.toThrow('rollback please');
441
+
442
+ const users = await db(USERS_TABLE).select('*');
443
+ const audits = await db(AUDIT_TABLE).select('*');
444
+
445
+ expect(users).toHaveLength(0);
446
+ expect(audits).toHaveLength(0);
447
+ });
448
+
449
+ it('should honour the requested isolation level', async () => {
450
+ const auditor = new DefaultAuditor<TestAuditAction>({
451
+ actor: { id: 'admin-1', type: 'admin' },
452
+ storage,
453
+ });
454
+
455
+ const level = await withAuditableTransaction(
456
+ db,
457
+ auditor,
458
+ async (trx) => {
459
+ const { rows } = await trx.raw('SHOW transaction_isolation');
460
+
461
+ auditor.audit('user.created', {
462
+ userId: 1,
463
+ email: 'isolated@example.com',
464
+ });
465
+
466
+ return rows[0].transaction_isolation as string;
467
+ },
468
+ { isolationLevel: 'serializable' },
469
+ );
470
+
471
+ expect(level).toBe('serializable');
472
+ expect(await storage.count({})).toBe(1);
473
+ });
474
+
475
+ it('should reuse an existing transaction rather than opening a savepoint', async () => {
476
+ const auditor = new DefaultAuditor<TestAuditAction>({
477
+ actor: { id: 'admin-1', type: 'admin' },
478
+ storage,
479
+ });
480
+
481
+ // The outer transaction rolls back; if the inner call had opened a
482
+ // savepoint that committed independently, the audit would survive.
483
+ await expect(
484
+ db.transaction(async (outer) => {
485
+ await withAuditableTransaction(outer, auditor, async (trx) => {
486
+ await trx(USERS_TABLE).insert({
487
+ name: 'Nested User',
488
+ email: 'nested@example.com',
489
+ });
490
+
491
+ auditor.audit('user.created', {
492
+ userId: 1,
493
+ email: 'nested@example.com',
494
+ });
495
+ });
496
+
497
+ throw new Error('outer rollback');
498
+ }),
499
+ ).rejects.toThrow('outer rollback');
500
+
501
+ const audits = await db(AUDIT_TABLE).select('*');
502
+ expect(audits).toHaveLength(0);
503
+ });
504
+ });
505
+
506
+ describe('withTransaction', () => {
507
+ it('should flush audits inside the transaction', async () => {
508
+ const auditor = new DefaultAuditor<TestAuditAction>({
509
+ actor: { id: 'admin-1', type: 'admin' },
510
+ storage,
511
+ });
512
+
513
+ const result = await storage.withTransaction(auditor, async () => {
514
+ auditor.audit('user.created', {
515
+ userId: 7,
516
+ email: 'wrapped@example.com',
517
+ });
518
+ return 'ok';
519
+ });
520
+
521
+ expect(result).toBe('ok');
522
+
523
+ const audits = await db<AuditLogTable>(AUDIT_TABLE).select('*');
524
+ expect(audits).toHaveLength(1);
525
+ expect(audits[0].type).toBe('user.created');
526
+ });
527
+
528
+ it('should not write audits when the callback throws', async () => {
529
+ const auditor = new DefaultAuditor<TestAuditAction>({
530
+ actor: { id: 'admin-1', type: 'admin' },
531
+ storage,
532
+ });
533
+
534
+ await expect(
535
+ storage.withTransaction(auditor, async () => {
536
+ auditor.audit('user.created', {
537
+ userId: 8,
538
+ email: 'never@example.com',
539
+ });
540
+ throw new Error('nope');
541
+ }),
542
+ ).rejects.toThrow('nope');
543
+
544
+ expect(await storage.count({})).toBe(0);
545
+ });
546
+
547
+ it('should reuse a transaction passed in by the caller', async () => {
548
+ const auditor = new DefaultAuditor<TestAuditAction>({
549
+ actor: { id: 'admin-1', type: 'admin' },
550
+ storage,
551
+ });
552
+
553
+ await expect(
554
+ db.transaction(async (outer) => {
555
+ await storage.withTransaction(
556
+ auditor,
557
+ async () => {
558
+ auditor.audit('user.created', {
559
+ userId: 9,
560
+ email: 'reused@example.com',
561
+ });
562
+ },
563
+ outer,
564
+ );
565
+
566
+ throw new Error('outer rollback');
567
+ }),
568
+ ).rejects.toThrow('outer rollback');
569
+
570
+ expect(await storage.count({})).toBe(0);
571
+ });
572
+ });
573
+
574
+ describe('query', () => {
575
+ beforeEach(async () => {
576
+ // Insert test audit records
577
+ const auditor1 = new DefaultAuditor<TestAuditAction>({
578
+ actor: { id: 'user-1', type: 'user' },
579
+ storage,
580
+ metadata: { endpoint: '/users' },
581
+ });
582
+
583
+ const auditor2 = new DefaultAuditor<TestAuditAction>({
584
+ actor: { id: 'admin-1', type: 'admin' },
585
+ storage,
586
+ metadata: { endpoint: '/admin/users' },
587
+ });
588
+
589
+ auditor1.audit(
590
+ 'user.created',
591
+ { userId: 1, email: 'user1@example.com' },
592
+ { entityId: '1', table: 'users' },
593
+ );
594
+ auditor1.audit(
595
+ 'user.updated',
596
+ { userId: 1, changes: ['name'] },
597
+ { entityId: '1', table: 'users' },
598
+ );
599
+ auditor2.audit(
600
+ 'user.deleted',
601
+ { userId: 2 },
602
+ { entityId: '2', table: 'users' },
603
+ );
604
+
605
+ await auditor1.flush();
606
+ await auditor2.flush();
607
+ });
608
+
609
+ it('should query all records', async () => {
610
+ const records = await storage.query({});
611
+
612
+ expect(records).toHaveLength(3);
613
+ });
614
+
615
+ it('should filter by type', async () => {
616
+ const records = await storage.query({ type: 'user.created' });
617
+
618
+ expect(records).toHaveLength(1);
619
+ expect(records[0].type).toBe('user.created');
620
+ });
621
+
622
+ it('should filter by multiple types', async () => {
623
+ const records = await storage.query({
624
+ type: ['user.created', 'user.updated'],
625
+ });
626
+
627
+ expect(records).toHaveLength(2);
628
+ });
629
+
630
+ it('should filter by actorId', async () => {
631
+ const records = await storage.query({ actorId: 'admin-1' });
632
+
633
+ expect(records).toHaveLength(1);
634
+ expect(records[0].type).toBe('user.deleted');
635
+ });
636
+
637
+ it('should filter by entityId', async () => {
638
+ const records = await storage.query({ entityId: '1' });
639
+
640
+ expect(records).toHaveLength(2);
641
+ });
642
+
643
+ it('should filter by table', async () => {
644
+ const records = await storage.query({ table: 'users' });
645
+
646
+ expect(records).toHaveLength(3);
647
+ });
648
+
649
+ it('should filter by date range', async () => {
650
+ const past = new Date(Date.now() - 60_000);
651
+ const future = new Date(Date.now() + 60_000);
652
+
653
+ expect(await storage.query({ from: past, to: future })).toHaveLength(3);
654
+ expect(await storage.query({ from: future })).toHaveLength(0);
655
+ expect(await storage.query({ to: past })).toHaveLength(0);
656
+ });
657
+
658
+ it('should round-trip payload, actor and metadata', async () => {
659
+ const records = await storage.query({ type: 'user.created' });
660
+
661
+ expect(records[0].payload).toEqual({
662
+ userId: 1,
663
+ email: 'user1@example.com',
664
+ });
665
+ expect(records[0].actor).toEqual({ id: 'user-1', type: 'user' });
666
+ expect(records[0].metadata).toEqual({ endpoint: '/users' });
667
+ });
668
+
669
+ it('should apply pagination', async () => {
670
+ const page1 = await storage.query({ limit: 2, offset: 0 });
671
+ const page2 = await storage.query({ limit: 2, offset: 2 });
672
+
673
+ expect(page1).toHaveLength(2);
674
+ expect(page2).toHaveLength(1);
675
+ });
676
+
677
+ it('should order by timestamp descending by default', async () => {
678
+ const records = await storage.query({});
679
+
680
+ // Records should be in descending order (newest first)
681
+ for (let i = 1; i < records.length; i++) {
682
+ expect(records[i - 1].timestamp.getTime()).toBeGreaterThanOrEqual(
683
+ records[i].timestamp.getTime(),
684
+ );
685
+ }
686
+ });
687
+
688
+ it('should order by type ascending', async () => {
689
+ const records = await storage.query({
690
+ orderBy: 'type',
691
+ orderDirection: 'asc',
692
+ });
693
+
694
+ const types = records.map((r) => r.type);
695
+ expect(types).toEqual([...types].sort());
696
+ });
697
+ });
698
+
699
+ describe('count', () => {
700
+ beforeEach(async () => {
701
+ const auditor = new DefaultAuditor<TestAuditAction>({
702
+ actor: { id: 'user-1', type: 'user' },
703
+ storage,
704
+ });
705
+
706
+ auditor.audit('user.created', { userId: 1, email: 'user1@example.com' });
707
+ auditor.audit('user.created', { userId: 2, email: 'user2@example.com' });
708
+ auditor.audit('user.updated', { userId: 1, changes: ['name'] });
709
+
710
+ await auditor.flush();
711
+ });
712
+
713
+ it('should count all records', async () => {
714
+ const count = await storage.count({});
715
+
716
+ expect(count).toBe(3);
717
+ });
718
+
719
+ it('should count with type filter', async () => {
720
+ const count = await storage.count({ type: 'user.created' });
721
+
722
+ expect(count).toBe(2);
723
+ });
724
+
725
+ it('should count with multiple type filter', async () => {
726
+ const count = await storage.count({
727
+ type: ['user.created', 'user.updated'],
728
+ });
729
+
730
+ expect(count).toBe(3);
731
+ });
732
+
733
+ it('should count with actorId filter', async () => {
734
+ const count = await storage.count({ actorId: 'user-1' });
735
+
736
+ expect(count).toBe(3);
737
+ });
738
+ });
739
+ });