@geekmidas/audit 0.1.0 → 0.2.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 (55) hide show
  1. package/dist/{Auditor-DrR0ImvJ.d.cts → Auditor-_Dn2dp8d.d.mts} +4 -1
  2. package/dist/Auditor-_Dn2dp8d.d.mts.map +1 -0
  3. package/dist/{Auditor-Xf4Tp63p.d.mts → Auditor-sW7YAJIA.d.cts} +4 -1
  4. package/dist/Auditor-sW7YAJIA.d.cts.map +1 -0
  5. package/dist/Auditor.d.cts +1 -1
  6. package/dist/Auditor.d.mts +1 -1
  7. package/dist/DefaultAuditor-BAVnNmRh.mjs.map +1 -1
  8. package/dist/{DefaultAuditor-3Q1zVd1b.d.mts → DefaultAuditor-BSYMwojG.d.cts} +3 -2
  9. package/dist/DefaultAuditor-BSYMwojG.d.cts.map +1 -0
  10. package/dist/{DefaultAuditor-BaZa4u_m.d.cts → DefaultAuditor-CYE-uAry.d.mts} +3 -2
  11. package/dist/DefaultAuditor-CYE-uAry.d.mts.map +1 -0
  12. package/dist/DefaultAuditor-JbZ_BQfh.cjs.map +1 -1
  13. package/dist/DefaultAuditor.d.cts +2 -2
  14. package/dist/DefaultAuditor.d.mts +2 -2
  15. package/dist/{cache-0AjJ0zis.d.cts → cache-2VI80Vao.d.mts} +3 -2
  16. package/dist/cache-2VI80Vao.d.mts.map +1 -0
  17. package/dist/cache-BbIl31RL.mjs.map +1 -1
  18. package/dist/{cache-DBbGcCEq.d.mts → cache-CDHqXqcl.d.cts} +3 -2
  19. package/dist/cache-CDHqXqcl.d.cts.map +1 -0
  20. package/dist/cache-l7jAptBl.cjs.map +1 -1
  21. package/dist/cache.d.cts +2 -2
  22. package/dist/cache.d.mts +2 -2
  23. package/dist/index.d.cts +2 -2
  24. package/dist/index.d.mts +2 -2
  25. package/dist/kysely.cjs.map +1 -1
  26. package/dist/kysely.d.cts +2 -1
  27. package/dist/kysely.d.cts.map +1 -0
  28. package/dist/kysely.d.mts +2 -1
  29. package/dist/kysely.d.mts.map +1 -0
  30. package/dist/kysely.mjs.map +1 -1
  31. package/dist/memory.cjs.map +1 -1
  32. package/dist/memory.d.cts +3 -2
  33. package/dist/memory.d.cts.map +1 -0
  34. package/dist/memory.d.mts +3 -2
  35. package/dist/memory.d.mts.map +1 -0
  36. package/dist/memory.mjs.map +1 -1
  37. package/dist/storage.d.cts +1 -1
  38. package/dist/storage.d.mts +1 -1
  39. package/dist/types.d.cts +1 -1
  40. package/dist/types.d.mts +1 -1
  41. package/package.json +3 -3
  42. package/src/Auditor.ts +121 -121
  43. package/src/DefaultAuditor.ts +91 -91
  44. package/src/__tests__/CacheAuditStorage.spec.ts +375 -375
  45. package/src/__tests__/DefaultAuditor.spec.ts +520 -462
  46. package/src/__tests__/InMemoryAuditStorage.spec.ts +311 -311
  47. package/src/__tests__/KyselyAuditStorage.integration.spec.ts +496 -496
  48. package/src/__tests__/KyselyAuditStorage.spec.ts +433 -433
  49. package/src/cache.ts +179 -179
  50. package/src/index.ts +14 -15
  51. package/src/kysely.ts +292 -292
  52. package/src/memory.ts +11 -11
  53. package/src/storage.ts +131 -131
  54. package/src/types.ts +73 -74
  55. package/tsconfig.json +9 -0
package/src/kysely.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type {
2
- ControlledTransaction,
3
- IsolationLevel,
4
- Kysely,
5
- Transaction,
2
+ ControlledTransaction,
3
+ IsolationLevel,
4
+ Kysely,
5
+ Transaction,
6
6
  } from 'kysely';
