@littlebearapps/create-platform 1.0.0 → 1.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 (69) hide show
  1. package/README.md +98 -0
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.js +36 -6
  4. package/dist/prompts.d.ts +14 -2
  5. package/dist/prompts.js +29 -7
  6. package/dist/templates.js +78 -0
  7. package/package.json +3 -2
  8. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  9. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  10. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  11. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  12. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  13. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  14. package/templates/full/workers/pattern-discovery.ts +661 -0
  15. package/templates/full/workers/platform-alert-router.ts +1809 -0
  16. package/templates/full/workers/platform-notifications.ts +424 -0
  17. package/templates/full/workers/platform-search.ts +480 -0
  18. package/templates/full/workers/platform-settings.ts +436 -0
  19. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  20. package/templates/shared/workers/lib/billing.ts +293 -0
  21. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  22. package/templates/shared/workers/lib/control.ts +292 -0
  23. package/templates/shared/workers/lib/economics.ts +368 -0
  24. package/templates/shared/workers/lib/metrics.ts +103 -0
  25. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  26. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  27. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  28. package/templates/shared/workers/lib/shared/types.ts +58 -0
  29. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  30. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  31. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  32. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  33. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  34. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  35. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  36. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  37. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  38. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  39. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  40. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  41. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  42. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  43. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  44. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  45. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  46. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  47. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  48. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  49. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  50. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  51. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  52. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  53. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  54. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  55. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  56. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  57. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  58. package/templates/shared/workers/platform-usage.ts +1915 -0
  59. package/templates/standard/workers/error-collector.ts +2670 -0
  60. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  61. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  62. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  63. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  64. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  65. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  66. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  67. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  68. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  69. package/templates/standard/workers/platform-sentinel.ts +1744 -0
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Storage Operations for Pattern Discovery
3
+ *
4
+ * D1 and KV operations for pattern suggestions and audit logs.
5
+ *
6
+ * @module workers/lib/pattern-discovery/storage
7
+ */
8
+
9
+ import type { D1Database, KVNamespace } from '@cloudflare/workers-types';
10
+ import type {
11
+ PatternSuggestion,
12
+ PatternRule,
13
+ AISuggestionResponse,
14
+ AuditAction,
15
+ ErrorCluster,
16
+ } from './types';
17
+ import type { Logger } from '@littlebearapps/platform-sdk';
18
+
19
+ /** KV key prefix for approved dynamic patterns */
20
+ export const DYNAMIC_PATTERNS_KEY = 'PATTERNS:DYNAMIC:APPROVED';
21
+
22
+ /**
23
+ * Generate a unique ID
24
+ */
25
+ function generateId(prefix: string): string {
26
+ const timestamp = Date.now().toString(36);
27
+ const random = Math.random().toString(36).slice(2, 8);
28
+ return `${prefix}-${timestamp}-${random}`;
29
+ }
30
+
31
+ /**
32
+ * Store pattern suggestions from AI response
33
+ */
34
+ export async function storePatternSuggestions(
35
+ db: D1Database,
36
+ cluster: ErrorCluster,
37
+ aiResponse: AISuggestionResponse,
38
+ log: Logger
39
+ ): Promise<string[]> {
40
+ const suggestionIds: string[] = [];
41
+
42
+ for (const pattern of aiResponse.patterns) {
43
+ // Skip low-confidence suggestions
44
+ if (pattern.confidence < 0.5) {
45
+ log.debug('Skipping low-confidence pattern', {
46
+ category: pattern.category,
47
+ confidence: pattern.confidence,
48
+ });
49
+ continue;
50
+ }
51
+
52
+ // Skip if an active pattern with the same type+value already exists.
53
+ // Prevents duplicate suggestions when the same error cluster appears in
54
+ // consecutive cron runs (e.g. duplicate "Slow workflow step" shadow suggestions).
55
+ try {
56
+ const existing = await db
57
+ .prepare(
58
+ `SELECT id, status FROM transient_pattern_suggestions
59
+ WHERE pattern_type = ? AND pattern_value = ?
60
+ AND status IN ('approved', 'shadow', 'pending')
61
+ LIMIT 1`
62
+ )
63
+ .bind(pattern.patternType, pattern.patternValue)
64
+ .first<{ id: string; status: string }>();
65
+
66
+ if (existing) {
67
+ log.info('Skipping duplicate pattern suggestion', {
68
+ existingId: existing.id,
69
+ existingStatus: existing.status,
70
+ patternValue: pattern.patternValue,
71
+ category: pattern.category,
72
+ });
73
+ continue;
74
+ }
75
+ } catch (error) {
76
+ // Non-blocking — proceed with insertion if dedup check fails
77
+ log.warn('Dedup check failed, proceeding with insertion', { error });
78
+ }
79
+
80
+ const id = generateId('suggestion');
81
+
82
+ try {
83
+ await db
84
+ .prepare(
85
+ `
86
+ INSERT INTO transient_pattern_suggestions (
87
+ id, pattern_type, pattern_value, category, scope,
88
+ confidence_score, sample_messages, ai_reasoning, cluster_id,
89
+ status
90
+ ) VALUES (?, ?, ?, ?, 'global', ?, ?, ?, ?, 'pending')
91
+ `
92
+ )
93
+ .bind(
94
+ id,
95
+ pattern.patternType,
96
+ pattern.patternValue,
97
+ pattern.category,
98
+ pattern.confidence,
99
+ JSON.stringify(pattern.positiveExamples),
100
+ pattern.reasoning,
101
+ cluster.id
102
+ )
103
+ .run();
104
+
105
+ // Log audit event
106
+ await logAuditEvent(db, id, 'created', 'ai:deepseek', 'Pattern suggested by AI analysis', {
107
+ confidence: pattern.confidence,
108
+ clusterId: cluster.id,
109
+ clusterOccurrences: cluster.occurrenceCount,
110
+ });
111
+
112
+ suggestionIds.push(id);
113
+
114
+ log.info('Stored pattern suggestion', {
115
+ id,
116
+ patternType: pattern.patternType,
117
+ category: pattern.category,
118
+ confidence: pattern.confidence,
119
+ });
120
+ } catch (error) {
121
+ log.error('Failed to store pattern suggestion', error, {
122
+ category: pattern.category,
123
+ });
124
+ }
125
+ }
126
+
127
+ return suggestionIds;
128
+ }
129
+
130
+ /**
131
+ * Log an audit event
132
+ */
133
+ export async function logAuditEvent(
134
+ db: D1Database,
135
+ patternId: string,
136
+ action: AuditAction,
137
+ actor: string,
138
+ reason: string,
139
+ metadata?: Record<string, unknown>
140
+ ): Promise<void> {
141
+ const id = generateId('audit');
142
+
143
+ await db
144
+ .prepare(
145
+ `
146
+ INSERT INTO pattern_audit_log (id, pattern_id, action, actor, reason, metadata)
147
+ VALUES (?, ?, ?, ?, ?, ?)
148
+ `
149
+ )
150
+ .bind(id, patternId, action, actor, reason, metadata ? JSON.stringify(metadata) : null)
151
+ .run();
152
+ }
153
+
154
+ /**
155
+ * Get pending suggestions for review
156
+ */
157
+ export async function getPendingSuggestions(
158
+ db: D1Database,
159
+ limit: number = 20
160
+ ): Promise<PatternSuggestion[]> {
161
+ const result = await db
162
+ .prepare(
163
+ `
164
+ SELECT
165
+ id, pattern_type as patternType, pattern_value as patternValue,
166
+ category, scope, confidence_score as confidenceScore,
167
+ sample_messages as sampleMessages, ai_reasoning as aiReasoning,
168
+ cluster_id as clusterId, status,
169
+ reviewed_by as reviewedBy, reviewed_at as reviewedAt,
170
+ rejection_reason as rejectionReason,
171
+ backtest_match_count as backtestMatchCount,
172
+ backtest_total_errors as backtestTotalErrors,
173
+ backtest_match_rate as backtestMatchRate,
174
+ shadow_mode_start as shadowModeStart,
175
+ shadow_mode_end as shadowModeEnd,
176
+ shadow_mode_matches as shadowModeMatches,
177
+ shadow_match_days as shadowMatchDays,
178
+ enabled_at as enabledAt, disabled_at as disabledAt,
179
+ last_matched_at as lastMatchedAt, match_count as matchCount,
180
+ is_protected as isProtected, source, original_regex as originalRegex,
181
+ created_at as createdAt, updated_at as updatedAt
182
+ FROM transient_pattern_suggestions
183
+ WHERE status = 'pending'
184
+ ORDER BY confidence_score DESC, created_at ASC
185
+ LIMIT ?
186
+ `
187
+ )
188
+ .bind(limit)
189
+ .all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
190
+
191
+ return result.results.map((r) => ({
192
+ ...r,
193
+ sampleMessages: JSON.parse(r.sampleMessages || '[]'),
194
+ shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
195
+ isProtected: Boolean(r.isProtected),
196
+ }));
197
+ }
198
+
199
+ /**
200
+ * Approve a pattern suggestion
201
+ */
202
+ export async function approveSuggestion(
203
+ db: D1Database,
204
+ kv: KVNamespace,
205
+ suggestionId: string,
206
+ reviewedBy: string,
207
+ log: Logger
208
+ ): Promise<boolean> {
209
+ const now = Math.floor(Date.now() / 1000);
210
+
211
+ try {
212
+ // Update suggestion status
213
+ await db
214
+ .prepare(
215
+ `
216
+ UPDATE transient_pattern_suggestions
217
+ SET status = 'approved', reviewed_by = ?, reviewed_at = ?,
218
+ enabled_at = ?, updated_at = unixepoch()
219
+ WHERE id = ? AND status = 'pending'
220
+ `
221
+ )
222
+ .bind(reviewedBy, now, now, suggestionId)
223
+ .run();
224
+
225
+ // Log audit event
226
+ await logAuditEvent(db, suggestionId, 'approved', `human:${reviewedBy}`, 'Pattern approved');
227
+
228
+ // Refresh KV cache
229
+ await refreshDynamicPatternsCache(db, kv, log);
230
+
231
+ log.info('Approved pattern suggestion', { suggestionId, reviewedBy });
232
+ return true;
233
+ } catch (error) {
234
+ log.error('Failed to approve suggestion', error, { suggestionId });
235
+ return false;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Reject a pattern suggestion
241
+ */
242
+ export async function rejectSuggestion(
243
+ db: D1Database,
244
+ suggestionId: string,
245
+ reviewedBy: string,
246
+ reason: string,
247
+ log: Logger
248
+ ): Promise<boolean> {
249
+ const now = Math.floor(Date.now() / 1000);
250
+
251
+ try {
252
+ await db
253
+ .prepare(
254
+ `
255
+ UPDATE transient_pattern_suggestions
256
+ SET status = 'rejected', reviewed_by = ?, reviewed_at = ?,
257
+ rejection_reason = ?, updated_at = unixepoch()
258
+ WHERE id = ? AND status = 'pending'
259
+ `
260
+ )
261
+ .bind(reviewedBy, now, reason, suggestionId)
262
+ .run();
263
+
264
+ await logAuditEvent(db, suggestionId, 'rejected', `human:${reviewedBy}`, reason);
265
+
266
+ log.info('Rejected pattern suggestion', { suggestionId, reviewedBy, reason });
267
+ return true;
268
+ } catch (error) {
269
+ log.error('Failed to reject suggestion', error, { suggestionId });
270
+ return false;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Disable an approved pattern
276
+ */
277
+ export async function disableSuggestion(
278
+ db: D1Database,
279
+ kv: KVNamespace,
280
+ suggestionId: string,
281
+ actor: string,
282
+ reason: string,
283
+ isAutomatic: boolean,
284
+ log: Logger
285
+ ): Promise<boolean> {
286
+ const now = Math.floor(Date.now() / 1000);
287
+
288
+ try {
289
+ await db
290
+ .prepare(
291
+ `
292
+ UPDATE transient_pattern_suggestions
293
+ SET status = 'disabled', disabled_at = ?, updated_at = unixepoch()
294
+ WHERE id = ? AND status = 'approved'
295
+ `
296
+ )
297
+ .bind(now, suggestionId)
298
+ .run();
299
+
300
+ const action: AuditAction = isAutomatic ? 'auto-disabled' : 'disabled';
301
+ await logAuditEvent(db, suggestionId, action, actor, reason);
302
+
303
+ // Refresh KV cache
304
+ await refreshDynamicPatternsCache(db, kv, log);
305
+
306
+ log.info('Disabled pattern', { suggestionId, actor, reason, isAutomatic });
307
+ return true;
308
+ } catch (error) {
309
+ log.error('Failed to disable pattern', error, { suggestionId });
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Refresh the KV cache of approved dynamic patterns
316
+ */
317
+ export async function refreshDynamicPatternsCache(
318
+ db: D1Database,
319
+ kv: KVNamespace,
320
+ log: Logger
321
+ ): Promise<void> {
322
+ try {
323
+ const result = await db
324
+ .prepare(
325
+ `
326
+ SELECT
327
+ id, pattern_type as type, pattern_value as value,
328
+ category, scope
329
+ FROM transient_pattern_suggestions
330
+ WHERE status = 'approved'
331
+ ORDER BY created_at ASC
332
+ `
333
+ )
334
+ .all<PatternRule & { id: string }>();
335
+
336
+ const patterns = result.results;
337
+
338
+ // 7-day TTL as safety net — if refreshDynamicPatternsCache() fails
339
+ // silently, stale patterns will auto-expire rather than persist forever.
340
+ // Daily crons refresh this well within the 7-day window.
341
+ // (Previously had no TTL; before that, 1h TTL caused vanishing between cron runs.)
342
+ await kv.put(DYNAMIC_PATTERNS_KEY, JSON.stringify(patterns), { expirationTtl: 604800 });
343
+
344
+ log.info('Refreshed dynamic patterns cache', { count: patterns.length });
345
+ } catch (error) {
346
+ log.error('Failed to refresh patterns cache', error);
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Get approved dynamic patterns from KV (with D1 fallback)
352
+ */
353
+ export async function getDynamicPatterns(
354
+ kv: KVNamespace,
355
+ db: D1Database,
356
+ log: Logger
357
+ ): Promise<PatternRule[]> {
358
+ try {
359
+ // Try KV first
360
+ const cached = await kv.get(DYNAMIC_PATTERNS_KEY);
361
+ if (cached) {
362
+ const patterns = JSON.parse(cached) as PatternRule[];
363
+ log.debug('Loaded dynamic patterns from KV', { count: patterns.length });
364
+ return patterns;
365
+ }
366
+
367
+ // Fallback to D1
368
+ const result = await db
369
+ .prepare(
370
+ `
371
+ SELECT
372
+ pattern_type as type, pattern_value as value,
373
+ category, scope
374
+ FROM transient_pattern_suggestions
375
+ WHERE status = 'approved'
376
+ ORDER BY created_at ASC
377
+ `
378
+ )
379
+ .all<PatternRule>();
380
+
381
+ const patterns = result.results;
382
+
383
+ // Cache in KV for next time (7-day safety TTL, refreshed daily by cron)
384
+ await kv.put(DYNAMIC_PATTERNS_KEY, JSON.stringify(patterns), { expirationTtl: 604800 });
385
+
386
+ log.debug('Loaded dynamic patterns from D1', { count: patterns.length });
387
+ return patterns;
388
+ } catch (error) {
389
+ log.error('Failed to get dynamic patterns', error);
390
+ return [];
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Update match statistics for a pattern
396
+ */
397
+ export async function recordPatternMatch(
398
+ db: D1Database,
399
+ patternId: string
400
+ ): Promise<void> {
401
+ await db
402
+ .prepare(
403
+ `
404
+ UPDATE transient_pattern_suggestions
405
+ SET match_count = match_count + 1,
406
+ last_matched_at = unixepoch(),
407
+ updated_at = unixepoch()
408
+ WHERE id = ?
409
+ `
410
+ )
411
+ .bind(patternId)
412
+ .run();
413
+ }
414
+
415
+ /**
416
+ * Evidence for a pattern match - used for human review context
417
+ */
418
+ export interface PatternMatchEvidence {
419
+ patternId: string;
420
+ scriptName: string;
421
+ project?: string;
422
+ errorFingerprint?: string;
423
+ normalizedMessage?: string;
424
+ errorType?: string;
425
+ priority?: string;
426
+ }
427
+
428
+ /**
429
+ * Record detailed match evidence for a pattern
430
+ * Used by error-collector when dynamic patterns match
431
+ */
432
+ export async function recordPatternMatchEvidence(
433
+ db: D1Database,
434
+ evidence: PatternMatchEvidence
435
+ ): Promise<void> {
436
+ const id = generateId('evidence');
437
+
438
+ try {
439
+ await db
440
+ .prepare(
441
+ `
442
+ INSERT INTO pattern_match_evidence
443
+ (id, pattern_id, script_name, project, error_fingerprint, normalized_message, error_type, priority)
444
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
445
+ `
446
+ )
447
+ .bind(
448
+ id,
449
+ evidence.patternId,
450
+ evidence.scriptName,
451
+ evidence.project ?? null,
452
+ evidence.errorFingerprint ?? null,
453
+ evidence.normalizedMessage ?? null,
454
+ evidence.errorType ?? null,
455
+ evidence.priority ?? null
456
+ )
457
+ .run();
458
+
459
+ // Also increment the simple match_count on the pattern
460
+ await recordPatternMatch(db, evidence.patternId);
461
+ } catch (error) {
462
+ // Log but don't throw - evidence tracking is non-critical
463
+ console.error('Failed to record pattern match evidence:', error);
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Aggregated evidence for pattern review
469
+ */
470
+ export interface AggregatedPatternEvidence {
471
+ totalMatches: number;
472
+ matchesByProject: Record<string, number>;
473
+ matchesByScript: Record<string, number>;
474
+ sampleMessages: string[];
475
+ distinctDays: number;
476
+ firstMatchAt: number | null;
477
+ lastMatchAt: number | null;
478
+ }
479
+
480
+ /**
481
+ * Aggregate match evidence for a pattern
482
+ * Used by shadow evaluation to build review context
483
+ */
484
+ export async function aggregatePatternEvidence(
485
+ db: D1Database,
486
+ patternId: string
487
+ ): Promise<AggregatedPatternEvidence> {
488
+ // Get total matches
489
+ const totalResult = await db
490
+ .prepare(`SELECT COUNT(*) as count FROM pattern_match_evidence WHERE pattern_id = ?`)
491
+ .bind(patternId)
492
+ .first<{ count: number }>();
493
+
494
+ // Get matches grouped by project
495
+ const projectsResult = await db
496
+ .prepare(
497
+ `
498
+ SELECT COALESCE(project, 'unknown') as project, COUNT(*) as count
499
+ FROM pattern_match_evidence
500
+ WHERE pattern_id = ?
501
+ GROUP BY project
502
+ ORDER BY count DESC
503
+ `
504
+ )
505
+ .bind(patternId)
506
+ .all<{ project: string; count: number }>();
507
+
508
+ // Get matches grouped by script
509
+ const scriptsResult = await db
510
+ .prepare(
511
+ `
512
+ SELECT script_name, COUNT(*) as count
513
+ FROM pattern_match_evidence
514
+ WHERE pattern_id = ?
515
+ GROUP BY script_name
516
+ ORDER BY count DESC
517
+ LIMIT 10
518
+ `
519
+ )
520
+ .bind(patternId)
521
+ .all<{ script_name: string; count: number }>();
522
+
523
+ // Get distinct days (count unique dates)
524
+ const daysResult = await db
525
+ .prepare(
526
+ `
527
+ SELECT COUNT(DISTINCT date(matched_at, 'unixepoch')) as days
528
+ FROM pattern_match_evidence
529
+ WHERE pattern_id = ?
530
+ `
531
+ )
532
+ .bind(patternId)
533
+ .first<{ days: number }>();
534
+
535
+ // Get sample messages (distinct, up to 5)
536
+ const messagesResult = await db
537
+ .prepare(
538
+ `
539
+ SELECT DISTINCT normalized_message
540
+ FROM pattern_match_evidence
541
+ WHERE pattern_id = ? AND normalized_message IS NOT NULL
542
+ LIMIT 5
543
+ `
544
+ )
545
+ .bind(patternId)
546
+ .all<{ normalized_message: string }>();
547
+
548
+ // Get first and last match times
549
+ const timesResult = await db
550
+ .prepare(
551
+ `
552
+ SELECT MIN(matched_at) as first_match, MAX(matched_at) as last_match
553
+ FROM pattern_match_evidence
554
+ WHERE pattern_id = ?
555
+ `
556
+ )
557
+ .bind(patternId)
558
+ .first<{ first_match: number | null; last_match: number | null }>();
559
+
560
+ // Build result
561
+ const matchesByProject: Record<string, number> = {};
562
+ for (const row of projectsResult.results) {
563
+ matchesByProject[row.project] = row.count;
564
+ }
565
+
566
+ const matchesByScript: Record<string, number> = {};
567
+ for (const row of scriptsResult.results) {
568
+ matchesByScript[row.script_name] = row.count;
569
+ }
570
+
571
+ return {
572
+ totalMatches: totalResult?.count ?? 0,
573
+ matchesByProject,
574
+ matchesByScript,
575
+ sampleMessages: messagesResult.results.map((r) => r.normalized_message),
576
+ distinctDays: daysResult?.days ?? 0,
577
+ firstMatchAt: timesResult?.first_match ?? null,
578
+ lastMatchAt: timesResult?.last_match ?? null,
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Get approved patterns with match statistics
584
+ */
585
+ export async function getApprovedPatterns(
586
+ db: D1Database,
587
+ limit: number = 50,
588
+ offset: number = 0
589
+ ): Promise<PatternSuggestion[]> {
590
+ const result = await db
591
+ .prepare(
592
+ `
593
+ SELECT
594
+ id, pattern_type as patternType, pattern_value as patternValue,
595
+ category, scope, confidence_score as confidenceScore,
596
+ sample_messages as sampleMessages, ai_reasoning as aiReasoning,
597
+ cluster_id as clusterId, status,
598
+ reviewed_by as reviewedBy, reviewed_at as reviewedAt,
599
+ rejection_reason as rejectionReason,
600
+ backtest_match_count as backtestMatchCount,
601
+ backtest_total_errors as backtestTotalErrors,
602
+ backtest_match_rate as backtestMatchRate,
603
+ shadow_mode_start as shadowModeStart,
604
+ shadow_mode_end as shadowModeEnd,
605
+ shadow_mode_matches as shadowModeMatches,
606
+ shadow_match_days as shadowMatchDays,
607
+ enabled_at as enabledAt, disabled_at as disabledAt,
608
+ last_matched_at as lastMatchedAt, match_count as matchCount,
609
+ is_protected as isProtected, source, original_regex as originalRegex,
610
+ created_at as createdAt, updated_at as updatedAt
611
+ FROM transient_pattern_suggestions
612
+ WHERE status = 'approved'
613
+ ORDER BY match_count DESC, created_at DESC
614
+ LIMIT ? OFFSET ?
615
+ `
616
+ )
617
+ .bind(limit, offset)
618
+ .all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
619
+
620
+ return result.results.map((r) => ({
621
+ ...r,
622
+ sampleMessages: JSON.parse(r.sampleMessages || '[]'),
623
+ shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
624
+ isProtected: Boolean(r.isProtected),
625
+ }));
626
+ }
627
+
628
+ /**
629
+ * Get patterns in shadow mode
630
+ */
631
+ export async function getShadowPatterns(
632
+ db: D1Database,
633
+ limit: number = 50
634
+ ): Promise<PatternSuggestion[]> {
635
+ const result = await db
636
+ .prepare(
637
+ `
638
+ SELECT
639
+ id, pattern_type as patternType, pattern_value as patternValue,
640
+ category, scope, confidence_score as confidenceScore,
641
+ sample_messages as sampleMessages, ai_reasoning as aiReasoning,
642
+ cluster_id as clusterId, status,
643
+ reviewed_by as reviewedBy, reviewed_at as reviewedAt,
644
+ rejection_reason as rejectionReason,
645
+ backtest_match_count as backtestMatchCount,
646
+ backtest_total_errors as backtestTotalErrors,
647
+ backtest_match_rate as backtestMatchRate,
648
+ shadow_mode_start as shadowModeStart,
649
+ shadow_mode_end as shadowModeEnd,
650
+ shadow_mode_matches as shadowModeMatches,
651
+ shadow_match_days as shadowMatchDays,
652
+ enabled_at as enabledAt, disabled_at as disabledAt,
653
+ last_matched_at as lastMatchedAt, match_count as matchCount,
654
+ is_protected as isProtected, source, original_regex as originalRegex,
655
+ created_at as createdAt, updated_at as updatedAt
656
+ FROM transient_pattern_suggestions
657
+ WHERE status = 'shadow'
658
+ ORDER BY shadow_mode_end ASC, created_at ASC
659
+ LIMIT ?
660
+ `
661
+ )
662
+ .bind(limit)
663
+ .all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
664
+
665
+ return result.results.map((r) => ({
666
+ ...r,
667
+ sampleMessages: JSON.parse(r.sampleMessages || '[]'),
668
+ shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
669
+ isProtected: Boolean(r.isProtected),
670
+ }));
671
+ }
672
+
673
+ /**
674
+ * Get stale patterns
675
+ */
676
+ export async function getStalePatterns(
677
+ db: D1Database,
678
+ limit: number = 50
679
+ ): Promise<PatternSuggestion[]> {
680
+ const result = await db
681
+ .prepare(
682
+ `
683
+ SELECT
684
+ id, pattern_type as patternType, pattern_value as patternValue,
685
+ category, scope, confidence_score as confidenceScore,
686
+ sample_messages as sampleMessages, ai_reasoning as aiReasoning,
687
+ cluster_id as clusterId, status,
688
+ reviewed_by as reviewedBy, reviewed_at as reviewedAt,
689
+ rejection_reason as rejectionReason,
690
+ backtest_match_count as backtestMatchCount,
691
+ backtest_total_errors as backtestTotalErrors,
692
+ backtest_match_rate as backtestMatchRate,
693
+ shadow_mode_start as shadowModeStart,
694
+ shadow_mode_end as shadowModeEnd,
695
+ shadow_mode_matches as shadowModeMatches,
696
+ shadow_match_days as shadowMatchDays,
697
+ enabled_at as enabledAt, disabled_at as disabledAt,
698
+ last_matched_at as lastMatchedAt, match_count as matchCount,
699
+ is_protected as isProtected, source, original_regex as originalRegex,
700
+ created_at as createdAt, updated_at as updatedAt
701
+ FROM transient_pattern_suggestions
702
+ WHERE status = 'stale'
703
+ ORDER BY disabled_at DESC, created_at DESC
704
+ LIMIT ?
705
+ `
706
+ )
707
+ .bind(limit)
708
+ .all<PatternSuggestion & { sampleMessages: string; shadowMatchDays: string; isProtected: number }>();
709
+
710
+ return result.results.map((r) => ({
711
+ ...r,
712
+ sampleMessages: JSON.parse(r.sampleMessages || '[]'),
713
+ shadowMatchDays: r.shadowMatchDays ? JSON.parse(r.shadowMatchDays) : [],
714
+ isProtected: Boolean(r.isProtected),
715
+ }));
716
+ }
717
+
718
+ /**
719
+ * Get pattern statistics summary
720
+ */
721
+ export async function getPatternStats(db: D1Database): Promise<{
722
+ pendingCount: number;
723
+ shadowCount: number;
724
+ approvedCount: number;
725
+ staleCount: number;
726
+ rejectedCount: number;
727
+ disabledCount: number;
728
+ protectedCount: number;
729
+ staticCount: number;
730
+ totalMatches: number;
731
+ lastDiscoveryRun: number | null;
732
+ activeCategories: string[];
733
+ }> {
734
+ // Get counts by status
735
+ const countsResult = await db
736
+ .prepare(
737
+ `
738
+ SELECT
739
+ status,
740
+ COUNT(*) as count,
741
+ SUM(match_count) as totalMatches
742
+ FROM transient_pattern_suggestions
743
+ GROUP BY status
744
+ `
745
+ )
746
+ .all<{ status: string; count: number; totalMatches: number | null }>();
747
+
748
+ const counts = countsResult.results.reduce(
749
+ (acc, row) => {
750
+ acc[row.status] = row.count;
751
+ if (row.status === 'approved') {
752
+ acc.totalMatches = row.totalMatches || 0;
753
+ }
754
+ return acc;
755
+ },
756
+ { pending: 0, shadow: 0, approved: 0, stale: 0, rejected: 0, disabled: 0, totalMatches: 0 } as Record<string, number>
757
+ );
758
+
759
+ // Get protected and static counts
760
+ const protectedResult = await db
761
+ .prepare(
762
+ `
763
+ SELECT
764
+ SUM(CASE WHEN is_protected = 1 THEN 1 ELSE 0 END) as protectedCount,
765
+ SUM(CASE WHEN source = 'static-import' THEN 1 ELSE 0 END) as staticCount
766
+ FROM transient_pattern_suggestions
767
+ `
768
+ )
769
+ .first<{ protectedCount: number; staticCount: number }>();
770
+
771
+ // Get unique active categories
772
+ const categoriesResult = await db
773
+ .prepare(
774
+ `
775
+ SELECT DISTINCT category
776
+ FROM transient_pattern_suggestions
777
+ WHERE status = 'approved'
778
+ ORDER BY category
779
+ `
780
+ )
781
+ .all<{ category: string }>();
782
+
783
+ // Get last discovery run from clusters table
784
+ const lastRunResult = await db
785
+ .prepare(
786
+ `
787
+ SELECT MAX(created_at) as lastRun
788
+ FROM error_clusters
789
+ `
790
+ )
791
+ .first<{ lastRun: number | null }>();
792
+
793
+ return {
794
+ pendingCount: counts.pending || 0,
795
+ shadowCount: counts.shadow || 0,
796
+ approvedCount: counts.approved || 0,
797
+ staleCount: counts.stale || 0,
798
+ rejectedCount: counts.rejected || 0,
799
+ disabledCount: counts.disabled || 0,
800
+ protectedCount: protectedResult?.protectedCount || 0,
801
+ staticCount: protectedResult?.staticCount || 0,
802
+ totalMatches: counts.totalMatches || 0,
803
+ lastDiscoveryRun: lastRunResult?.lastRun || null,
804
+ activeCategories: categoriesResult.results.map((r) => r.category),
805
+ };
806
+ }