@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/TECHNICAL.md DELETED
@@ -1,937 +0,0 @@
1
- # @geekmidas/audit - Technical Documentation
2
-
3
- ## Overview
4
-
5
- The `@geekmidas/audit` package provides a transaction-aware audit system for tracking database mutations. Unlike event publishing (which happens after a transaction commits), audits run **inside** the same transaction as the mutation, ensuring audit records are atomically committed or rolled back with the data they describe.
6
-
7
- **Key Design Principle**: The core audit package is **database-agnostic**. Provider-specific wrappers (Kysely, Objection, Knex, MongoDB) are separate subpath exports that users opt into.
8
-
9
- ## Architecture
10
-
11
- ```
12
- ┌─────────────────────────────────────────────────────────────────┐
13
- │ @geekmidas/audit │
14
- ├─────────────────────────────────────────────────────────────────┤
15
- │ Core (database-agnostic) │ Provider Wrappers │
16
- │ ───────────────────────── │ ────────────────── │
17
- │ • Auditor interface │ • /kysely │
18
- │ • DefaultAuditor │ • /objection (future) │
19
- │ • AuditRecord types │ • /knex (future) │
20
- │ • AuditStorage interface │ • /mongo (future) │
21
- └─────────────────────────────────────────────────────────────────┘
22
- │
23
- ▼
24
- ┌─────────────────────────────────────────────────────────────────┐
25
- │ Endpoint Integration │
26
- │ • .auditor() builder method │
27
- │ • .audit() declarative audits │
28
- │ • ctx.auditor in handlers │
29
- │ • Automatic flush after handler │
30
- └─────────────────────────────────────────────────────────────────┘
31
- ```
32
-
33
- ## Why Provider-Agnostic?
34
-
35
- The audit system supports multiple database technologies:
36
-
37
- | Provider | Status | Import Path |
38
- |----------|--------|-------------|
39
- | Kysely | Implemented | `@geekmidas/audit/kysely` |
40
- | Objection.js | Future | `@geekmidas/audit/objection` |
41
- | Knex | Future | `@geekmidas/audit/knex` |
42
- | MongoDB | Future | `@geekmidas/audit/mongo` |
43
- | Drizzle | Future | `@geekmidas/audit/drizzle` |
44
-
45
- Users explicitly wrap their database with the appropriate provider wrapper. The endpoint adaptor **does not** automatically wrap services.
46
-
47
- ## Request Flow
48
-
49
- ```
50
- ┌─────────────────────────────────────────────────────────────────┐
51
- │ HTTP Request │
52
- └─────────────────────────────────────────────────────────────────┘
53
- │
54
- ▼
55
- ┌─────────────────────────────────────────────────────────────────┐
56
- │ HonoEndpointAdaptor │
57
- │ ┌───────────────────────────────────────────────────────────┐ │
58
- │ │ 1. Create Auditor (if .auditor() was called) │ │
59
- │ │ 2. Pass auditor to handler context │ │
60
- │ │ (NO automatic database wrapping) │ │
61
- │ └───────────────────────────────────────────────────────────┘ │
62
- └─────────────────────────────────────────────────────────────────┘
63
- │
64
- ▼
65
- ┌─────────────────────────────────────────────────────────────────┐
66
- │ Endpoint Handler │
67
- │ ┌───────────────────────────────────────────────────────────┐ │
68
- │ │ // User explicitly wraps database (provider-specific) │ │
69
- │ │ const db = new AuditableKysely(services.database, auditor);│ │
70
- │ │ │ │
71
- │ │ // Auto-captured audit (via wrapper) │ │
72
- │ │ await db.updateTable('users').set({ name: 'New' }).execute()│ │
73
- │ │ │ │
74
- │ │ // Manual audit (via context) │ │
75
- │ │ auditor.audit('custom.action', { ... }); │ │
76
- │ └───────────────────────────────────────────────────────────┘ │
77
- └─────────────────────────────────────────────────────────────────┘
78
- │
79
- ▼
80
- ┌─────────────────────────────────────────────────────────────────┐
81
- │ After Handler Execution │
82
- │ ┌───────────────────────────────────────────────────────────┐ │
83
- │ │ 1. Process declarative .audit() definitions │ │
84
- │ │ 2. Flush all collected audits to storage │ │
85
- │ │ (INSIDE the transaction) │ │
86
- │ └───────────────────────────────────────────────────────────┘ │
87
- └─────────────────────────────────────────────────────────────────┘
88
- ```
89
-
90
- ## Core Types
91
-
92
- ### AuditRecord
93
-
94
- The fundamental unit of audit data:
95
-
96
- ```typescript
97
- type AuditOperation = 'INSERT' | 'UPDATE' | 'DELETE' | 'CUSTOM';
98
-
99
- interface AuditRecord<TPayload = unknown> {
100
- // Identity
101
- id: string; // Unique identifier (nanoid)
102
- type: string; // Audit type (e.g., 'user.updated')
103
-
104
- // Operation details
105
- operation: AuditOperation; // What kind of operation
106
- table?: string; // Database table (for DB operations)
107
- entityId?: string | Record<string, unknown>; // Primary key(s)
108
-
109
- // Change tracking
110
- oldValues?: Record<string, unknown>; // Previous state (UPDATE/DELETE)
111
- newValues?: Record<string, unknown>; // New state (INSERT/UPDATE)
112
- payload?: TPayload; // Custom payload (CUSTOM operations)
113
-
114
- // Context
115
- timestamp: Date; // When the audit was recorded
116
- actor?: AuditActor; // Who performed the action
117
- metadata?: AuditMetadata; // Request context
118
- }
119
-
120
- interface AuditActor {
121
- id?: string; // User/service ID
122
- type?: string; // 'user', 'system', 'service', etc.
123
- [key: string]: unknown; // Extensible for custom fields
124
- }
125
-
126
- interface AuditMetadata {
127
- requestId?: string; // Correlation ID
128
- endpoint?: string; // Which endpoint was called
129
- method?: string; // HTTP method
130
- ip?: string; // Client IP
131
- userAgent?: string; // Client user agent
132
- [key: string]: unknown; // Extensible
133
- }
134
- ```
135
-
136
- ### AuditableAction Type
137
-
138
- Define type-safe audit actions (similar to `PublishableMessage` in events):
139
-
140
- ```typescript
141
- /**
142
- * Define an auditable action type with its payload structure.
143
- * Use union types to define all possible audit actions for your app.
144
- */
145
- type AuditableAction<TType extends string, TPayload = unknown> = {
146
- type: TType;
147
- payload: TPayload;
148
- };
149
-
150
- // Example: Define your app's audit types
151
- type AppAuditAction =
152
- | AuditableAction<'user.created', { userId: string; email: string }>
153
- | AuditableAction<'user.updated', { userId: string; changes: string[] }>
154
- | AuditableAction<'user.deleted', { userId: string; reason?: string }>
155
- | AuditableAction<'order.placed', { orderId: string; total: number }>
156
- | AuditableAction<'payment.processed', { orderId: string; transactionId: string }>;
157
- ```
158
-
159
- ### Type Extraction Utilities
160
-
161
- ```typescript
162
- /**
163
- * Extract the type string from an AuditableAction union
164
- */
165
- type ExtractAuditType<T extends AuditableAction<string, unknown>> =
166
- T extends AuditableAction<infer TType, unknown> ? TType : never;
167
-
168
- /**
169
- * Extract the payload for a specific audit type
170
- */
171
- type ExtractAuditPayload<
172
- T extends AuditableAction<string, unknown>,
173
- TType extends ExtractAuditType<T>,
174
- > = T extends AuditableAction<TType, infer TPayload> ? TPayload : never;
175
- ```
176
-
177
- ### Auditor Interface
178
-
179
- The core abstraction for audit collection. **Generic over audit action types** for full type safety:
180
-
181
- ```typescript
182
- interface Auditor<
183
- TAuditAction extends AuditableAction<string, unknown> = AuditableAction<string, unknown>,
184
- > {
185
- /**
186
- * The actor for all audits in this context.
187
- * Set at construction time, immutable.
188
- */
189
- readonly actor: AuditActor;
190
-
191
- /**
192
- * Record a type-safe audit entry.
193
- * The payload type is inferred from the audit type.
194
- */
195
- audit<TType extends ExtractAuditType<TAuditAction>>(
196
- type: TType,
197
- payload: ExtractAuditPayload<TAuditAction, TType>,
198
- options?: AuditOptions,
199
- ): void;
200
-
201
- /**
202
- * Record a raw audit record.
203
- * Use this when you need full control over the audit structure.
204
- */
205
- record(record: Omit<AuditRecord, 'id' | 'timestamp' | 'actor'>): void;
206
-
207
- /**
208
- * Get all collected audit records.
209
- */
210
- getRecords(): AuditRecord[];
211
-
212
- /**
213
- * Flush all collected audits to storage.
214
- * Called automatically by the endpoint adaptor inside the transaction.
215
- */
216
- flush(trx?: unknown): Promise<void>;
217
- }
218
-
219
- interface AuditOptions {
220
- entityId?: string | Record<string, unknown>;
221
- table?: string;
222
- operation?: AuditOperation;
223
- oldValues?: Record<string, unknown>;
224
- newValues?: Record<string, unknown>;
225
- }
226
- ```
227
-
228
- ### AuditStorage Interface
229
-
230
- Pluggable storage backend:
231
-
232
- ```typescript
233
- interface AuditStorage {
234
- /**
235
- * Write audit records to storage.
236
- * @param records - The audit records to write
237
- * @param trx - Transaction context (if writing to same DB)
238
- */
239
- write(records: AuditRecord[], trx?: unknown): Promise<void>;
240
-
241
- /**
242
- * Optional: Query audit records for retrieval.
243
- */
244
- query?(options: AuditQueryOptions): Promise<AuditRecord[]>;
245
- }
246
- ```
247
-
248
- ## Three Ways to Audit
249
-
250
- ### 1. Manual Auditing (via Context)
251
-
252
- For explicit control or non-database operations:
253
-
254
- ```typescript
255
- const processOrder = e
256
- .post('/orders')
257
- .services([databaseService, paymentService])
258
- .auditor(auditStorageService)
259
- .handle(async ({ body, services, auditor }) => {
260
- // Database operation (not auto-audited without wrapper)
261
- const order = await services.database
262
- .insertInto('orders')
263
- .values(body)
264
- .returningAll()
265
- .executeTakeFirstOrThrow();
266
-
267
- // Manual audit for external service call
268
- const paymentResult = await services.payment.charge(order.total);
269
- auditor.audit('payment.charged', {
270
- orderId: order.id,
271
- amount: order.total,
272
- transactionId: paymentResult.transactionId,
273
- });
274
-
275
- // Conditional audit
276
- if (order.total > 10000) {
277
- auditor.audit('order.high_value', {
278
- orderId: order.id,
279
- total: order.total,
280
- requiresReview: true,
281
- });
282
- }
283
-
284
- return order;
285
- });
286
- ```
287
-
288
- ### 2. Declarative Auditing (Builder Pattern)
289
-
290
- Similar to `.events()`, declare audits on the endpoint with `.audit()`:
291
-
292
- ```typescript
293
- // Define app audit types for type safety
294
- type AppAuditAction =
295
- | AuditableAction<'user.created', { userId: string; email: string }>
296
- | AuditableAction<'user.updated', { userId: string; changes: string[] }>;
297
-
298
- const createUser = e
299
- .post('/users')
300
- .services([databaseService])
301
- .auditor(auditStorageService) // Just the service
302
- .actor(({ session, header }) => ({
303
- id: session?.userId,
304
- type: 'user',
305
- ip: header('x-forwarded-for'),
306
- }))
307
- .body(createUserSchema)
308
- .output(userSchema)
309
- // Declarative audits - array like .events()
310
- .audit<AppAuditAction>([
311
- {
312
- type: 'user.created', // ✅ Type-safe - must be valid audit type
313
- payload: (response) => ({
314
- userId: response.id,
315
- email: response.email, // ✅ Payload shape enforced
316
- }),
317
- // Optional: only audit under certain conditions
318
- when: (response) => response.role !== 'system',
319
- // Optional: specify entity ID for easier querying
320
- entityId: (response) => response.id,
321
- table: 'users',
322
- },
323
- ])
324
- .handle(async ({ body, services }) => {
325
- return await services.database
326
- .insertInto('users')
327
- .values(body)
328
- .returningAll()
329
- .executeTakeFirstOrThrow();
330
- });
331
- ```
332
-
333
- ### 3. Automatic Database Auditing (Provider Wrappers)
334
-
335
- User explicitly wraps their database with a provider-specific wrapper:
336
-
337
- ```typescript
338
- import { AuditableKysely } from '@geekmidas/audit/kysely';
339
-
340
- const updateUser = e
341
- .put('/users/:id')
342
- .services([databaseService])
343
- .auditor(auditStorageService) // Just the service
344
- .actor(({ session, header }) => ({
345
- id: session.userId,
346
- type: 'user',
347
- ip: header('x-forwarded-for'),
348
- }))
349
- .handle(async ({ params, body, services, auditor }) => {
350
- // User explicitly wraps database
351
- const db = new AuditableKysely(services.database, auditor, {
352
- excludeTables: ['audit_logs'],
353
- });
354
-
355
- // This UPDATE is automatically audited:
356
- // - Old values fetched before update
357
- // - New values captured after update
358
- // - Audit record includes table, entityId, oldValues, newValues
359
- // - Actor automatically set from session
360
- return await db
361
- .updateTable('users')
362
- .set(body)
363
- .where('id', '=', params.id)
364
- .returningAll()
365
- .executeTakeFirstOrThrow();
366
- });
367
- ```
368
-
369
- ## Service Factory Pattern (Recommended)
370
-
371
- Create a service that provides an auditable wrapper factory:
372
-
373
- ```typescript
374
- // services/database.ts
375
- import { AuditableKysely } from '@geekmidas/audit/kysely';
376
- import type { Auditor } from '@geekmidas/audit';
377
-
378
- interface DatabaseService {
379
- raw: Kysely<Database>;
380
- withAuditor: (auditor: Auditor) => AuditableKysely<Database>;
381
- }
382
-
383
- const databaseService = {
384
- serviceName: 'database' as const,
385
- async register(envParser) {
386
- const db = createKyselyConnection(envParser);
387
- return {
388
- raw: db,
389
- withAuditor: (auditor: Auditor) => new AuditableKysely(db, auditor),
390
- };
391
- },
392
- } satisfies Service<'database', DatabaseService>;
393
- ```
394
-
395
- Usage in endpoints:
396
-
397
- ```typescript
398
- const updateUser = e
399
- .put('/users/:id')
400
- .services([databaseService])
401
- .auditor(auditStorageService)
402
- .handle(async ({ params, body, services, auditor }) => {
403
- // Clean one-liner to get auditable database
404
- const db = services.database.withAuditor(auditor);
405
-
406
- return await db
407
- .updateTable('users')
408
- .set(body)
409
- .where('id', '=', params.id)
410
- .returningAll()
411
- .executeTakeFirstOrThrow();
412
- });
413
- ```
414
-
415
- ## Provider Wrapper: AuditableKysely
416
-
417
- ### Overview
418
-
419
- `AuditableKysely` wraps a Kysely instance to intercept INSERT/UPDATE/DELETE operations:
420
-
421
- ```typescript
422
- import { AuditableKysely } from '@geekmidas/audit/kysely';
423
-
424
- const auditableDb = new AuditableKysely(db, auditor, {
425
- excludeTables: ['audit_logs', 'sessions'],
426
- getPrimaryKey: (table, record) => record.id,
427
- });
428
- ```
429
-
430
- ### Configuration
431
-
432
- ```typescript
433
- interface AuditableKyselyConfig<DB> {
434
- /**
435
- * Tables to exclude from automatic auditing.
436
- * Use to prevent recursion or skip high-volume tables.
437
- */
438
- excludeTables?: (keyof DB)[];
439
-
440
- /**
441
- * Custom primary key extractor.
442
- * Default: looks for 'id' field.
443
- */
444
- getPrimaryKey?: (
445
- table: keyof DB,
446
- record: unknown
447
- ) => string | Record<string, unknown>;
448
- }
449
- ```
450
-
451
- ### How Operations Are Intercepted
452
-
453
- **INSERT:**
454
- ```typescript
455
- // User code
456
- await db.insertInto('users').values({ name: 'John' }).execute();
457
-
458
- // Internally:
459
- // 1. Execute INSERT
460
- // 2. Record audit with operation='INSERT', newValues={name:'John'}
461
- ```
462
-
463
- **UPDATE:**
464
- ```typescript
465
- // User code
466
- await db.updateTable('users').set({ name: 'Jane' }).where('id', '=', '123').execute();
467
-
468
- // Internally:
469
- // 1. SELECT * FROM users WHERE id = '123' (capture old values)
470
- // 2. Execute UPDATE
471
- // 3. SELECT * FROM users WHERE id = '123' (capture new values)
472
- // 4. Record audit with oldValues, newValues
473
- ```
474
-
475
- **DELETE:**
476
- ```typescript
477
- // User code
478
- await db.deleteFrom('users').where('id', '=', '123').execute();
479
-
480
- // Internally:
481
- // 1. SELECT * FROM users WHERE id = '123' (capture old values)
482
- // 2. Execute DELETE
483
- // 3. Record audit with operation='DELETE', oldValues
484
- ```
485
-
486
- ### Supported Methods
487
-
488
- ```typescript
489
- class AuditableKysely<DB> {
490
- // Wrapped (audited)
491
- insertInto<T>(table: T): AuditableInsertQueryBuilder;
492
- updateTable<T>(table: T): AuditableUpdateQueryBuilder;
493
- deleteFrom<T>(table: T): AuditableDeleteQueryBuilder;
494
-
495
- // Pass-through (not audited)
496
- selectFrom<T>(table: T): SelectQueryBuilder;
497
- with(...): WithBuilder;
498
-
499
- // Access raw db for edge cases
500
- get raw(): Kysely<DB> | Transaction<DB>;
501
- }
502
- ```
503
-
504
- ## Future Provider Wrappers
505
-
506
- ### Objection.js (Future)
507
-
508
- ```typescript
509
- import { AuditableObjection } from '@geekmidas/audit/objection';
510
-
511
- const User = new AuditableObjection(UserModel, auditor);
512
-
513
- await User.query()
514
- .patchAndFetchById(params.id, body);
515
- ```
516
-
517
- ### Knex (Future)
518
-
519
- ```typescript
520
- import { AuditableKnex } from '@geekmidas/audit/knex';
521
-
522
- const db = new AuditableKnex(knex, auditor);
523
-
524
- await db('users')
525
- .where('id', params.id)
526
- .update(body);
527
- ```
528
-
529
- ### MongoDB (Future)
530
-
531
- ```typescript
532
- import { AuditableMongo } from '@geekmidas/audit/mongo';
533
-
534
- const db = new AuditableMongo(mongoClient, auditor);
535
-
536
- await db.collection('users')
537
- .findOneAndUpdate(
538
- { _id: params.id },
539
- { $set: body },
540
- { returnDocument: 'after' }
541
- );
542
- ```
543
-
544
- ## Storage Implementations
545
-
546
- ### Same Database Storage (Recommended)
547
-
548
- Store audits in the same database for transactional consistency:
549
-
550
- ```typescript
551
- const auditStorageService = {
552
- serviceName: 'auditStorage' as const,
553
- async register(envParser) {
554
- return {
555
- async write(records, trx) {
556
- if (records.length === 0) return;
557
-
558
- // Use transaction if provided
559
- const db = trx ?? getDatabase();
560
-
561
- await db
562
- .insertInto('audit_logs')
563
- .values(records.map(r => ({
564
- id: r.id,
565
- type: r.type,
566
- operation: r.operation,
567
- table_name: r.table,
568
- entity_id: JSON.stringify(r.entityId),
569
- old_values: r.oldValues ? JSON.stringify(r.oldValues) : null,
570
- new_values: r.newValues ? JSON.stringify(r.newValues) : null,
571
- payload: r.payload ? JSON.stringify(r.payload) : null,
572
- actor_id: r.actor?.id,
573
- actor_type: r.actor?.type,
574
- metadata: r.metadata ? JSON.stringify(r.metadata) : null,
575
- created_at: r.timestamp,
576
- })))
577
- .execute();
578
- },
579
- } satisfies AuditStorage;
580
- },
581
- } satisfies Service<'auditStorage', AuditStorage>;
582
- ```
583
-
584
- ### External Audit Service
585
-
586
- For compliance or separate audit systems:
587
-
588
- ```typescript
589
- const externalAuditService = {
590
- serviceName: 'auditStorage' as const,
591
- async register(envParser) {
592
- const config = envParser.create((get) => ({
593
- auditServiceUrl: get('AUDIT_SERVICE_URL').string(),
594
- apiKey: get('AUDIT_API_KEY').string(),
595
- })).parse();
596
-
597
- return {
598
- async write(records, _trx) {
599
- // Note: External writes can't participate in the transaction
600
- // Consider using outbox pattern for guaranteed delivery
601
- await fetch(`${config.auditServiceUrl}/audits`, {
602
- method: 'POST',
603
- headers: {
604
- 'Content-Type': 'application/json',
605
- 'Authorization': `Bearer ${config.apiKey}`,
606
- },
607
- body: JSON.stringify({ records }),
608
- });
609
- },
610
- } satisfies AuditStorage;
611
- },
612
- };
613
- ```
614
-
615
- ## Custom Auditor Implementations
616
-
617
- The `Auditor` interface is generic, allowing custom implementations:
618
-
619
- ### Filtered Auditor
620
-
621
- Only audit certain operations:
622
-
623
- ```typescript
624
- class FilteredAuditor implements Auditor {
625
- constructor(
626
- private readonly inner: Auditor,
627
- private readonly filter: (record: AuditRecord) => boolean
628
- ) {}
629
-
630
- get actor(): AuditActor {
631
- return this.inner.actor;
632
- }
633
-
634
- audit<T>(type: string, payload: T, options?: AuditOptions): void {
635
- this.inner.audit(type, payload, options);
636
- }
637
-
638
- record(record: Omit<AuditRecord, 'id' | 'timestamp' | 'actor'>): void {
639
- this.inner.record(record);
640
- }
641
-
642
- getRecords(): AuditRecord[] {
643
- return this.inner.getRecords().filter(this.filter);
644
- }
645
-
646
- async flush(trx?: unknown): Promise<void> {
647
- // Only flush filtered records
648
- // ...
649
- }
650
- }
651
- ```
652
-
653
- ### Enriched Auditor
654
-
655
- Add extra context to all audits:
656
-
657
- ```typescript
658
- class EnrichedAuditor implements Auditor {
659
- constructor(
660
- private readonly inner: Auditor,
661
- private readonly enrichment: Record<string, unknown>
662
- ) {}
663
-
664
- get actor(): AuditActor {
665
- return this.inner.actor;
666
- }
667
-
668
- audit<T>(type: string, payload: T, options?: AuditOptions): void {
669
- this.inner.audit(type, { ...payload, ...this.enrichment }, options);
670
- }
671
-
672
- // ... delegate other methods
673
- }
674
- ```
675
-
676
- ## Transaction Integration
677
-
678
- ### Key Principle
679
-
680
- Audits are flushed **inside** the transaction, ensuring atomicity:
681
-
682
- ```typescript
683
- // In HonoEndpointAdaptor
684
- const response = await endpoint.handler({ services, auditor }, responseBuilder);
685
-
686
- // Process declarative audits
687
- for (const audit of endpoint.audits) {
688
- if (!audit.when || audit.when(response)) {
689
- auditor.audit(audit.type, audit.payload(response), {
690
- entityId: audit.entityId?.(response),
691
- table: audit.table
692
- });
693
- }
694
- }
695
-
696
- // CRITICAL: Flush inside transaction
697
- await auditor.flush(transaction);
698
- // If flush fails → entire transaction rolls back
699
- // If mutation fails → audits never written
700
- ```
701
-
702
- ### Explicit Transaction Handling
703
-
704
- When you manage transactions explicitly:
705
-
706
- ```typescript
707
- const transferFunds = e
708
- .post('/transfers')
709
- .services([databaseService])
710
- .auditor(auditStorageService)
711
- .handle(async ({ body, services, auditor }) => {
712
- return await withTransaction(services.database.raw, async (trx) => {
713
- // Wrap transaction with auditable layer
714
- const db = new AuditableKysely(trx, auditor);
715
-
716
- // Debit
717
- await db
718
- .updateTable('accounts')
719
- .set({ balance: sql`balance - ${body.amount}` })
720
- .where('id', '=', body.fromAccountId)
721
- .execute();
722
-
723
- // Credit
724
- await db
725
- .updateTable('accounts')
726
- .set({ balance: sql`balance + ${body.amount}` })
727
- .where('id', '=', body.toAccountId)
728
- .execute();
729
-
730
- // Manual audit for the transfer as a whole
731
- auditor.audit('transfer.completed', {
732
- fromAccountId: body.fromAccountId,
733
- toAccountId: body.toAccountId,
734
- amount: body.amount,
735
- });
736
-
737
- // Flush audits INSIDE the transaction
738
- await auditor.flush(trx);
739
-
740
- return { success: true };
741
- });
742
- });
743
- ```
744
-
745
- ## Endpoint Integration
746
-
747
- ### EndpointBuilder Methods
748
-
749
- The audit builder pattern mirrors `.publisherService()` and `.events()`:
750
-
751
- ```typescript
752
- // Pattern comparison:
753
- // Events: .publisherService(service).events([...])
754
- // Audits: .auditor(service).audit([...])
755
-
756
- // Add auditor service (just the service, no actor extractor)
757
- .auditor(auditStorageService)
758
-
759
- // Optional: Set actor extractor separately
760
- .actor(({ session, header }) => ({
761
- id: session?.userId,
762
- type: session ? 'user' : 'anonymous',
763
- ip: header('x-forwarded-for'),
764
- }))
765
-
766
- // Add declarative audits (array, like .events())
767
- .audit<AppAuditAction>([
768
- {
769
- type: 'user.created',
770
- payload: (response) => ({ userId: response.id, email: response.email }),
771
- when: (response) => response.active,
772
- entityId: (response) => response.id,
773
- table: 'users',
774
- },
775
- ])
776
- ```
777
-
778
- ### MappedAudit Type
779
-
780
- Similar to `MappedEvent`, defines how to map response to audit:
781
-
782
- ```typescript
783
- interface MappedAudit<
784
- TAuditAction extends AuditableAction<string, unknown>,
785
- TOutput extends StandardSchemaV1,
786
- > {
787
- type: ExtractAuditType<TAuditAction>;
788
- payload: (
789
- response: InferStandardSchema<TOutput>,
790
- ctx: EndpointContext,
791
- ) => ExtractAuditPayload<TAuditAction, typeof type>;
792
- when?: (response: InferStandardSchema<TOutput>) => boolean;
793
- entityId?: (response: InferStandardSchema<TOutput>) => string | Record<string, unknown>;
794
- table?: string;
795
- }
796
- ```
797
-
798
- ### Actor Extractor Type
799
-
800
- The actor extractor is **optional** and set via a separate `.actor()` method:
801
-
802
- ```typescript
803
- type ActorExtractor<TServices, TSession, TLogger> = (ctx: {
804
- services: ServiceRecord<TServices>;
805
- session: TSession;
806
- header: HeaderFn;
807
- cookie: CookieFn;
808
- logger: TLogger;
809
- }) => AuditActor | Promise<AuditActor>;
810
-
811
- // Usage - actor is optional, defaults to empty actor if not set
812
- .auditor(auditStorageService)
813
- .actor(({ session, header }) => ({
814
- id: session.userId,
815
- type: 'user',
816
- ip: header('x-forwarded-for'),
817
- }))
818
- ```
819
-
820
- ### EndpointContext
821
-
822
- When `.auditor()` is called, `auditor` is **guaranteed** to exist (not optional):
823
-
824
- ```typescript
825
- // Without .auditor() - auditor is undefined
826
- .handle(async ({ services }) => {
827
- // auditor not available
828
- });
829
-
830
- // With .auditor() - auditor is guaranteed and typed
831
- .handle(async ({ services, auditor }) => {
832
- // auditor: Auditor<AppAuditAction> (not optional!)
833
-
834
- // ✅ Type-safe - payload inferred from 'user.created'
835
- auditor.audit('user.created', { userId: '123', email: 'test@example.com' });
836
-
837
- // ❌ Type error - wrong payload
838
- auditor.audit('user.created', { orderId: '123' });
839
-
840
- // ❌ Type error - unknown audit type
841
- auditor.audit('unknown.type', {});
842
- });
843
- ```
844
-
845
- ## Performance Considerations
846
-
847
- ### Extra SELECT for Old Values
848
-
849
- UPDATE/DELETE require fetching old values:
850
-
851
- ```
852
- UPDATE users SET name = 'New' WHERE id = '123'
853
-
854
- Becomes:
855
- 1. SELECT * FROM users WHERE id = '123' ← Extra query
856
- 2. UPDATE users SET name = 'New' WHERE id = '123'
857
- 3. SELECT * FROM users WHERE id = '123' ← For new values (or use RETURNING)
858
- ```
859
-
860
- **Mitigations:**
861
- 1. **Use RETURNING**: Avoid extra SELECT for new values
862
- 2. **Exclude high-volume tables**: `excludeTables: ['logs', 'metrics']`
863
- 3. **Index WHERE columns**: Ensure efficient lookups
864
- 4. **Use declarative audits**: For simple cases, skip auto-capture
865
-
866
- ### Memory Usage
867
-
868
- Records held in memory until flush:
869
-
870
- ```typescript
871
- // 1000 inserts = ~1000 AuditRecord objects
872
- // Each ~200-500 bytes
873
- // Total: ~200KB-500KB per request
874
-
875
- // For bulk operations, consider:
876
- // 1. Periodic flushing during operation
877
- // 2. Batch-level auditing instead of row-level
878
- ```
879
-
880
- ## Package Exports
881
-
882
- ```typescript
883
- // Core (database-agnostic)
884
- import {
885
- Auditor,
886
- DefaultAuditor,
887
- AuditRecord,
888
- AuditStorage,
889
- AuditActor,
890
- AuditMetadata,
891
- } from '@geekmidas/audit';
892
-
893
- // Kysely provider
894
- import { AuditableKysely } from '@geekmidas/audit/kysely';
895
-
896
- // Future providers
897
- import { AuditableObjection } from '@geekmidas/audit/objection';
898
- import { AuditableKnex } from '@geekmidas/audit/knex';
899
- import { AuditableMongo } from '@geekmidas/audit/mongo';
900
- ```
901
-
902
- ## File Structure
903
-
904
- ```
905
- packages/audit/
906
- ├── src/
907
- │ ├── index.ts # Core exports
908
- │ ├── types.ts # AuditRecord, AuditActor, etc.
909
- │ ├── Auditor.ts # Auditor interface
910
- │ ├── DefaultAuditor.ts # Default implementation
911
- │ ├── storage.ts # AuditStorage interface
912
- │ ├── kysely/ # Kysely provider
913
- │ │ ├── index.ts
914
- │ │ ├── AuditableKysely.ts
915
- │ │ └── builders/
916
- │ │ ├── AuditableInsertBuilder.ts
917
- │ │ ├── AuditableUpdateBuilder.ts
918
- │ │ └── AuditableDeleteBuilder.ts
919
- │ └── __tests__/
920
- ├── package.json # With subpath exports
921
- ├── tsconfig.json
922
- └── TECHNICAL.md # This file
923
- ```
924
-
925
- ## Summary
926
-
927
- The `@geekmidas/audit` package provides:
928
-
929
- 1. **Transaction-safe auditing** - Audits commit/rollback with mutations
930
- 2. **Database-agnostic core** - Provider wrappers are separate subpath imports
931
- 3. **Three audit methods** - Manual (context), declarative (builder), automatic (wrapper)
932
- 4. **Type-safe audit types** - `AuditableAction<TType, TPayload>` like `PublishableMessage`
933
- 5. **Consistent API pattern** - `.auditor()` + `.audit()` mirrors `.publisherService()` + `.events()`
934
- 6. **Optional actor extraction** - Separate `.actor()` method for flexibility
935
- 7. **Full change tracking** - Old values, new values, actor, metadata
936
- 8. **Pluggable storage** - Same DB, external service, or custom
937
- 9. **Generic Auditor interface** - Extensible for custom implementations