7
7
  import { nanoid } from 'nanoid';
8
8
  import type { AuditQueryOptions, AuditStorage } from './storage';
@@ -30,20 +30,20 @@ import type { AuditRecord } from './types';
30
30
  * ```
31
31
  */
32
32
  export interface TransactionAwareAuditor<TTransaction = unknown> {
33
- /** Register the transaction with the auditor for use during flush */
34
- setTransaction(trx: TTransaction): void;
35
- /** Flush all pending audits, optionally within a transaction */
36
- flush(trx?: TTransaction): Promise<void>;
33
+ /** Register the transaction with the auditor for use during flush */
34
+ setTransaction(trx: TTransaction): void;
35
+ /** Flush all pending audits, optionally within a transaction */
36
+ flush(trx?: TTransaction): Promise<void>;
37
37
  }
38
38
 
39
39
  export interface TransactionSettings {
40
- isolationLevel?: IsolationLevel;
40
+ isolationLevel?: IsolationLevel;
41
41
  }
42
42
 
43
43
  export type DatabaseConnection<T> =
44
- | ControlledTransaction<T>
45
- | Kysely<T>
46
- | Transaction<T>;
44
+ | ControlledTransaction<T>
45
+ | Kysely<T>
46
+ | Transaction<T>;
47
47
 
48
48
  /**
49
49
  * Execute a callback within a database transaction with automatic audit handling.
@@ -89,37 +89,37 @@ export type DatabaseConnection<T> =
89
89
  * ```
90
90
  */
91
91
  export async function withAuditableTransaction<DB, T>(
92
- db: DatabaseConnection<DB>,
93
- auditor: TransactionAwareAuditor<Transaction<DB>>,
94
- cb: (trx: Transaction<DB>) => Promise<T>,
95
- settings?: TransactionSettings,
92
+ db: DatabaseConnection<DB>,
93
+ auditor: TransactionAwareAuditor<Transaction<DB>>,
94
+ cb: (trx: Transaction<DB>) => Promise<T>,
95
+ settings?: TransactionSettings,
96
96
  ): Promise<T> {
97
- const execute = async (trx: Transaction<DB>): Promise<T> => {
98
- // Register transaction with auditor
99
- auditor.setTransaction(trx);
97
+ const execute = async (trx: Transaction<DB>): Promise<T> => {
98
+ // Register transaction with auditor
99
+ auditor.setTransaction(trx);
100
100
 
101
- // Execute the callback
102
- const result = await cb(trx);
101
+ // Execute the callback
102
+ const result = await cb(trx);
103
103
 
104
- // Flush audits BEFORE transaction commits
105
- // If this fails, the transaction will roll back
106
- await auditor.flush(trx);
104
+ // Flush audits BEFORE transaction commits
105
+ // If this fails, the transaction will roll back
106
+ await auditor.flush(trx);
107
107
 
108
- return result;
109
- };
108
+ return result;
109
+ };
110
110
 
111
- // If already in a transaction, just run with it
112
- if (db.isTransaction) {
113
- return execute(db as Transaction<DB>);
114
- }
111
+ // If already in a transaction, just run with it
112
+ if (db.isTransaction) {
113
+ return execute(db as Transaction<DB>);
114
+ }
115
115
 
116
- const builder = db.transaction();
116
+ const builder = db.transaction();
117
117
 
118
- if (settings?.isolationLevel) {
119
- return builder.setIsolationLevel(settings.isolationLevel).execute(execute);
120
- }
118
+ if (settings?.isolationLevel) {
119
+ return builder.setIsolationLevel(settings.isolationLevel).execute(execute);
120
+ }
121
121
 
122
- return builder.execute(execute);
122
+ return builder.execute(execute);
123
123
  }
124
124
 
125
125
  /**
@@ -137,19 +137,19 @@ export async function withAuditableTransaction<DB, T>(
137
137
  * ```
138
138
  */
