@littlebearapps/platform-consumer-sdk 1.0.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.
@@ -0,0 +1,249 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Platform SDK - Durable Object Heartbeat Mixin
5
+ *
6
+ * Provides alarm-based health monitoring for Durable Objects.
7
+ * Uses the Alarms API to send periodic heartbeats to the platform telemetry queue.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { withHeartbeat } from '../lib/platform-sdk';
12
+ *
13
+ * export class MyDurableObject extends withHeartbeat(DurableObject, {
14
+ * featureKey: 'scout:do:triage-workflow',
15
+ * intervalMs: 5 * 60 * 1000, // 5 minutes
16
+ * }) {
17
+ * // Existing implementation unchanged
18
+ * }
19
+ * ```
20
+ */
21
+
22
+ import type { TelemetryMessage } from './types';
23
+ import { createLogger, type Logger } from './logging';
24
+
25
+ // =============================================================================
26
+ // MODULE LOGGER (lazy-initialized to avoid global scope crypto calls)
27
+ // =============================================================================
28
+
29
+ let _log: Logger | null = null;
30
+ function getLog(): Logger {
31
+ if (!_log) {
32
+ _log = createLogger({
33
+ worker: 'platform-sdk',
34
+ featureId: 'platform:sdk:do-heartbeat',
35
+ });
36
+ }
37
+ return _log;
38
+ }
39
+
40
+ // =============================================================================
41
+ // TYPES
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Configuration for the heartbeat mixin.
46
+ */
47
+ export interface HeartbeatConfig {
48
+ /** Feature key in format 'project:category:feature' */
49
+ featureKey: string;
50
+ /** Heartbeat interval in milliseconds. Default: 5 minutes */
51
+ intervalMs?: number;
52
+ /** Whether heartbeats are enabled. Default: true */
53
+ enabled?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Required environment bindings for heartbeat functionality.
58
+ */
59
+ export interface HeartbeatEnv {
60
+ /** Queue for telemetry messages */
61
+ PLATFORM_TELEMETRY: Queue<TelemetryMessage>;
62
+ }
63
+
64
+ /**
65
+ * Base class type for Durable Objects.
66
+ * Uses rest parameters for TypeScript mixin compatibility.
67
+ */
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ export type DOClass = new (...args: any[]) => DurableObject;
70
+
71
+ // =============================================================================
72
+ // CONSTANTS
73
+ // =============================================================================
74
+
75
+ /** Default heartbeat interval: 5 minutes */
76
+ const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
77
+
78
+ // =============================================================================
79
+ // MIXIN
80
+ // =============================================================================
81
+
82
+ /**
83
+ * Parse a feature key into its component parts.
84
+ */
85
+ function parseFeatureKey(featureKey: string): {
86
+ project: string;
87
+ category: string;
88
+ feature: string;
89
+ } {
90
+ const parts = featureKey.split(':');
91
+ if (parts.length !== 3) {
92
+ throw new Error(
93
+ `Invalid featureKey format: "${featureKey}". Expected "project:category:feature"`
94
+ );
95
+ }
96
+ return {
97
+ project: parts[0],
98
+ category: parts[1],
99
+ feature: parts[2],
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Mixin that adds heartbeat functionality to a Durable Object.
105
+ *
106
+ * Uses the Cloudflare Alarms API to schedule periodic heartbeats.
107
+ * Heartbeats are sent to the PLATFORM_TELEMETRY queue with `is_heartbeat: true`.
108
+ *
109
+ * The mixin:
110
+ * 1. Schedules the first heartbeat on construction
111
+ * 2. Sends a heartbeat message when the alarm fires
112
+ * 3. Reschedules the next heartbeat
113
+ * 4. Calls the parent's alarm() method if it exists
114
+ *
115
+ * @param Base - The base Durable Object class to extend
116
+ * @param config - Heartbeat configuration
117
+ * @returns Extended class with heartbeat functionality
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * export class ScoutTriageWorkflow extends withHeartbeat(DurableObject, {
122
+ * featureKey: 'scout:do:triage-workflow',
123
+ * intervalMs: 5 * 60 * 1000,
124
+ * }) {
125
+ * async fetch(request: Request): Promise<Response> {
126
+ * // Your existing implementation
127
+ * }
128
+ * }
129
+ * ```
130
+ */
131
+ export function withHeartbeat<TBase extends DOClass>(Base: TBase, config: HeartbeatConfig): TBase {
132
+ const { featureKey, intervalMs = DEFAULT_INTERVAL_MS, enabled = true } = config;
133
+
134
+ // Validate feature key format early
135
+ const { project, category, feature } = parseFeatureKey(featureKey);
136
+
137
+ return class extends Base {
138
+ // Store parsed config for use in methods
139
+ protected readonly _heartbeatConfig = {
140
+ featureKey,
141
+ project,
142
+ category,
143
+ feature,
144
+ intervalMs,
145
+ enabled,
146
+ };
147
+
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ constructor(...args: any[]) {
150
+ super(...args);
151
+
152
+ // Schedule first heartbeat if enabled
153
+ // args[0] is the DurableObjectState
154
+ if (this._heartbeatConfig.enabled) {
155
+ void this._scheduleNextHeartbeat(args[0] as DurableObjectState);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Handle alarm events.
161
+ * Sends heartbeat, reschedules next alarm, and calls parent alarm if present.
162
+ */
163
+ async alarm(): Promise<void> {
164
+ const state = (this as unknown as { state: DurableObjectState }).state;
165
+ const env = (this as unknown as { env: HeartbeatEnv }).env;
166
+
167
+ if (this._heartbeatConfig.enabled) {
168
+ // Send heartbeat to telemetry queue
169
+ await this._sendHeartbeat(env);
170
+
171
+ // Schedule next heartbeat
172
+ await this._scheduleNextHeartbeat(state);
173
+ }
174
+
175
+ // Call parent alarm() if it exists
176
+ // Note: In TypeScript, we need to check if the parent class has an alarm method
177
+ const parentAlarm = Object.getPrototypeOf(Object.getPrototypeOf(this)).alarm;
178
+ if (typeof parentAlarm === 'function' && parentAlarm !== this.alarm) {
179
+ await parentAlarm.call(this);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Schedule the next heartbeat alarm.
185
+ */
186
+ protected async _scheduleNextHeartbeat(state: DurableObjectState): Promise<void> {
187
+ try {
188
+ const nextAlarmTime = Date.now() + this._heartbeatConfig.intervalMs;
189
+ await state.storage.setAlarm(nextAlarmTime);
190
+ getLog().debug('Heartbeat scheduled', {
191
+ featureKey: this._heartbeatConfig.featureKey,
192
+ scheduledAt: new Date(nextAlarmTime).toISOString(),
193
+ });
194
+ } catch (error) {
195
+ getLog().error('Failed to schedule heartbeat', error, {
196
+ featureKey: this._heartbeatConfig.featureKey,
197
+ });
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Send a heartbeat message to the telemetry queue.
203
+ */
204
+ protected async _sendHeartbeat(env: HeartbeatEnv): Promise<void> {
205
+ if (!env.PLATFORM_TELEMETRY) {
206
+ getLog().warn('No PLATFORM_TELEMETRY queue binding, heartbeat not sent', undefined, {
207
+ featureKey: this._heartbeatConfig.featureKey,
208
+ });
209
+ return;
210
+ }
211
+
212
+ const heartbeatMessage: TelemetryMessage = {
213
+ feature_key: this._heartbeatConfig.featureKey,
214
+ project: this._heartbeatConfig.project,
215
+ category: this._heartbeatConfig.category,
216
+ feature: this._heartbeatConfig.feature,
217
+ metrics: {},
218
+ timestamp: Date.now(),
219
+ is_heartbeat: true,
220
+ };
221
+
222
+ try {
223
+ await env.PLATFORM_TELEMETRY.send(heartbeatMessage);
224
+ getLog().debug('Heartbeat sent', { featureKey: this._heartbeatConfig.featureKey });
225
+ } catch (error) {
226
+ // Fail open - log error but don't throw
227
+ getLog().error('Failed to send heartbeat', error, {
228
+ featureKey: this._heartbeatConfig.featureKey,
229
+ });
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Manually trigger a heartbeat (for testing or on-demand health checks).
235
+ */
236
+ async sendHeartbeatNow(): Promise<void> {
237
+ const env = (this as unknown as { env: HeartbeatEnv }).env;
238
+ await this._sendHeartbeat(env);
239
+ }
240
+
241
+ /**
242
+ * Manually reschedule the next heartbeat (for testing or recovery).
243
+ */
244
+ async rescheduleHeartbeat(): Promise<void> {
245
+ const state = (this as unknown as { state: DurableObjectState }).state;
246
+ await this._scheduleNextHeartbeat(state);
247
+ }
248
+ } as TBase;
249
+ }
@@ -0,0 +1,273 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ /**
4
+ * Dynamic Patterns
5
+ *
6
+ * AI-discovered transient error patterns loaded from KV at runtime.
7
+ * Separate from static patterns (patterns.ts) because these require KV I/O.
8
+ *
9
+ * Pattern lifecycle: pending -> shadow -> ready for review -> approved -> KV cache -> runtime
10
+ *
11
+ * Supports a constrained DSL with 4 types: contains, startsWith, statusCode, regex.
12
+ * Regex patterns have a 200-char safety limit to prevent ReDoS.
13
+ *
14
+ * Multi-account utilities (exportDynamicPatterns/importDynamicPatterns) enable
15
+ * cross-account pattern sync via sync-config or platform-agent.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { loadDynamicPatterns, classifyWithDynamicPatterns } from '@littlebearapps/platform-consumer-sdk/dynamic-patterns';
20
+ *
21
+ * const patterns = await loadDynamicPatterns(env.PLATFORM_CACHE);
22
+ * const result = classifyWithDynamicPatterns('Custom error message', patterns);
23
+ * if (result) {
24
+ * console.log(`Dynamic match: ${result.category} (${result.patternId})`);
25
+ * }
26
+ * ```
27
+ */
28
+
29
+ // =============================================================================
30
+ // TYPES
31
+ // =============================================================================
32
+
33
+ /** Dynamic pattern rule from pattern-discovery system */
34
+ export interface DynamicPatternRule {
35
+ type: 'contains' | 'startsWith' | 'statusCode' | 'regex';
36
+ value: string;
37
+ category: string;
38
+ scope: string;
39
+ id?: string;
40
+ }
41
+
42
+ /** Compiled pattern ready for classification */
43
+ export interface CompiledPattern {
44
+ test: (message: string) => boolean;
45
+ category: string;
46
+ id?: string;
47
+ }
48
+
49
+ /** Classification result from dynamic pattern matching */
50
+ export interface ClassificationResult {
51
+ category: string;
52
+ source: 'dynamic';
53
+ patternId?: string;
54
+ }
55
+
56
+ // =============================================================================
57
+ // CONSTANTS
58
+ // =============================================================================
59
+
60
+ /** KV key for approved dynamic patterns — useful for sync-config and multi-account */
61
+ export const DYNAMIC_PATTERNS_KV_KEY = 'PATTERNS:DYNAMIC:APPROVED';
62
+
63
+ // =============================================================================
64
+ // COMPILATION
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Compile a single dynamic pattern rule to a test function.
69
+ * Uses the constrained DSL to ensure safety (no arbitrary regex execution).
70
+ *
71
+ * @returns Compiled pattern or null if compilation fails
72
+ */
73
+ function compileDynamicPattern(rule: DynamicPatternRule): CompiledPattern | null {
74
+ try {
75
+ switch (rule.type) {
76
+ case 'contains': {
77
+ const tokens = rule.value.toLowerCase().split(/\s+/);
78
+ return {
79
+ test: (msg: string) => {
80
+ const lowerMsg = msg.toLowerCase();
81
+ return tokens.every((token) => lowerMsg.includes(token));
82
+ },
83
+ category: rule.category,
84
+ id: rule.id,
85
+ };
86
+ }
87
+ case 'startsWith': {
88
+ const prefix = rule.value.toLowerCase();
89
+ return {
90
+ test: (msg: string) => msg.toLowerCase().startsWith(prefix),
91
+ category: rule.category,
92
+ id: rule.id,
93
+ };
94
+ }
95
+ case 'statusCode': {
96
+ const code = rule.value;
97
+ const pattern = new RegExp(`\\b${code}\\b`);
98
+ return {
99
+ test: (msg: string) => pattern.test(msg),
100
+ category: rule.category,
101
+ id: rule.id,
102
+ };
103
+ }
104
+ case 'regex': {
105
+ // Add safety limit to prevent ReDoS
106
+ if (rule.value.length > 200) {
107
+ console.warn(`Skipping overly long regex pattern: ${rule.category}`);
108
+ return null;
109
+ }
110
+ const pattern = new RegExp(rule.value, 'i');
111
+ return {
112
+ test: (msg: string) => pattern.test(msg),
113
+ category: rule.category,
114
+ id: rule.id,
115
+ };
116
+ }
117
+ default:
118
+ return null;
119
+ }
120
+ } catch (error) {
121
+ console.warn(`Failed to compile pattern for ${rule.category}:`, error);
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Compile an array of dynamic pattern rules into test functions.
128
+ * Invalid rules are silently dropped (logged via console.warn).
129
+ *
130
+ * @param rules - Array of dynamic pattern rules from D1/KV
131
+ * @returns Array of compiled patterns ready for classification
132
+ */
133
+ export function compileDynamicPatterns(rules: DynamicPatternRule[]): CompiledPattern[] {
134
+ const compiled: CompiledPattern[] = [];
135
+ for (const rule of rules) {
136
+ const pattern = compileDynamicPattern(rule);
137
+ if (pattern) {
138
+ compiled.push(pattern);
139
+ }
140
+ }
141
+ return compiled;
142
+ }
143
+
144
+ // =============================================================================
145
+ // IN-MEMORY CACHE
146
+ // =============================================================================
147
+
148
+ let dynamicPatternsCache: CompiledPattern[] | null = null;
149
+ let dynamicPatternsCacheTime = 0;
150
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
151
+
152
+ // =============================================================================
153
+ // LOADING
154
+ // =============================================================================
155
+
156
+ /**
157
+ * Load dynamic patterns from KV and compile them.
158
+ * Uses in-memory cache (5-minute TTL) to avoid repeated KV reads within worker lifetime.
159
+ *
160
+ * @param kv - KV namespace containing approved patterns
161
+ * @returns Compiled patterns ready for classification
162
+ */
163
+ export async function loadDynamicPatterns(kv: KVNamespace): Promise<CompiledPattern[]> {
164
+ const now = Date.now();
165
+ if (dynamicPatternsCache && now - dynamicPatternsCacheTime < CACHE_TTL_MS) {
166
+ return dynamicPatternsCache;
167
+ }
168
+
169
+ try {
170
+ const cached = await kv.get(DYNAMIC_PATTERNS_KV_KEY);
171
+ if (!cached) {
172
+ dynamicPatternsCache = [];
173
+ dynamicPatternsCacheTime = now;
174
+ return [];
175
+ }
176
+
177
+ const rules = JSON.parse(cached) as DynamicPatternRule[];
178
+ const compiled = compileDynamicPatterns(rules);
179
+
180
+ dynamicPatternsCache = compiled;
181
+ dynamicPatternsCacheTime = now;
182
+ return compiled;
183
+ } catch (error) {
184
+ console.error('Failed to load dynamic patterns:', error);
185
+ return dynamicPatternsCache || [];
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Clear the in-memory dynamic patterns cache.
191
+ * Call after pattern updates or for testing.
192
+ */
193
+ export function clearDynamicPatternsCache(): void {
194
+ dynamicPatternsCache = null;
195
+ dynamicPatternsCacheTime = 0;
196
+ }
197
+
198
+ // =============================================================================
199
+ // CLASSIFICATION
200
+ // =============================================================================
201
+
202
+ /**
203
+ * Classify an error message using dynamic patterns only.
204
+ * For combined static + dynamic classification, use classifyErrorWithSource() in fingerprint.ts.
205
+ *
206
+ * @param message - Error message to classify
207
+ * @param patterns - Pre-loaded compiled dynamic patterns
208
+ * @returns Classification result or null if no match
209
+ */
210
+ export function classifyWithDynamicPatterns(
211
+ message: string,
212
+ patterns: CompiledPattern[]
213
+ ): ClassificationResult | null {
214
+ for (const compiled of patterns) {
215
+ if (compiled.test(message)) {
216
+ return {
217
+ category: compiled.category,
218
+ source: 'dynamic',
219
+ patternId: compiled.id,
220
+ };
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+
226
+ // =============================================================================
227
+ // MULTI-ACCOUNT UTILITIES
228
+ // =============================================================================
229
+
230
+ /**
231
+ * Export raw dynamic patterns from KV for cross-account transfer.
232
+ * Returns the JSON string as-is (no compilation) for writing to another KV.
233
+ *
234
+ * @param kv - Source KV namespace with approved patterns
235
+ * @returns Raw JSON string or null if no patterns are cached
236
+ */
237
+ export async function exportDynamicPatterns(kv: KVNamespace): Promise<string | null> {
238
+ return await kv.get(DYNAMIC_PATTERNS_KV_KEY);
239
+ }
240
+
241
+ /**
242
+ * Import dynamic patterns into a KV namespace.
243
+ * Validates structure AND compiles each rule as a safety gate:
244
+ * - Checks required fields (type, value, category)
245
+ * - Compiles to verify regex validity + applies 200-char limit for regex type
246
+ * - Silently drops rules that fail compilation (matches loadDynamicPatterns behaviour)
247
+ *
248
+ * TTL matches pattern-discovery's 7-day safety net.
249
+ *
250
+ * @param kv - Target KV namespace
251
+ * @param patternsJson - Raw JSON string from exportDynamicPatterns()
252
+ * @returns Count of imported and dropped rules
253
+ */
254
+ export async function importDynamicPatterns(
255
+ kv: KVNamespace,
256
+ patternsJson: string
257
+ ): Promise<{ imported: number; dropped: number }> {
258
+ const rules = JSON.parse(patternsJson) as DynamicPatternRule[];
259
+
260
+ // Structural validation
261
+ const validTypes = new Set(['contains', 'startsWith', 'statusCode', 'regex']);
262
+ const structurallyValid = rules.filter(
263
+ (r) => r.type && r.value && r.category && validTypes.has(r.type)
264
+ );
265
+
266
+ // Compilation gate: verify each rule compiles (catches invalid regex, ReDoS-length limit)
267
+ const compilable = structurallyValid.filter((r) => compileDynamicPatterns([r]).length > 0);
268
+
269
+ await kv.put(DYNAMIC_PATTERNS_KV_KEY, JSON.stringify(compilable), { expirationTtl: 604800 });
270
+ clearDynamicPatternsCache();
271
+
272
+ return { imported: compilable.length, dropped: rules.length - compilable.length };
273
+ }