convex-audit-log 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 (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +408 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +336 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +297 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +36 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +317 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +3 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/lib.d.ts +341 -0
  32. package/dist/component/lib.d.ts.map +1 -0
  33. package/dist/component/lib.js +598 -0
  34. package/dist/component/lib.js.map +1 -0
  35. package/dist/component/schema.d.ts +87 -0
  36. package/dist/component/schema.d.ts.map +1 -0
  37. package/dist/component/schema.js +71 -0
  38. package/dist/component/schema.js.map +1 -0
  39. package/dist/component/shared.d.ts +203 -0
  40. package/dist/component/shared.d.ts.map +1 -0
  41. package/dist/component/shared.js +94 -0
  42. package/dist/component/shared.js.map +1 -0
  43. package/dist/react/index.d.ts +247 -0
  44. package/dist/react/index.d.ts.map +1 -0
  45. package/dist/react/index.js +196 -0
  46. package/dist/react/index.js.map +1 -0
  47. package/package.json +115 -0
  48. package/src/client/_generated/_ignore.ts +1 -0
  49. package/src/client/index.test.ts +61 -0
  50. package/src/client/index.ts +525 -0
  51. package/src/client/setup.test.ts +26 -0
  52. package/src/component/_generated/api.ts +52 -0
  53. package/src/component/_generated/component.ts +392 -0
  54. package/src/component/_generated/dataModel.ts +60 -0
  55. package/src/component/_generated/server.ts +161 -0
  56. package/src/component/convex.config.ts +3 -0
  57. package/src/component/lib.test.ts +171 -0
  58. package/src/component/lib.ts +722 -0
  59. package/src/component/schema.ts +93 -0
  60. package/src/component/setup.test.ts +11 -0
  61. package/src/component/shared.ts +167 -0
  62. package/src/react/index.ts +305 -0
  63. package/src/test.ts +18 -0
@@ -0,0 +1,722 @@
1
+ import { v } from "convex/values";
2
+ import {
3
+ mutation,
4
+ query,
5
+ internalMutation,
6
+ internalQuery,
7
+ } from "./_generated/server.js";
8
+ import { internal } from "./_generated/api.js";
9
+ import schema from "./schema.js";
10
+ import {
11
+ vSeverity,
12
+ vAuditEventInput,
13
+ vChangeEventInput,
14
+ vQueryFilters,
15
+ vPagination,
16
+ vCleanupOptions,
17
+ vConfigOptions,
18
+ type Severity,
19
+ } from "./shared.js";
20
+
21
+ const auditLogValidator = schema.tables.auditLogs.validator.extend({
22
+ _id: v.id("auditLogs"),
23
+ _creationTime: v.number(),
24
+ });
25
+
26
+ /**
27
+ * Log a single audit event.
28
+ */
29
+ export const log = mutation({
30
+ args: vAuditEventInput.fields,
31
+ returns: v.id("auditLogs"),
32
+ handler: async (ctx, args) => {
33
+ const timestamp = Date.now();
34
+
35
+ const logId = await ctx.db.insert("auditLogs", {
36
+ ...args,
37
+ timestamp,
38
+ });
39
+
40
+ return logId;
41
+ },
42
+ });
43
+
44
+ /**
45
+ * Log a change event with before/after states and optional diff generation.
46
+ */
47
+ export const logChange = mutation({
48
+ args: vChangeEventInput.fields,
49
+ returns: v.id("auditLogs"),
50
+ handler: async (ctx, args) => {
51
+ const timestamp = Date.now();
52
+ let diff: string | undefined;
53
+
54
+ if (args.generateDiff && args.before && args.after) {
55
+ diff = generateDiff(args.before, args.after);
56
+ }
57
+
58
+ const logId = await ctx.db.insert("auditLogs", {
59
+ action: args.action,
60
+ actorId: args.actorId,
61
+ resourceType: args.resourceType,
62
+ resourceId: args.resourceId,
63
+ before: args.before,
64
+ after: args.after,
65
+ diff,
66
+ severity: args.severity ?? "info",
67
+ ipAddress: args.ipAddress,
68
+ userAgent: args.userAgent,
69
+ sessionId: args.sessionId,
70
+ tags: args.tags,
71
+ retentionCategory: args.retentionCategory,
72
+ timestamp,
73
+ });
74
+
75
+ return logId;
76
+ },
77
+ });
78
+
79
+ /**
80
+ * Log multiple audit events in a single transaction.
81
+ */
82
+ export const logBulk = mutation({
83
+ args: {
84
+ events: v.array(vAuditEventInput),
85
+ },
86
+ returns: v.array(v.id("auditLogs")),
87
+ handler: async (ctx, args) => {
88
+ const timestamp = Date.now();
89
+ const ids: string[] = [];
90
+
91
+ for (const event of args.events) {
92
+ const logId = await ctx.db.insert("auditLogs", {
93
+ ...event,
94
+ timestamp,
95
+ });
96
+ ids.push(logId);
97
+ }
98
+
99
+ return ids as any;
100
+ },
101
+ });
102
+
103
+ /**
104
+ * Query audit logs by resource.
105
+ */
106
+ export const queryByResource = query({
107
+ args: {
108
+ resourceType: v.string(),
109
+ resourceId: v.string(),
110
+ limit: v.optional(v.number()),
111
+ fromTimestamp: v.optional(v.number()),
112
+ },
113
+ returns: v.array(auditLogValidator),
114
+ handler: async (ctx, args) => {
115
+ let q = ctx.db
116
+ .query("auditLogs")
117
+ .withIndex("by_resource", (q) =>
118
+ q.eq("resourceType", args.resourceType).eq("resourceId", args.resourceId)
119
+ )
120
+ .order("desc");
121
+
122
+ if (args.fromTimestamp) {
123
+ q = ctx.db
124
+ .query("auditLogs")
125
+ .withIndex("by_resource", (q) =>
126
+ q
127
+ .eq("resourceType", args.resourceType)
128
+ .eq("resourceId", args.resourceId)
129
+ .gte("timestamp", args.fromTimestamp!)
130
+ )
131
+ .order("desc");
132
+ }
133
+
134
+ return await q.take(args.limit ?? 50);
135
+ },
136
+ });
137
+
138
+ /**
139
+ * Query audit logs by actor (user).
140
+ */
141
+ export const queryByActor = query({
142
+ args: {
143
+ actorId: v.string(),
144
+ limit: v.optional(v.number()),
145
+ fromTimestamp: v.optional(v.number()),
146
+ actions: v.optional(v.array(v.string())),
147
+ },
148
+ returns: v.array(auditLogValidator),
149
+ handler: async (ctx, args) => {
150
+ let results = await ctx.db
151
+ .query("auditLogs")
152
+ .withIndex("by_actor_timestamp", (q) => q.eq("actorId", args.actorId))
153
+ .order("desc")
154
+ .take(args.limit ?? 50);
155
+
156
+ if (args.fromTimestamp) {
157
+ results = results.filter((log) => log.timestamp >= args.fromTimestamp!);
158
+ }
159
+
160
+ if (args.actions && args.actions.length > 0) {
161
+ results = results.filter((log) => args.actions!.includes(log.action));
162
+ }
163
+
164
+ return results;
165
+ },
166
+ });
167
+
168
+ /**
169
+ * Query audit logs by severity level.
170
+ */
171
+ export const queryBySeverity = query({
172
+ args: {
173
+ severity: v.array(vSeverity),
174
+ limit: v.optional(v.number()),
175
+ fromTimestamp: v.optional(v.number()),
176
+ },
177
+ returns: v.array(auditLogValidator),
178
+ handler: async (ctx, args) => {
179
+ const allResults = [];
180
+
181
+ for (const sev of args.severity) {
182
+ const results = await ctx.db
183
+ .query("auditLogs")
184
+ .withIndex("by_severity_timestamp", (q) => q.eq("severity", sev))
185
+ .order("desc")
186
+ .take(args.limit ?? 50);
187
+
188
+ allResults.push(...results);
189
+ }
190
+
191
+ return allResults
192
+ .filter((log) =>
193
+ args.fromTimestamp ? log.timestamp >= args.fromTimestamp : true
194
+ )
195
+ .sort((a, b) => b.timestamp - a.timestamp)
196
+ .slice(0, args.limit ?? 50);
197
+ },
198
+ });
199
+
200
+ /**
201
+ * Query audit logs by action type.
202
+ */
203
+ export const queryByAction = query({
204
+ args: {
205
+ action: v.string(),
206
+ limit: v.optional(v.number()),
207
+ fromTimestamp: v.optional(v.number()),
208
+ },
209
+ returns: v.array(auditLogValidator),
210
+ handler: async (ctx, args) => {
211
+ let results = await ctx.db
212
+ .query("auditLogs")
213
+ .withIndex("by_action_timestamp", (q) => q.eq("action", args.action))
214
+ .order("desc")
215
+ .take(args.limit ?? 50);
216
+
217
+ if (args.fromTimestamp) {
218
+ results = results.filter((log) => log.timestamp >= args.fromTimestamp!);
219
+ }
220
+
221
+ return results;
222
+ },
223
+ });
224
+
225
+ /**
226
+ * Advanced search with multiple filters.
227
+ */
228
+ export const search = query({
229
+ args: {
230
+ filters: vQueryFilters,
231
+ pagination: vPagination,
232
+ },
233
+ returns: v.object({
234
+ items: v.array(auditLogValidator),
235
+ cursor: v.union(v.string(), v.null()),
236
+ hasMore: v.boolean(),
237
+ }),
238
+ handler: async (ctx, args) => {
239
+ const { filters, pagination } = args;
240
+ const limit = pagination.limit;
241
+
242
+ let results = await ctx.db
243
+ .query("auditLogs")
244
+ .withIndex("by_timestamp")
245
+ .order("desc")
246
+ .take(limit * 10);
247
+
248
+ if (filters.fromTimestamp) {
249
+ results = results.filter((log) => log.timestamp >= filters.fromTimestamp!);
250
+ }
251
+ if (filters.toTimestamp) {
252
+ results = results.filter((log) => log.timestamp <= filters.toTimestamp!);
253
+ }
254
+ if (filters.severity && filters.severity.length > 0) {
255
+ results = results.filter((log) =>
256
+ filters.severity!.includes(log.severity)
257
+ );
258
+ }
259
+ if (filters.actions && filters.actions.length > 0) {
260
+ results = results.filter((log) => filters.actions!.includes(log.action));
261
+ }
262
+ if (filters.resourceTypes && filters.resourceTypes.length > 0) {
263
+ results = results.filter(
264
+ (log) =>
265
+ log.resourceType && filters.resourceTypes!.includes(log.resourceType)
266
+ );
267
+ }
268
+ if (filters.actorIds && filters.actorIds.length > 0) {
269
+ results = results.filter(
270
+ (log) => log.actorId && filters.actorIds!.includes(log.actorId)
271
+ );
272
+ }
273
+ if (filters.tags && filters.tags.length > 0) {
274
+ results = results.filter(
275
+ (log) =>
276
+ log.tags && filters.tags!.some((tag) => log.tags!.includes(tag))
277
+ );
278
+ }
279
+
280
+ // Apply cursor-based pagination
281
+ let startIndex = 0;
282
+ if (pagination.cursor) {
283
+ const cursorIndex = results.findIndex(
284
+ (log) => log._id === pagination.cursor
285
+ );
286
+ if (cursorIndex !== -1) {
287
+ startIndex = cursorIndex + 1;
288
+ }
289
+ }
290
+
291
+ const paginatedResults = results.slice(startIndex, startIndex + limit);
292
+ const hasMore = startIndex + limit < results.length;
293
+ const cursor = paginatedResults.length > 0
294
+ ? paginatedResults[paginatedResults.length - 1]._id
295
+ : null;
296
+
297
+ return {
298
+ items: paginatedResults,
299
+ cursor,
300
+ hasMore,
301
+ };
302
+ },
303
+ });
304
+
305
+ /**
306
+ * Watch for critical events (real-time subscription).
307
+ */
308
+ export const watchCritical = query({
309
+ args: {
310
+ severity: v.optional(v.array(vSeverity)),
311
+ limit: v.optional(v.number()),
312
+ },
313
+ returns: v.array(auditLogValidator),
314
+ handler: async (ctx, args) => {
315
+ const severityLevels = args.severity ?? ["error", "critical"];
316
+ const limit = args.limit ?? 20;
317
+
318
+ const allResults = [];
319
+
320
+ for (const sev of severityLevels) {
321
+ const results = await ctx.db
322
+ .query("auditLogs")
323
+ .withIndex("by_severity_timestamp", (q) => q.eq("severity", sev as Severity))
324
+ .order("desc")
325
+ .take(limit);
326
+
327
+ allResults.push(...results);
328
+ }
329
+
330
+ return allResults
331
+ .sort((a, b) => b.timestamp - a.timestamp)
332
+ .slice(0, limit);
333
+ },
334
+ });
335
+
336
+ /**
337
+ * Get a single audit log by ID.
338
+ */
339
+ export const get = query({
340
+ args: {
341
+ id: v.string(),
342
+ },
343
+ returns: v.union(v.null(), auditLogValidator),
344
+ handler: async (ctx, args) => {
345
+ try {
346
+ const result = await ctx.db.get(args.id as any);
347
+ // Only return if it's an audit log (not a config document)
348
+ if (result && "action" in result) {
349
+ return result;
350
+ }
351
+ return null;
352
+ } catch {
353
+ return null;
354
+ }
355
+ },
356
+ });
357
+
358
+ /**
359
+ * Clean up old audit logs based on retention policies.
360
+ */
361
+ export const cleanup = mutation({
362
+ args: vCleanupOptions.fields,
363
+ returns: v.number(),
364
+ handler: async (ctx, args) => {
365
+ const batchSize = args.batchSize ?? 100;
366
+ const olderThanDays = args.olderThanDays ?? 90;
367
+ const cutoffTimestamp = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
368
+ const preserveSeverity = args.preserveSeverity ?? [];
369
+
370
+ // Get logs older than cutoff
371
+ const oldLogs = await ctx.db
372
+ .query("auditLogs")
373
+ .withIndex("by_timestamp", (q) => q.lt("timestamp", cutoffTimestamp))
374
+ .take(batchSize);
375
+
376
+ let deletedCount = 0;
377
+
378
+ for (const log of oldLogs) {
379
+ // Skip preserved severity levels
380
+ if (preserveSeverity.includes(log.severity)) {
381
+ continue;
382
+ }
383
+
384
+ // Skip specific retention categories if specified
385
+ if (args.retentionCategory && log.retentionCategory !== args.retentionCategory) {
386
+ continue;
387
+ }
388
+
389
+ await ctx.db.delete(log._id);
390
+ deletedCount++;
391
+ }
392
+
393
+ return deletedCount;
394
+ },
395
+ });
396
+
397
+ /**
398
+ * Get current configuration.
399
+ */
400
+ export const getConfig = query({
401
+ args: {},
402
+ returns: v.union(
403
+ v.null(),
404
+ v.object({
405
+ _id: v.id("config"),
406
+ _creationTime: v.number(),
407
+ defaultRetentionDays: v.number(),
408
+ criticalRetentionDays: v.number(),
409
+ piiFieldsToRedact: v.array(v.string()),
410
+ samplingEnabled: v.boolean(),
411
+ samplingRate: v.number(),
412
+ customRetention: v.optional(
413
+ v.array(
414
+ v.object({
415
+ category: v.string(),
416
+ retentionDays: v.number(),
417
+ })
418
+ )
419
+ ),
420
+ })
421
+ ),
422
+ handler: async (ctx) => {
423
+ return await ctx.db.query("config").first();
424
+ },
425
+ });
426
+
427
+ /**
428
+ * Update configuration.
429
+ */
430
+ export const updateConfig = mutation({
431
+ args: vConfigOptions.fields,
432
+ returns: v.id("config"),
433
+ handler: async (ctx, args) => {
434
+ const existing = await ctx.db.query("config").first();
435
+
436
+ if (existing) {
437
+ await ctx.db.patch(existing._id, {
438
+ ...(args.defaultRetentionDays !== undefined && {
439
+ defaultRetentionDays: args.defaultRetentionDays,
440
+ }),
441
+ ...(args.criticalRetentionDays !== undefined && {
442
+ criticalRetentionDays: args.criticalRetentionDays,
443
+ }),
444
+ ...(args.piiFieldsToRedact !== undefined && {
445
+ piiFieldsToRedact: args.piiFieldsToRedact,
446
+ }),
447
+ ...(args.samplingEnabled !== undefined && {
448
+ samplingEnabled: args.samplingEnabled,
449
+ }),
450
+ ...(args.samplingRate !== undefined && {
451
+ samplingRate: args.samplingRate,
452
+ }),
453
+ ...(args.customRetention !== undefined && {
454
+ customRetention: args.customRetention,
455
+ }),
456
+ });
457
+ return existing._id;
458
+ }
459
+
460
+ // Create new config with defaults
461
+ return await ctx.db.insert("config", {
462
+ defaultRetentionDays: args.defaultRetentionDays ?? 90,
463
+ criticalRetentionDays: args.criticalRetentionDays ?? 365,
464
+ piiFieldsToRedact: args.piiFieldsToRedact ?? [],
465
+ samplingEnabled: args.samplingEnabled ?? false,
466
+ samplingRate: args.samplingRate ?? 1.0,
467
+ customRetention: args.customRetention,
468
+ });
469
+ },
470
+ });
471
+
472
+ /**
473
+ * Detect anomalies based on event frequency patterns.
474
+ */
475
+ export const detectAnomalies = query({
476
+ args: {
477
+ patterns: v.array(
478
+ v.object({
479
+ action: v.string(),
480
+ threshold: v.number(),
481
+ windowMinutes: v.number(),
482
+ })
483
+ ),
484
+ },
485
+ returns: v.array(
486
+ v.object({
487
+ action: v.string(),
488
+ count: v.number(),
489
+ threshold: v.number(),
490
+ windowMinutes: v.number(),
491
+ detectedAt: v.number(),
492
+ })
493
+ ),
494
+ handler: async (ctx, args) => {
495
+ const anomalies = [];
496
+ const now = Date.now();
497
+
498
+ for (const pattern of args.patterns) {
499
+ const windowStart = now - pattern.windowMinutes * 60 * 1000;
500
+
501
+ const events = await ctx.db
502
+ .query("auditLogs")
503
+ .withIndex("by_action_timestamp", (q) =>
504
+ q.eq("action", pattern.action).gte("timestamp", windowStart)
505
+ )
506
+ .collect();
507
+
508
+ if (events.length >= pattern.threshold) {
509
+ anomalies.push({
510
+ action: pattern.action,
511
+ count: events.length,
512
+ threshold: pattern.threshold,
513
+ windowMinutes: pattern.windowMinutes,
514
+ detectedAt: now,
515
+ });
516
+ }
517
+ }
518
+
519
+ return anomalies;
520
+ },
521
+ });
522
+
523
+ /**
524
+ * Generate a report of audit logs.
525
+ */
526
+ export const generateReport = query({
527
+ args: {
528
+ startDate: v.number(),
529
+ endDate: v.number(),
530
+ format: v.union(v.literal("json"), v.literal("csv")),
531
+ includeFields: v.optional(v.array(v.string())),
532
+ groupBy: v.optional(v.string()),
533
+ },
534
+ returns: v.object({
535
+ format: v.union(v.literal("json"), v.literal("csv")),
536
+ data: v.string(),
537
+ generatedAt: v.number(),
538
+ recordCount: v.number(),
539
+ }),
540
+ handler: async (ctx, args) => {
541
+ const logs = await ctx.db
542
+ .query("auditLogs")
543
+ .withIndex("by_timestamp", (q) =>
544
+ q.gte("timestamp", args.startDate).lte("timestamp", args.endDate)
545
+ )
546
+ .collect();
547
+
548
+ const includeFields = args.includeFields ?? [
549
+ "timestamp",
550
+ "action",
551
+ "actorId",
552
+ "resourceType",
553
+ "resourceId",
554
+ "severity",
555
+ ];
556
+
557
+ const filteredLogs = logs.map((log) => {
558
+ const filtered: Record<string, unknown> = {};
559
+ for (const field of includeFields) {
560
+ if (field in log) {
561
+ filtered[field] = (log as Record<string, unknown>)[field];
562
+ }
563
+ }
564
+ return filtered;
565
+ });
566
+
567
+ let data: string;
568
+
569
+ if (args.format === "csv") {
570
+ // Generate CSV
571
+ const headers = includeFields.join(",");
572
+ const rows = filteredLogs.map((log) =>
573
+ includeFields
574
+ .map((field) => {
575
+ const value = log[field];
576
+ if (value === undefined || value === null) return "";
577
+ if (typeof value === "string") return `"${value.replace(/"/g, '""')}"`;
578
+ return String(value);
579
+ })
580
+ .join(",")
581
+ );
582
+ data = [headers, ...rows].join("\n");
583
+ } else {
584
+ // Generate JSON
585
+ if (args.groupBy && includeFields.includes(args.groupBy)) {
586
+ // Group by specified field
587
+ const grouped: Record<string, typeof filteredLogs> = {};
588
+ for (const log of filteredLogs) {
589
+ const key = String(log[args.groupBy] ?? "unknown");
590
+ if (!grouped[key]) grouped[key] = [];
591
+ grouped[key].push(log);
592
+ }
593
+ data = JSON.stringify(grouped, null, 2);
594
+ } else {
595
+ data = JSON.stringify(filteredLogs, null, 2);
596
+ }
597
+ }
598
+
599
+ return {
600
+ format: args.format,
601
+ data,
602
+ generatedAt: Date.now(),
603
+ recordCount: filteredLogs.length,
604
+ };
605
+ },
606
+ });
607
+
608
+ /**
609
+ * Get statistics for audit logs.
610
+ */
611
+ export const getStats = query({
612
+ args: {
613
+ fromTimestamp: v.optional(v.number()),
614
+ toTimestamp: v.optional(v.number()),
615
+ },
616
+ returns: v.object({
617
+ totalCount: v.number(),
618
+ bySeverity: v.object({
619
+ info: v.number(),
620
+ warning: v.number(),
621
+ error: v.number(),
622
+ critical: v.number(),
623
+ }),
624
+ topActions: v.array(
625
+ v.object({
626
+ action: v.string(),
627
+ count: v.number(),
628
+ })
629
+ ),
630
+ topActors: v.array(
631
+ v.object({
632
+ actorId: v.string(),
633
+ count: v.number(),
634
+ })
635
+ ),
636
+ }),
637
+ handler: async (ctx, args) => {
638
+ const fromTimestamp = args.fromTimestamp ?? Date.now() - 24 * 60 * 60 * 1000;
639
+ const toTimestamp = args.toTimestamp ?? Date.now();
640
+
641
+ const logs = await ctx.db
642
+ .query("auditLogs")
643
+ .withIndex("by_timestamp", (q) =>
644
+ q.gte("timestamp", fromTimestamp).lte("timestamp", toTimestamp)
645
+ )
646
+ .collect();
647
+
648
+ // Count by severity
649
+ const bySeverity = {
650
+ info: 0,
651
+ warning: 0,
652
+ error: 0,
653
+ critical: 0,
654
+ };
655
+
656
+ const actionCounts: Record<string, number> = {};
657
+ const actorCounts: Record<string, number> = {};
658
+
659
+ for (const log of logs) {
660
+ bySeverity[log.severity]++;
661
+
662
+ actionCounts[log.action] = (actionCounts[log.action] ?? 0) + 1;
663
+
664
+ if (log.actorId) {
665
+ actorCounts[log.actorId] = (actorCounts[log.actorId] ?? 0) + 1;
666
+ }
667
+ }
668
+
669
+ // Get top 10 actions
670
+ const topActions = Object.entries(actionCounts)
671
+ .sort((a, b) => b[1] - a[1])
672
+ .slice(0, 10)
673
+ .map(([action, count]) => ({ action, count }));
674
+
675
+ // Get top 10 actors
676
+ const topActors = Object.entries(actorCounts)
677
+ .sort((a, b) => b[1] - a[1])
678
+ .slice(0, 10)
679
+ .map(([actorId, count]) => ({ actorId, count }));
680
+
681
+ return {
682
+ totalCount: logs.length,
683
+ bySeverity,
684
+ topActions,
685
+ topActors,
686
+ };
687
+ },
688
+ });
689
+
690
+ /**
691
+ * Generate a simple diff between two objects.
692
+ */
693
+ function generateDiff(before: unknown, after: unknown): string {
694
+ const changes: string[] = [];
695
+
696
+ if (typeof before !== "object" || typeof after !== "object") {
697
+ return `Changed from ${JSON.stringify(before)} to ${JSON.stringify(after)}`;
698
+ }
699
+
700
+ const beforeObj = before as Record<string, unknown>;
701
+ const afterObj = after as Record<string, unknown>;
702
+
703
+ // Check for removed keys
704
+ for (const key of Object.keys(beforeObj)) {
705
+ if (!(key in afterObj)) {
706
+ changes.push(`- ${key}: ${JSON.stringify(beforeObj[key])}`);
707
+ }
708
+ }
709
+
710
+ // Check for added or changed keys
711
+ for (const key of Object.keys(afterObj)) {
712
+ if (!(key in beforeObj)) {
713
+ changes.push(`+ ${key}: ${JSON.stringify(afterObj[key])}`);
714
+ } else if (JSON.stringify(beforeObj[key]) !== JSON.stringify(afterObj[key])) {
715
+ changes.push(
716
+ `~ ${key}: ${JSON.stringify(beforeObj[key])} → ${JSON.stringify(afterObj[key])}`
717
+ );
718
+ }
719
+ }
720
+
721
+ return changes.join("\n");
722
+ }