@geekmidas/audit 0.0.8 → 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.
Files changed (45) hide show
  1. package/README.md +397 -0
  2. package/dist/{Auditor-D3me-qKX.d.mts → Auditor-DrR0ImvJ.d.cts} +43 -1
  3. package/dist/{Auditor-QYUMGJCH.d.cts → Auditor-Xf4Tp63p.d.mts} +43 -1
  4. package/dist/Auditor.d.cts +1 -1
  5. package/dist/Auditor.d.mts +1 -1
  6. package/dist/{DefaultAuditor-BTuMMiWh.d.cts → DefaultAuditor-3Q1zVd1b.d.mts} +2 -2
  7. package/dist/{DefaultAuditor-C1FWrJg6.d.mts → DefaultAuditor-BaZa4u_m.d.cts} +2 -2
  8. package/dist/DefaultAuditor.d.cts +2 -2
  9. package/dist/DefaultAuditor.d.mts +2 -2
  10. package/dist/cache-0AjJ0zis.d.cts +95 -0
  11. package/dist/cache-BbIl31RL.mjs +167 -0
  12. package/dist/cache-BbIl31RL.mjs.map +1 -0
  13. package/dist/cache-DBbGcCEq.d.mts +95 -0
  14. package/dist/cache-l7jAptBl.cjs +173 -0
  15. package/dist/cache-l7jAptBl.cjs.map +1 -0
  16. package/dist/cache.cjs +3 -0
  17. package/dist/cache.d.cts +3 -0
  18. package/dist/cache.d.mts +3 -0
  19. package/dist/cache.mjs +3 -0
  20. package/dist/index.d.cts +2 -2
  21. package/dist/index.d.mts +2 -2
  22. package/dist/kysely.cjs +24 -0
  23. package/dist/kysely.cjs.map +1 -1
  24. package/dist/kysely.d.cts +10 -1
  25. package/dist/kysely.d.mts +10 -1
  26. package/dist/kysely.mjs +24 -0
  27. package/dist/kysely.mjs.map +1 -1
  28. package/dist/memory.cjs +49 -0
  29. package/dist/memory.cjs.map +1 -0
  30. package/dist/memory.d.cts +43 -0
  31. package/dist/memory.d.mts +43 -0
  32. package/dist/memory.mjs +48 -0
  33. package/dist/memory.mjs.map +1 -0
  34. package/dist/storage.d.cts +1 -1
  35. package/dist/storage.d.mts +1 -1
  36. package/dist/types.d.cts +1 -1
  37. package/dist/types.d.mts +1 -1
  38. package/package.json +33 -5
  39. package/src/__tests__/CacheAuditStorage.spec.ts +382 -0
  40. package/src/__tests__/InMemoryAuditStorage.spec.ts +317 -0
  41. package/src/__tests__/KyselyAuditStorage.integration.spec.ts +24 -20
  42. package/src/cache.ts +263 -0
  43. package/src/kysely.ts +33 -0
  44. package/src/memory.ts +50 -0
  45. package/src/storage.ts +47 -0
@@ -20,9 +20,13 @@ import { DefaultAuditor } from '../DefaultAuditor';
20
20
  import { type AuditLogTable, KyselyAuditStorage } from '../kysely';
21
21
  import type { AuditableAction } from '../types';
22
22
 