139
139
  export interface AuditLogTable {
140
- id: string;
141
- type: string;
142
- operation: string;
143
- table: string | null;
144
- entityId: string | null;
145
- oldValues: unknown | null;
146
- newValues: unknown | null;
147
- payload: unknown | null;
148
- timestamp: Date;
149
- actorId: string | null;
150
- actorType: string | null;
151
- actorData: unknown | null;
152
- metadata: unknown | null;
140
+ id: string;
141
+ type: string;
142
+ operation: string;
143
+ table: string | null;
144
+ entityId: string | null;
145
+ oldValues: unknown | null;
146
+ newValues: unknown | null;
147
+ payload: unknown | null;
148
+ timestamp: Date;
149
+ actorId: string | null;
150
+ actorType: string | null;
151
+ actorData: unknown | null;
152
+ metadata: unknown | null;
153
153
  }
154
154
 
155
155
  /**
@@ -167,30 +167,30 @@ export interface AuditLogTable {
167
167
  * ```
168
168
  */
169
169
  export type InsertableAuditLogTable = Omit<AuditLogTable, 'id'> & {
170
- id?: string;
170
+ id?: string;
171
171
  };
172
172
 
173
173
  /**
174
174
  * Configuration for KyselyAuditStorage.
175
175
  */
176
176
  export interface KyselyAuditStorageConfig<DB> {
177
- /** Kysely database instance */
178
- db: Kysely<DB>;
179
- /** Table name for audit logs (must be a key in DB that extends AuditLogTable) */
180
- tableName: keyof DB & string;
181
- /**
182
- * Service name of the database service.
183
- * When set, endpoint adaptors will automatically use the audit transaction as `db`
184
- * in the handler context if the endpoint's database service has the same name.
185
- */
186
- databaseServiceName?: string;
187
- /**
188
- * Let the database auto-generate IDs (e.g., via DEFAULT gen_random_uuid()).
189
- * When true, the ID field is omitted from inserts if not provided.
190
- * When false (default), IDs are generated using nanoid if not provided.
191
- * @default false
192
- */
193
- autoId?: boolean;
177
+ /** Kysely database instance */
178
+ db: Kysely<DB>;
179
+ /** Table name for audit logs (must be a key in DB that extends AuditLogTable) */
180
+ tableName: keyof DB & string;
181
+ /**
182
+ * Service name of the database service.
183
+ * When set, endpoint adaptors will automatically use the audit transaction as `db`
184
+ * in the handler context if the endpoint's database service has the same name.
185
+ */
186
+ databaseServiceName?: string;
187
+ /**
188
+ * Let the database auto-generate IDs (e.g., via DEFAULT gen_random_uuid()).
189
+ * When true, the ID field is omitted from inserts if not provided.
190
+ * When false (default), IDs are generated using nanoid if not provided.
191
+ * @default false
192
+ */
193
+ autoId?: boolean;
194
194
  }
195
195
 
196
196
  /**
@@ -217,230 +217,230 @@ export interface KyselyAuditStorageConfig<DB> {
217
217
  * ```
218
218
  */
