@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/src/kysely.ts DELETED
@@ -1,424 +0,0 @@
1
- import type {
2
- ControlledTransaction,
3
- IsolationLevel,
4
- Kysely,
5
- Transaction,
6
- } from 'kysely';
7
- import { nanoid } from 'nanoid';
8
- import type {
9
- AuditQueryOptions,
10
- AuditStorage,
11
- TransactionAwareAuditor,
12
- } from './storage';
13
- import type { AuditRecord } from './types';
14
-
15
- export type { TransactionAwareAuditor };
16
-
17
- export interface TransactionSettings {
18
- isolationLevel?: IsolationLevel;
19
- }
20
-
21
- export type DatabaseConnection<T> =
22
- | ControlledTransaction<T>
23
- | Kysely<T>
24
- | Transaction<T>;
25
-
26
- /**
27
- * Execute a callback within a database transaction with automatic audit handling.
28
- *
29
- * This wrapper ensures that:
30
- * 1. The transaction is automatically registered with the auditor
31
- * 2. Manual audits (via `auditor.audit()`) are flushed BEFORE the transaction commits
32
- * 3. If audit flush fails, the entire transaction rolls back
33
- * 4. If the callback fails, audits are NOT written (atomic consistency)
34
- *
35
- * **Note:** Declarative audits (defined via `.audit([...])` on the endpoint builder)
36
- * are processed AFTER the handler returns, so they run outside this transaction.
37
- * If you need all audits to be atomic with your database operations, use manual
38
- * audits via `auditor.audit()` inside this wrapper.
39
- *
40
- * @param db - Database connection (Kysely, Transaction, or ControlledTransaction)
41
- * @param auditor - Auditor instance that will receive the transaction
42
- * @param cb - Callback to execute within the transaction
43
- * @param settings - Optional transaction settings (isolation level)
44
- * @returns The result of the callback
45
- *
46
- * @example
47
- * ```typescript
48
- * import { withAuditableTransaction } from '@geekmidas/audit/kysely';
49
- *
50
- * const result = await withAuditableTransaction(
51
- * services.database,
52
- * auditor,
53
- * async (trx) => {
54
- * const user = await trx
55
- * .insertInto('users')
56
- * .values(data)
57
- * .returningAll()
58
- * .executeTakeFirstOrThrow();
59
- *
60
- * // Manual audits are atomic with the transaction
61
- * auditor.audit('user.created', { userId: user.id, email: user.email });
62
- *
63
- * return user;
64
- * },
65
- * );
66
- * // Audits are automatically flushed inside the transaction before commit
67
- * ```
68
- */
69
- export async function withAuditableTransaction<DB, T>(
70
- db: DatabaseConnection<DB>,
71
- auditor: TransactionAwareAuditor<Transaction<DB>>,
72
- cb: (trx: Transaction<DB>) => Promise<T>,
73
- settings?: TransactionSettings,
74
- ): Promise<T> {
75
- const execute = async (trx: Transaction<DB>): Promise<T> => {
76
- // Register transaction with auditor
77
- auditor.setTransaction(trx);
78
-
79
- // Execute the callback
80
- const result = await cb(trx);
81
-
82
- // Flush audits BEFORE transaction commits
83
- // If this fails, the transaction will roll back
84
- await auditor.flush(trx);
85
-
86
- return result;
87
- };
88
-
89
- // If already in a transaction, just run with it
90
- if (db.isTransaction) {
91
- return execute(db as Transaction<DB>);
92
- }
93
-
94
- const builder = db.transaction();
95
-
96
- if (settings?.isolationLevel) {
97
- return builder.setIsolationLevel(settings.isolationLevel).execute(execute);
98
- }
99
-
100
- return builder.execute(execute);
101
- }
102
-
103
- /**
104
- * Database table interface for audit records.
105
- * Use this to define your audit_logs table in your Kysely database schema.
106
- *
107
- * Column names use snake_case to match standard PostgreSQL conventions.
108
- *
109
- * @example
110
- * ```typescript
111
- * interface Database {
112
- * audit_logs: AuditLogTable;
113
- * // ... other tables
114
- * }
115
- * ```
116
- */
117
- export interface AuditLogTable {
118
- id: string;
119
- type: string;
120
- operation: string;
121
- table: string | null;
122
- entityId: string | null;
123
- oldValues: unknown | null;
124
- newValues: unknown | null;
125
- payload: unknown | null;
126
- timestamp: Date;
127
- actorId: string | null;
128
- actorType: string | null;
129
- actorData: unknown | null;
130
- metadata: unknown | null;
131
- }
132
-
133
- /**
134
- * Insertable version of AuditLogTable where id is optional.
135
- * Use this when your database auto-generates IDs or when using autoId option.
136
- *
137
- * @example
138
- * ```typescript
139
- * interface Database {
140
- * audit_logs: AuditLogTable;
141
- * }
142
- *
143
- * // For insertions where id is auto-generated
144
- * type NewAuditLog = InsertableAuditLogTable;
145
- * ```
146
- */
147
- export type InsertableAuditLogTable = Omit<AuditLogTable, 'id'> & {
148
- id?: string;
149
- };
150
-
151
- /**
152
- * Configuration for KyselyAuditStorage.
153
- */
154
- export interface KyselyAuditStorageConfig<DB> {
155
- /** Kysely database instance */
156
- db: Kysely<DB>;
157
- /** Table name for audit logs (must be a key in DB that extends AuditLogTable) */
158
- tableName: keyof DB & string;
159
- /**
160
- * Service name of the database service.
161
- * When set, endpoint adaptors will automatically use the audit transaction as `db`
162
- * in the handler context if the endpoint's database service has the same name.
163
- */
164
- databaseServiceName?: string;
165
- /**
166
- * Let the database auto-generate IDs (e.g., via DEFAULT gen_random_uuid()).
167
- * When true, the ID field is omitted from inserts if not provided.
168
- * When false (default), IDs are generated using nanoid if not provided.
169
- * @default false
170
- */
171
- autoId?: boolean;
172
- }
173
-
174
- /**
175
- * Kysely-based audit storage implementation.
176
- * Stores audit records in a database table using Kysely.
177
- *
178
- * @template DB - Your Kysely database schema
179
- *
180
- * @example
181
- * ```typescript
182
- * interface Database {
183
- * audit_logs: AuditLogTable;
184
- * }
185
- *
186
- * const storage = new KyselyAuditStorage({
187
- * db: kyselyDb,
188
- * tableName: 'audit_logs',
189
- * });
190
- *
191
- * const auditor = new DefaultAuditor({
192
- * actor: { id: 'user-123', type: 'user' },
193
- * storage,
194
- * });
195
- * ```
196
- */
197
- export class KyselyAuditStorage<DB> implements AuditStorage {
198
- private readonly db: Kysely<DB>;
199
- private readonly tableName: keyof DB & string;
200
- private readonly autoId: boolean;
201
- readonly databaseServiceName?: string;
202
-
203
- constructor(config: KyselyAuditStorageConfig<DB>) {
204
- this.db = config.db;
205
- this.tableName = config.tableName;
206
- this.databaseServiceName = config.databaseServiceName;
207
- this.autoId = config.autoId ?? false;
208
- }
209
-
210
- async write(records: AuditRecord[], trx?: unknown): Promise<void> {
211
- if (records.length === 0) {
212
- return;
213
- }
214
-
215
- const db = (trx as Transaction<DB>) ?? this.db;
216
- const rows = records.map((record) => this.toRow(record));
217
-
218
- await (db as any).insertInto(this.tableName).values(rows).execute();
219
- }
220
-
221
- async query(options: AuditQueryOptions): Promise<AuditRecord[]> {
222
- let query = (this.db as any).selectFrom(this.tableName).selectAll();
223
-
224
- query = this.applyFilters(query, options);
225
-
226
- // Ordering
227
- const orderBy = options.orderBy ?? 'timestamp';
228
- const orderDirection = options.orderDirection ?? 'desc';
229
- query = query.orderBy(
230
- orderBy === 'timestamp' ? 'timestamp' : 'type',
231
- orderDirection,
232
- );
233
-
234
- // Pagination
235
- if (options.limit !== undefined) {
236
- query = query.limit(options.limit);
237
- }
238
- if (options.offset !== undefined) {
239
- query = query.offset(options.offset);
240
- }
241
-
242
- const rows = await query.execute();
243
- return rows.map((row: AuditLogTable) => this.fromRow(row));
244
- }
245
-
246
- async count(
247
- options: Omit<AuditQueryOptions, 'limit' | 'offset'>,
248
- ): Promise<number> {
249
- let query = (this.db as any)
250
- .selectFrom(this.tableName)
251
- .select((eb: any) => eb.fn.count('id').as('count'));
252
-
253
- query = this.applyFilters(query, options);
254
-
255
- const result = await query.executeTakeFirst();
256
- return Number(result?.count ?? 0);
257
- }
258
-
259
- /**
260
- * Get the Kysely database instance for transactional operations.
261
- * Used by endpoint adaptors to automatically wrap handlers in transactions.
262
- */
263
- getDatabase(): Kysely<DB> {
264
- return this.db;
265
- }
266
-
267
- /**
268
- * Execute a callback within a Kysely transaction with automatic audit handling.
269
- * The auditor is registered with the transaction and audits are flushed
270
- * before the transaction commits.
271
- *
272
- * If the provided db connection is already a transaction, it will be reused
273
- * instead of creating a nested transaction.
274
- */
275
- async withTransaction<T>(
276
- auditor: TransactionAwareAuditor<Transaction<DB>>,
277
- callback: () => Promise<T>,
278
- db?: DatabaseConnection<DB>,
279
- ): Promise<T> {
280
- const connection = db ?? this.db;
281
-
282
- // If already in a transaction, reuse it
283
- if (connection.isTransaction) {
284
- const trx = connection as Transaction<DB>;
285
- auditor.setTransaction(trx);
286
- const result = await callback();
287
- await auditor.flush(trx);
288
- return result;
289
- }
290
-
291
- // Create new transaction
292
- return connection.transaction().execute(async (trx) => {
293
- auditor.setTransaction(trx);
294
- const result = await callback();
295
- await auditor.flush(trx);
296
- return result;
297
- });
298
- }
299
-
300
- private applyFilters(query: any, options: AuditQueryOptions): any {
301
- // Type filter
302
- if (options.type !== undefined) {
303
- if (Array.isArray(options.type)) {
304
- query = query.where('type', 'in', options.type);
305
- } else {
306
- query = query.where('type', '=', options.type);
307
- }
308
- }
309
-
310
- // Entity ID filter
311
- if (options.entityId !== undefined) {
312
- const entityId =
313
- typeof options.entityId === 'string'
314
- ? options.entityId
315
- : JSON.stringify(options.entityId);
316
- query = query.where('entityId', '=', entityId);
317
- }
318
-
319
- // Table filter
320
- if (options.table !== undefined) {
321
- query = query.where('table', '=', options.table);
322
- }
323
-
324
- // Actor ID filter
325
- if (options.actorId !== undefined) {
326
- query = query.where('actorId', '=', options.actorId);
327
- }
328
-
329
- // Date range filters
330
- if (options.from !== undefined) {
331
- query = query.where('timestamp', '>=', options.from);
332
- }
333
- if (options.to !== undefined) {
334
- query = query.where('timestamp', '<=', options.to);
335
- }
336
-
337
- return query;
338
- }
339
-
340
- private toRow(record: AuditRecord): AuditLogTable {
341
- // If autoId is true, let database generate ID (ignore record.id)
342
- // If autoId is false (default), use record.id or generate with nanoid
343
- const id = this.autoId ? undefined : record.id || nanoid();
344
-
345
- return {
346
- ...(id && { id }),
347
- type: record.type,
348
- operation: record.operation,
349
- table: record.table ?? null,
350
- entityId:
351
- record.entityId === undefined
352
- ? null
353
- : typeof record.entityId === 'string'
354
- ? record.entityId
355
- : JSON.stringify(record.entityId),
356
- oldValues: record.oldValues ?? null,
357
- newValues: record.newValues ?? null,
358
- payload: record.payload ?? null,
359
- timestamp: record.timestamp,
360
- actorId: record.actor?.id ?? null,
361
- actorType: record.actor?.type ?? null,
362
- actorData:
363
- record.actor !== undefined ? this.getActorData(record.actor) : null,
364
- metadata: record.metadata ?? null,
365
- } as AuditLogTable;
366
- }
367
-
368
- private fromRow(row: AuditLogTable): AuditRecord {
369
- const actor =
370
- row.actorId !== null || row.actorType !== null
371
- ? {
372
- id: row.actorId ?? undefined,
373
- type: row.actorType ?? undefined,
374
- ...(row.actorData ? this.parseJson(row.actorData) : {}),
375
- }
376
- : undefined;
377
-
378
- return {
379
- id: row.id,
380
- type: row.type,
381
- operation: row.operation as AuditRecord['operation'],
382
- table: row.table ?? undefined,
383
- entityId: row.entityId ? this.parseEntityId(row.entityId) : undefined,
384
- oldValues: row.oldValues ? this.parseJson(row.oldValues) : undefined,
385
- newValues: row.newValues ? this.parseJson(row.newValues) : undefined,
386
- payload: row.payload ? this.parseJson(row.payload) : undefined,
387
- timestamp: row.timestamp,
388
- actor,
389
- metadata: row.metadata ? this.parseJson(row.metadata) : undefined,
390
- };
391
- }
392
-
393
- /**
394
- * Parse a JSON value that may already be parsed (e.g., from jsonb columns).
395
- */
396
- private parseJson(value: unknown): Record<string, unknown> {
397
- if (typeof value === 'object' && value !== null) {
398
- return value as Record<string, unknown>;
399
- }
400
- if (typeof value === 'string') {
401
- return JSON.parse(value);
402
- }
403
- return {};
404
- }
405
-
406
- private getActorData(
407
- actor: NonNullable<AuditRecord['actor']>,
408
- ): Record<string, unknown> {
409
- const { id, type, ...rest } = actor;
410
- return rest;
411
- }
412
-
413
- private parseEntityId(entityId: string): string | Record<string, unknown> {
414
- try {
415
- const parsed = JSON.parse(entityId);
416
- if (typeof parsed === 'object' && parsed !== null) {
417
- return parsed;
418
- }
419
- return entityId;
420
- } catch {
421
- return entityId;
422
- }
423
- }
424
- }
package/src/memory.ts DELETED
@@ -1,50 +0,0 @@
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 DELETED
@@ -1,203 +0,0 @@
1
- import type { AuditableAction, AuditRecord } from './types';
2
-
3
- /**
4
- * Minimal interface for transaction-aware audit flushing.
5
- * Use this when you need to flush audits within a database transaction.
6
- *
7
- * @template TTransaction - Transaction type (e.g., Kysely `Transaction`, Knex `Transaction`)
8
- *
9
- * @example
10
- * ```typescript
11
- * import { withAuditableTransaction } from '@geekmidas/audit/kysely';
12
- * import type { TransactionAwareAuditor } from '@geekmidas/audit/kysely';
13
- *
14
- * const result = await withAuditableTransaction(
15
- * db,
16
- * auditor as TransactionAwareAuditor<Transaction<DB>>,
17
- * async (trx) => {
18
- * // Your transactional operations
19
- * return result;
20
- * },
21
- * );
22
- * ```
23
- */
24
- export interface TransactionAwareAuditor<TTransaction = unknown> {
25
- /** Register the transaction with the auditor for use during flush */
26
- setTransaction(trx: TTransaction): void;
27
- /** Flush all pending audits, optionally within a transaction */
28
- flush(trx?: TTransaction): Promise<void>;
29
- }
30
-
31
- /**
32
- * Options for querying audit records.
33
- */
34
- export interface AuditQueryOptions {
35
- /** Filter by audit type */
36
- type?: string | string[];
37
- /** Filter by entity ID */
38
- entityId?: string | Record<string, unknown>;
39
- /** Filter by table name */
40
- table?: string;
41
- /** Filter by actor ID */
42
- actorId?: string;
43
- /** Filter by date range (start) */
44
- from?: Date;
45
- /** Filter by date range (end) */
46
- to?: Date;
47
- /** Limit number of results */
48
- limit?: number;
49
- /** Offset for pagination */
50
- offset?: number;
51
- /** Sort order */
52
- orderBy?: 'timestamp' | 'type';
53
- /** Sort direction */
54
- orderDirection?: 'asc' | 'desc';
55
- }
56
-
57
- /**
58
- * Interface for audit storage backends.
59
- * Implement this to store audits in your preferred location.
60
- *
61
- * @example
62
- * ```typescript
63
- * // Same database storage (recommended for transactions)
64
- * const auditStorage: AuditStorage = {
65
- * async write(records, trx) {
66
- * const db = trx ?? getDatabase();
67
- * await db.insertInto('audit_logs').values(records).execute();
68
- * },
69
- * };
70
- *
71
- * // External audit service
72
- * const externalAuditStorage: AuditStorage = {
73
- * async write(records) {
74
- * await fetch('https://audit.example.com/api/audits', {
75
- * method: 'POST',
76
- * body: JSON.stringify({ records }),
77
- * });
78
- * },
79
- * };
80
- * ```
81
- *
82
- * @template TAuditAction - Optional type parameter for type-safe audit actions.
83
- * When provided, this type is preserved in the Service definition and can be
84
- * extracted by EndpointBuilder to provide type inference for `.audit([...])`.
85
- */
86
- export interface AuditStorage<
87
- TAuditAction extends AuditableAction<string, unknown> = AuditableAction<
88
- string,
89
- unknown
90
- >,
91
- > {
92
- /** @internal Type marker for extracting audit action type */
93
- readonly __auditActionType?: TAuditAction;
94
- /**
95
- * Write audit records to storage.
96
- * Called by Auditor.flush() to persist collected audits.
97
- *
98
- * @param records - The audit records to write
99
- * @param trx - Optional transaction context (for same-DB storage)
100
- */
101
- write(records: AuditRecord[], trx?: unknown): Promise<void>;
102
-
103
- /**
104
- * Optional: Query audit records for retrieval.
105
- * Implement this for audit log viewing/searching.
106
- *
107
- * @param options - Query filters and pagination
108
- * @returns Matching audit records
109
- */
110
- query?(options: AuditQueryOptions): Promise<AuditRecord[]>;
111
-
112
- /**
113
- * Optional: Count audit records matching filters.
114
- * Useful for pagination.
115
- *
116
- * @param options - Query filters (limit/offset ignored)
117
- * @returns Count of matching records
118
- */
119
- count?(options: Omit<AuditQueryOptions, 'limit' | 'offset'>): Promise<number>;
120
-
121
- /**
122
- * Optional: Get the database connection for transactional audit writes.
123
- * When implemented, the endpoint adaptor can automatically wrap handlers
124
- * in a transaction, ensuring audits are atomic with other database operations.
125
- *
126
- * @returns Database connection (e.g., Kysely instance)
127
- *
128
- * @example
129
- * ```typescript
130
- * class KyselyAuditStorage implements AuditStorage {
131
- * constructor(private db: Kysely<DB>) {}
132
- *
133
- * getDatabase() {
134
- * return this.db;
135
- * }
136
- * }
137
- * ```
138
- */
139
- getDatabase?(): unknown;
140
-
141
- /**
142
- * Optional: The service name of the database service used by this storage.
143
- * When set, endpoint adaptors will automatically use the audit transaction as `db`
144
- * in the handler context if the endpoint's database service has the same name.
145
- *
146
- * @example
147
- * ```typescript
148
- * const storage = new KyselyAuditStorage({
149
- * db,
150
- * tableName: 'audit_logs',
151
- * databaseServiceName: 'database', // Matches databaseService.serviceName
152
- * });
153
- * ```
154
- */
155
- databaseServiceName?: string;
156
-
157
- /**
158
- * Optional: Execute a callback within a database transaction.
159
- * The auditor is registered with the transaction and audits are flushed
160
- * before the transaction commits.
161
- *
162
- * This is database-agnostic - each storage implementation provides its own
163
- * transaction handling based on the underlying database.
164
- *
165
- * If a database connection is provided, it should be used instead of the
166
- * storage's internal connection. If the connection is already a transaction,
167
- * it should be reused instead of creating a nested transaction.
168
- *
169
- * @param auditor - The auditor to register with the transaction
170
- * @param callback - The callback to execute within the transaction
171
- * @param db - Optional database connection (may already be a transaction)
172
- * @returns The result of the callback
173
- *
174
- * @example
175
- * ```typescript
176
- * // KyselyAuditStorage implementation
177
- * async withTransaction<T>(auditor, callback, db) {
178
- * const connection = db ?? this.db;
179
- * if (connection.isTransaction) {
180
- * // Reuse existing transaction
181
- * auditor.setTransaction(connection);
182
- * const result = await callback();
183
- * await auditor.flush(connection);
184
- * return result;
185
- * }
186
- * return connection.transaction().execute(async (trx) => {
187
- * auditor.setTransaction(trx);
188
- * const result = await callback();
189
- * await auditor.flush(trx);
190
- * return result;
191
- * });
192
- * }
193
- * ```
194
- */
195
- withTransaction?<T>(
196
- auditor: {
197
- setTransaction(trx: unknown): void;
198
- flush(trx?: unknown): Promise<void>;
199
- },
200
- callback: () => Promise<T>,
201
- db?: unknown,
202
- ): Promise<T>;
203
- }