23
+ // Use unique table names to avoid conflicts with parallel tests
24
+ const AUDIT_TABLE = 'audit_int_logs' as const;
25
+ const USERS_TABLE = 'audit_int_users' as const;
26
+
23
27
  interface TestDatabase {
24
- auditLogs: AuditLogTable;
25
- users: {
28
+ [AUDIT_TABLE]: AuditLogTable;
29
+ [USERS_TABLE]: {
26
30
  id: Generated<number>;
27
31
  name: string;
28
32
  email: string;
@@ -52,7 +56,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
52
56
 
53
57
  // Create audit_logs table
54
58
  await db.schema
55
- .createTable('auditLogs')
59
+ .createTable(AUDIT_TABLE)
56
60
  .ifNotExists()
57
61
  .addColumn('id', 'varchar(32)', (col) => col.primaryKey())
58
62
  .addColumn('type', 'varchar', (col) => col.notNull())
@@ -73,7 +77,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
73
77
 
74
78
  // Create users table for testing audit integration
75
79
  await db.schema
76
- .createTable('users')
80
+ .createTable(USERS_TABLE)
77
81
  .ifNotExists()
78
82
  .addColumn('id', 'serial', (col) => col.primaryKey())
79
83
  .addColumn('name', 'varchar', (col) => col.notNull())
@@ -82,20 +86,20 @@ describe('KyselyAuditStorage Integration Tests', () => {
82
86
 
83
87
  storage = new KyselyAuditStorage({
84
88
  db,
85
- tableName: 'auditLogs',
89
+ tableName: AUDIT_TABLE,
86
90
  });
87
91
  });
88
92
 
89
93
  afterEach(async () => {
90
94
  // Clean up data after each test
91
- await db.deleteFrom('auditLogs').execute();
92
- await db.deleteFrom('users').execute();
95
+ await db.deleteFrom(AUDIT_TABLE).execute();
96
+ await db.deleteFrom(USERS_TABLE).execute();
93
97
  });
94
98
 
95
99
  afterAll(async () => {
96
100
  // Drop tables and close connection
97
- await db.schema.dropTable('auditLogs').ifExists().execute();
98
- await db.schema.dropTable('users').ifExists().execute();
101
+ await db.schema.dropTable(AUDIT_TABLE).ifExists().execute();
102
+ await db.schema.dropTable(USERS_TABLE).ifExists().execute();
99
103
  await db.destroy();
100
104
  });
101
105
 
@@ -112,7 +116,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
112
116
  await auditor.flush();
113
117
 
114
118
  // Verify record was written
115
- const records = await db.selectFrom('auditLogs').selectAll().execute();
119
+ const records = await db.selectFrom(AUDIT_TABLE).selectAll().execute();
116
120
 
117
121
  expect(records).toHaveLength(1);
118
122
  expect(records[0].type).toBe('user.created');
@@ -140,7 +144,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
140
144
  await auditor.flush();
141
145
 
142
146
  const records = await db
143
- .selectFrom('auditLogs')
147
+ .selectFrom(AUDIT_TABLE)
144
148
  .selectAll()
145
149
  .orderBy('timestamp', 'asc')
146
150
  .execute();
@@ -162,7 +166,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
162
166
  await db.transaction().execute(async (trx) => {
163
167
  // Insert user
164
168
  const user = await trx
165
- .insertInto('users')
169
+ .insertInto(USERS_TABLE)
166
170
  .values({ name: 'Test User', email: 'test@example.com' })
167
171
  .returningAll()
168
172
  .executeTakeFirstOrThrow();
@@ -179,8 +183,8 @@ describe('KyselyAuditStorage Integration Tests', () => {
179
183
  });
180
184
 
181
185
  // Verify both user and audit record exist
182
- const users = await db.selectFrom('users').selectAll().execute();
183
- const audits = await db.selectFrom('auditLogs').selectAll().execute();
186
+ const users = await db.selectFrom(USERS_TABLE).selectAll().execute();
187
+ const audits = await db.selectFrom(AUDIT_TABLE).selectAll().execute();
184
188
 
185
189
  expect(users).toHaveLength(1);
186
190
  expect(audits).toHaveLength(1);
@@ -198,7 +202,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
198
202
  const transactionPromise = db.transaction().execute(async (trx) => {
199
203
  // Insert user
200
204
  const user = await trx
201
- .insertInto('users')
205
+ .insertInto(USERS_TABLE)
202
206
  .values({ name: 'Rollback User', email: 'rollback@example.com' })
203
207
  .returningAll()
204
208
  .executeTakeFirstOrThrow();
@@ -218,8 +222,8 @@ describe('KyselyAuditStorage Integration Tests', () => {
218
222
  );
219
223
 
220
224
  // Verify both user and audit record were rolled back
221
- const users = await db.selectFrom('users').selectAll().execute();
222
- const audits = await db.selectFrom('auditLogs').selectAll().execute();
225
+ const users = await db.selectFrom(USERS_TABLE).selectAll().execute();
226
+ const audits = await db.selectFrom(AUDIT_TABLE).selectAll().execute();
223
227
 
224
228
  expect(users).toHaveLength(0);
225
229
  expect(audits).toHaveLength(0);
@@ -374,7 +378,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
374
378
  });
375
379
 
376
380
  const user = await db
377
- .insertInto('users')
381
+ .insertInto(USERS_TABLE)
378
382
  .values({ name: 'John Doe', email: 'john@example.com' })
379
383
  .returningAll()
380
384
  .executeTakeFirstOrThrow();
@@ -399,7 +403,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
399
403
  });
400
404
 
401
405
  await db
402
- .updateTable('users')
406
+ .updateTable(USERS_TABLE)
403
407
  .set({ name: 'John Smith' })
404
408
  .where('id', '=', user.id)
405
409
  .execute();
@@ -424,7 +428,7 @@ describe('KyselyAuditStorage Integration Tests', () => {
424
428
  metadata: { endpoint: '/users/1', method: 'DELETE' },
425
429
  });
426
430
 
427
- await db.deleteFrom('users').where('id', '=', user.id).execute();
431
+ await db.deleteFrom(USERS_TABLE).where('id', '=', user.id).execute();
428
432
 
429
433
  deleteAuditor.audit(
430
434
  'user.deleted',
package/src/cache.ts ADDED
@@ -0,0 +1,263 @@
1
+ import type { Cache } from '@geekmidas/cache';
2
+ import type { AuditQueryOptions, AuditStorage } from './storage';
3
+ import type { AuditRecord, AuditableAction } 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
+ };
package/src/kysely.ts CHANGED
@@ -286,6 +286,39 @@ export class KyselyAuditStorage<DB> implements AuditStorage {
286
286
  return this.db;
287
287
  }
288
288
 
289
+ /**
290
+ * Execute a callback within a Kysely transaction with automatic audit handling.
291
+ * The auditor is registered with the transaction and audits are flushed
292
+ * before the transaction commits.
293
+ *
294
+ * If the provided db connection is already a transaction, it will be reused
295
+ * instead of creating a nested transaction.
296
+ */
297
+ async withTransaction<T>(
298
+ auditor: TransactionAwareAuditor<Transaction<DB>>,
299
+ callback: () => Promise<T>,
300
+ db?: DatabaseConnection<DB>,
301
+ ): Promise<T> {
302
+ const connection = db ?? this.db;
303
+
304
+ // If already in a transaction, reuse it
305
+ if (connection.isTransaction) {
306
+ const trx = connection as Transaction<DB>;
307
+ auditor.setTransaction(trx);
308
+ const result = await callback();
309
+ await auditor.flush(trx);
310
+ return result;
311
+ }
312
+
313
+ // Create new transaction
314
+ return connection.transaction().execute(async (trx) => {
315
+ auditor.setTransaction(trx);
316
+ const result = await callback();
317
+ await auditor.flush(trx);
318
+ return result;
319
+ });
320
+ }
321
+
289
322
  private applyFilters(query: any, options: AuditQueryOptions): any {
290
323
  // Type filter
291
324
  if (options.type !== undefined) {
package/src/memory.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { InMemoryCache } from '@geekmidas/cache/memory';
2
+ import { CacheAuditStorage } from './cache';
3
+ import type { AuditableAction } from './types';
4
+
5
+ /**
6
+ * In-memory audit storage implementation.
7
+ * Convenience wrapper around CacheAuditStorage with InMemoryCache.
8
+ *
9
+ * Useful for testing, development, and applications that don't need persistent audit logs.
10
+ *
11
+ * @template TAuditAction - Optional type parameter for type-safe audit actions.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { InMemoryAuditStorage } from '@geekmidas/audit/memory';
16
+ * import { DefaultAuditor } from '@geekmidas/audit';
17
+ *
18
+ * const storage = new InMemoryAuditStorage();
19
+ * const auditor = new DefaultAuditor({
20
+ * actor: { id: 'user-123', type: 'user' },
21
+ * storage,
22
+ * });
23
+ *
24
+ * auditor.audit('user.created', { userId: '789', email: 'test@example.com' });
25
+ * await auditor.flush();
26
+ *
27
+ * // Query stored records
28
+ * const records = await storage.query({ type: 'user.created' });
29
+ *
30
+ * // Get all records (for testing)
31
+ * const all = await storage.getRecords();
32
+ *
33
+ * // Clear for next test
34
+ * await storage.clear();
35
+ * ```
36
+ */
37
+ export class InMemoryAuditStorage<
38
+ TAuditAction extends AuditableAction<string, unknown> = AuditableAction<
39
+ string,
40
+ unknown
41
+ >,
42
+ > extends CacheAuditStorage<TAuditAction> {
43
+ constructor() {
44
+ super({
45
+ cache: new InMemoryCache(),
46
+ // Use a long TTL since in-memory cache expires items
47
+ ttl: 86400 * 365, // 1 year
48
+ });
49
+ }
50
+ }
package/src/storage.ts CHANGED
@@ -125,4 +125,51 @@ export interface AuditStorage<
125
125
  * ```
126
126
  */
127
127
  databaseServiceName?: string;
128
+
129
+ /**
130
+ * Optional: Execute a callback within a database transaction.
131
+ * The auditor is registered with the transaction and audits are flushed
132
+ * before the transaction commits.
133
+ *
134
+ * This is database-agnostic - each storage implementation provides its own
135
+ * transaction handling based on the underlying database.
136
+ *
137
+ * If a database connection is provided, it should be used instead of the
138
+ * storage's internal connection. If the connection is already a transaction,
139
+ * it should be reused instead of creating a nested transaction.
140
+ *
141
+ * @param auditor - The auditor to register with the transaction
142
+ * @param callback - The callback to execute within the transaction
143
+ * @param db - Optional database connection (may already be a transaction)
144
+ * @returns The result of the callback
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * // KyselyAuditStorage implementation
149
+ * async withTransaction<T>(auditor, callback, db) {
150
+ * const connection = db ?? this.db;
151
+ * if (connection.isTransaction) {
152
+ * // Reuse existing transaction
153
+ * auditor.setTransaction(connection);
154
+ * const result = await callback();
155
+ * await auditor.flush(connection);
156
+ * return result;
157
+ * }
158
+ * return connection.transaction().execute(async (trx) => {
159
+ * auditor.setTransaction(trx);
160
+ * const result = await callback();
161
+ * await auditor.flush(trx);
162
+ * return result;
163
+ * });
164
+ * }
165
+ * ```
166
+ */
167
+ withTransaction?<T>(
168
+ auditor: {
169
+ setTransaction(trx: unknown): void;
170
+ flush(trx?: unknown): Promise<void>;
171
+ },
172
+ callback: () => Promise<T>,
173
+ db?: unknown,
174
+ ): Promise<T>;
128
175
  }