219
219
  export class KyselyAuditStorage<DB> implements AuditStorage {
220
- private readonly db: Kysely<DB>;
221
- private readonly tableName: keyof DB & string;
222
- private readonly autoId: boolean;
223
- readonly databaseServiceName?: string;
224
-
225
- constructor(config: KyselyAuditStorageConfig<DB>) {
226
- this.db = config.db;
227
- this.tableName = config.tableName;
228
- this.databaseServiceName = config.databaseServiceName;
229
- this.autoId = config.autoId ?? false;
230
- }
231
-
232
- async write(records: AuditRecord[], trx?: unknown): Promise<void> {
233
- if (records.length === 0) {
234
- return;
235
- }
236
-
237
- const db = (trx as Transaction<DB>) ?? this.db;
238
- const rows = records.map((record) => this.toRow(record));
239
-
240
- await (db as any).insertInto(this.tableName).values(rows).execute();
241
- }
242
-
243
- async query(options: AuditQueryOptions): Promise<AuditRecord[]> {
244
- let query = (this.db as any).selectFrom(this.tableName).selectAll();
245
-
246
- query = this.applyFilters(query, options);
247
-
248
- // Ordering
249
- const orderBy = options.orderBy ?? 'timestamp';
250
- const orderDirection = options.orderDirection ?? 'desc';
251
- query = query.orderBy(
252
- orderBy === 'timestamp' ? 'timestamp' : 'type',
253
- orderDirection,
254
- );
255
-
256
- // Pagination
257
- if (options.limit !== undefined) {
258
- query = query.limit(options.limit);
259
- }
260
- if (options.offset !== undefined) {
261
- query = query.offset(options.offset);
262
- }
263
-
264
- const rows = await query.execute();
265
- return rows.map((row: AuditLogTable) => this.fromRow(row));
266
- }
267
-
268
- async count(
269
- options: Omit<AuditQueryOptions, 'limit' | 'offset'>,
270
- ): Promise<number> {
271
- let query = (this.db as any)
272
- .selectFrom(this.tableName)
273
- .select((eb: any) => eb.fn.count('id').as('count'));
274
-
275
- query = this.applyFilters(query, options);
276
-
277
- const result = await query.executeTakeFirst();
278
- return Number(result?.count ?? 0);
279
- }
280
-
281
- /**
282
- * Get the Kysely database instance for transactional operations.
283
- * Used by endpoint adaptors to automatically wrap handlers in transactions.
284
- */
285
- getDatabase(): Kysely<DB> {
286
- return this.db;
287
- }
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
-
322
- private applyFilters(query: any, options: AuditQueryOptions): any {
323
- // Type filter
324
- if (options.type !== undefined) {
325
- if (Array.isArray(options.type)) {
326
- query = query.where('type', 'in', options.type);
327
- } else {
328
- query = query.where('type', '=', options.type);
329
- }
330
- }
331
-
332
- // Entity ID filter
333
- if (options.entityId !== undefined) {
334
- const entityId =
335
- typeof options.entityId === 'string'
336
- ? options.entityId
337
- : JSON.stringify(options.entityId);
338
- query = query.where('entityId', '=', entityId);
339
- }
340
-
341
- // Table filter
342
- if (options.table !== undefined) {
343
- query = query.where('table', '=', options.table);
344
- }
345
-
346
- // Actor ID filter
347
- if (options.actorId !== undefined) {
348
- query = query.where('actorId', '=', options.actorId);
349
- }
350
-
351
- // Date range filters
352
- if (options.from !== undefined) {
353
- query = query.where('timestamp', '>=', options.from);
354
- }
355
- if (options.to !== undefined) {
356
- query = query.where('timestamp', '<=', options.to);
357
- }
358
-
359
- return query;
360
- }
361
-
362
- private toRow(record: AuditRecord): AuditLogTable {
363
- // If autoId is true, let database generate ID (ignore record.id)
364
- // If autoId is false (default), use record.id or generate with nanoid
365
- const id = this.autoId ? undefined : record.id || nanoid();
366
-
367
- return {
368
- ...(id && { id }),
369
- type: record.type,
370
- operation: record.operation,
371
- table: record.table ?? null,
372
- entityId:
373
- record.entityId === undefined
374
- ? null
375
- : typeof record.entityId === 'string'
376
- ? record.entityId
377
- : JSON.stringify(record.entityId),
378
- oldValues: record.oldValues ?? null,
379
- newValues: record.newValues ?? null,
380
- payload: record.payload ?? null,
381
- timestamp: record.timestamp,
382
- actorId: record.actor?.id ?? null,
383
- actorType: record.actor?.type ?? null,
384
- actorData:
385
- record.actor !== undefined ? this.getActorData(record.actor) : null,
386
- metadata: record.metadata ?? null,
387
- } as AuditLogTable;
388
- }
389
-
390
- private fromRow(row: AuditLogTable): AuditRecord {
391
- const actor =
392
- row.actorId !== null || row.actorType !== null
393
- ? {
394
- id: row.actorId ?? undefined,
395
- type: row.actorType ?? undefined,
396
- ...(row.actorData ? this.parseJson(row.actorData) : {}),
397
- }
398
- : undefined;
399
-
400
- return {
401
- id: row.id,
402
- type: row.type,
403
- operation: row.operation as AuditRecord['operation'],
404
- table: row.table ?? undefined,
405
- entityId: row.entityId ? this.parseEntityId(row.entityId) : undefined,
406
- oldValues: row.oldValues ? this.parseJson(row.oldValues) : undefined,
407
- newValues: row.newValues ? this.parseJson(row.newValues) : undefined,
408
- payload: row.payload ? this.parseJson(row.payload) : undefined,
409
- timestamp: row.timestamp,
410
- actor,
411
- metadata: row.metadata ? this.parseJson(row.metadata) : undefined,
412
- };
413
- }
414
-
415
- /**
416
- * Parse a JSON value that may already be parsed (e.g., from jsonb columns).
417
- */
418
- private parseJson(value: unknown): Record<string, unknown> {
419
- if (typeof value === 'object' && value !== null) {
420
- return value as Record<string, unknown>;
421
- }
422
- if (typeof value === 'string') {
423
- return JSON.parse(value);
424
- }
425
- return {};
426
- }
427
-
428
- private getActorData(
429
- actor: NonNullable<AuditRecord['actor']>,
430
- ): Record<string, unknown> {
431
- const { id, type, ...rest } = actor;
432
- return rest;
433
- }
434
-
435
- private parseEntityId(entityId: string): string | Record<string, unknown> {
436
- try {
437
- const parsed = JSON.parse(entityId);
438
- if (typeof parsed === 'object' && parsed !== null) {
439
- return parsed;
440
- }
441
- return entityId;
442
- } catch {
443
- return entityId;
444
- }
445
- }
220
+ private readonly db: Kysely<DB>;
221
+ private readonly tableName: keyof DB & string;
222
+ private readonly autoId: boolean;
223
+ readonly databaseServiceName?: string;
224
+
225
+ constructor(config: KyselyAuditStorageConfig<DB>) {
226
+ this.db = config.db;
227
+ this.tableName = config.tableName;
228
+ this.databaseServiceName = config.databaseServiceName;
229
+ this.autoId = config.autoId ?? false;
230
+ }
231
+
232
+ async write(records: AuditRecord[], trx?: unknown): Promise<void> {
233
+ if (records.length === 0) {
234
+ return;
235
+ }
236
+
237
+ const db = (trx as Transaction<DB>) ?? this.db;
238
+ const rows = records.map((record) => this.toRow(record));
239
+
240
+ await (db as any).insertInto(this.tableName).values(rows).execute();
241
+ }
242
+
243
+ async query(options: AuditQueryOptions): Promise<AuditRecord[]> {
244
+ let query = (this.db as any).selectFrom(this.tableName).selectAll();
245
+
246
+ query = this.applyFilters(query, options);
247
+
248
+ // Ordering
249
+ const orderBy = options.orderBy ?? 'timestamp';
250
+ const orderDirection = options.orderDirection ?? 'desc';
251
+ query = query.orderBy(
252
+ orderBy === 'timestamp' ? 'timestamp' : 'type',
253
+ orderDirection,
254
+ );
255
+
256
+ // Pagination
257
+ if (options.limit !== undefined) {
258
+ query = query.limit(options.limit);
259
+ }
260
+ if (options.offset !== undefined) {
261
+ query = query.offset(options.offset);
262
+ }
263
+
264
+ const rows = await query.execute();
265
+ return rows.map((row: AuditLogTable) => this.fromRow(row));
266
+ }
267
+
268
+ async count(
269
+ options: Omit<AuditQueryOptions, 'limit' | 'offset'>,
270
+ ): Promise<number> {
271
+ let query = (this.db as any)
272
+ .selectFrom(this.tableName)
273
+ .select((eb: any) => eb.fn.count('id').as('count'));
274
+
275
+ query = this.applyFilters(query, options);
276
+
277
+ const result = await query.executeTakeFirst();
278
+ return Number(result?.count ?? 0);
279
+ }
280
+
281
+ /**
282
+ * Get the Kysely database instance for transactional operations.
283
+ * Used by endpoint adaptors to automatically wrap handlers in transactions.
284
+ */
285
+ getDatabase(): Kysely<DB> {
286
+ return this.db;
287
+ }
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
+
322
+ private applyFilters(query: any, options: AuditQueryOptions): any {
323
+ // Type filter
324
+ if (options.type !== undefined) {
325
+ if (Array.isArray(options.type)) {
326
+ query = query.where('type', 'in', options.type);
327
+ } else {
328
+ query = query.where('type', '=', options.type);
329
+ }
330
+ }
331
+
332
+ // Entity ID filter
333
+ if (options.entityId !== undefined) {
334
+ const entityId =
335
+ typeof options.entityId === 'string'
336
+ ? options.entityId
337
+ : JSON.stringify(options.entityId);
338
+ query = query.where('entityId', '=', entityId);
339
+ }
340
+
341
+ // Table filter
342
+ if (options.table !== undefined) {
343
+ query = query.where('table', '=', options.table);
344
+ }
345
+
346
+ // Actor ID filter
347
+ if (options.actorId !== undefined) {
348
+ query = query.where('actorId', '=', options.actorId);
349
+ }
350
+
351
+ // Date range filters
352
+ if (options.from !== undefined) {
353
+ query = query.where('timestamp', '>=', options.from);
354
+ }
355
+ if (options.to !== undefined) {
356
+ query = query.where('timestamp', '<=', options.to);
357
+ }
358
+
359
+ return query;
360
+ }
361
+
362
+ private toRow(record: AuditRecord): AuditLogTable {
363
+ // If autoId is true, let database generate ID (ignore record.id)
364
+ // If autoId is false (default), use record.id or generate with nanoid
365
+ const id = this.autoId ? undefined : record.id || nanoid();
366
+
367
+ return {
368
+ ...(id && { id }),
369
+ type: record.type,
370
+ operation: record.operation,
371
+ table: record.table ?? null,
372
+ entityId:
373
+ record.entityId === undefined
374
+ ? null
375
+ : typeof record.entityId === 'string'
376
+ ? record.entityId
377
+ : JSON.stringify(record.entityId),
378
+ oldValues: record.oldValues ?? null,
379
+ newValues: record.newValues ?? null,
380
+ payload: record.payload ?? null,
381
+ timestamp: record.timestamp,
382
+ actorId: record.actor?.id ?? null,
383
+ actorType: record.actor?.type ?? null,
384
+ actorData:
385
+ record.actor !== undefined ? this.getActorData(record.actor) : null,
386
+ metadata: record.metadata ?? null,
387
+ } as AuditLogTable;
388
+ }
389
+
390
+ private fromRow(row: AuditLogTable): AuditRecord {
391
+ const actor =
392
+ row.actorId !== null || row.actorType !== null
393
+ ? {
394
+ id: row.actorId ?? undefined,
395
+ type: row.actorType ?? undefined,
396
+ ...(row.actorData ? this.parseJson(row.actorData) : {}),
397
+ }
398
+ : undefined;
399
+
400
+ return {
401
+ id: row.id,
402
+ type: row.type,
403
+ operation: row.operation as AuditRecord['operation'],
404
+ table: row.table ?? undefined,
405
+ entityId: row.entityId ? this.parseEntityId(row.entityId) : undefined,
406
+ oldValues: row.oldValues ? this.parseJson(row.oldValues) : undefined,
407
+ newValues: row.newValues ? this.parseJson(row.newValues) : undefined,
408
+ payload: row.payload ? this.parseJson(row.payload) : undefined,
409
+ timestamp: row.timestamp,
410
+ actor,
411
+ metadata: row.metadata ? this.parseJson(row.metadata) : undefined,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Parse a JSON value that may already be parsed (e.g., from jsonb columns).
417
+ */
418
+ private parseJson(value: unknown): Record<string, unknown> {
419
+ if (typeof value === 'object' && value !== null) {
420
+ return value as Record<string, unknown>;
421
+ }
422
+ if (typeof value === 'string') {
423
+ return JSON.parse(value);
424
+ }
425
+ return {};
426
+ }
427
+
428
+ private getActorData(
429
+ actor: NonNullable<AuditRecord['actor']>,
430
+ ): Record<string, unknown> {
431
+ const { id, type, ...rest } = actor;
432
+ return rest;
433
+ }
434
+
435
+ private parseEntityId(entityId: string): string | Record<string, unknown> {
436
+ try {
437
+ const parsed = JSON.parse(entityId);
438
+ if (typeof parsed === 'object' && parsed !== null) {
439
+ return parsed;
440
+ }
441
+ return entityId;
442
+ } catch {
443
+ return entityId;
444
+ }
445
+ }
446
446
  }
package/src/memory.ts CHANGED
@@ -35,16 +35,16 @@ import type { AuditableAction } from './types';
35
35
  * ```
36
36
  */
37
37
  export class InMemoryAuditStorage<
38
- TAuditAction extends AuditableAction<string, unknown> = AuditableAction<
39
- string,
40
- unknown
41
- >,
38
+ TAuditAction extends AuditableAction<string, unknown> = AuditableAction<
39
+ string,
40
+ unknown
41
+ >,
42
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
- }
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
50
  }