@littlebearapps/platform-admin-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.
Files changed (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.ts +16 -0
  3. package/dist/index.js +89 -0
  4. package/dist/prompts.d.ts +27 -0
  5. package/dist/prompts.js +80 -0
  6. package/dist/scaffold.d.ts +5 -0
  7. package/dist/scaffold.js +65 -0
  8. package/dist/templates.d.ts +16 -0
  9. package/dist/templates.js +131 -0
  10. package/package.json +46 -0
  11. package/templates/full/migrations/006_pattern_discovery.sql +199 -0
  12. package/templates/full/migrations/007_notifications_search.sql +127 -0
  13. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  14. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  15. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  16. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  17. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  18. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  19. package/templates/full/workers/pattern-discovery.ts +661 -0
  20. package/templates/full/workers/platform-alert-router.ts +1809 -0
  21. package/templates/full/workers/platform-notifications.ts +424 -0
  22. package/templates/full/workers/platform-search.ts +480 -0
  23. package/templates/full/workers/platform-settings.ts +436 -0
  24. package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
  25. package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
  26. package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
  27. package/templates/full/wrangler.search.jsonc.hbs +16 -0
  28. package/templates/full/wrangler.settings.jsonc.hbs +23 -0
  29. package/templates/shared/README.md.hbs +69 -0
  30. package/templates/shared/config/budgets.yaml.hbs +72 -0
  31. package/templates/shared/config/services.yaml.hbs +45 -0
  32. package/templates/shared/migrations/001_core_tables.sql +117 -0
  33. package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
  34. package/templates/shared/migrations/003_feature_tracking.sql +250 -0
  35. package/templates/shared/migrations/004_settings_alerts.sql +452 -0
  36. package/templates/shared/migrations/seed.sql.hbs +4 -0
  37. package/templates/shared/package.json.hbs +21 -0
  38. package/templates/shared/scripts/sync-config.ts +242 -0
  39. package/templates/shared/tsconfig.json +12 -0
  40. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  41. package/templates/shared/workers/lib/billing.ts +293 -0
  42. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  43. package/templates/shared/workers/lib/control.ts +292 -0
  44. package/templates/shared/workers/lib/economics.ts +368 -0
  45. package/templates/shared/workers/lib/metrics.ts +103 -0
  46. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  47. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  48. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  49. package/templates/shared/workers/lib/shared/types.ts +58 -0
  50. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  51. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  52. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  53. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  54. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  55. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  56. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  57. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  58. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  59. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  60. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  61. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  62. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  63. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  64. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  65. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  66. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  67. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  68. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  69. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  70. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  71. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  72. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  73. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  74. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  75. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  76. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  77. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  78. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  79. package/templates/shared/workers/platform-usage.ts +1915 -0
  80. package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
  81. package/templates/standard/migrations/005_error_collection.sql +162 -0
  82. package/templates/standard/workers/error-collector.ts +2670 -0
  83. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  84. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  85. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  86. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  87. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  88. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  89. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  90. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  91. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  92. package/templates/standard/workers/platform-sentinel.ts +1744 -0
  93. package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
  94. package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * BCU (Budget Consumption Unit) Cost Allocator
3
+ *
4
+ * Provides scarcity-weighted quota enforcement for intelligent degradation.
5
+ * BCU normalises different resource types into a single unit for budget comparison.
6
+ *
7
+ * Key difference from costs.ts:
8
+ * - costs.ts: Financial reporting (actual USD costs)
9
+ * - economics.ts: Scarcity-weighted quota enforcement (relative resource pressure)
10
+ *
11
+ * BCU weights reflect scarcity and impact, not just cost:
12
+ * - AI neurons are expensive AND scarce (weight: 100)
13
+ * - D1 writes have durability implications (weight: 10)
14
+ * - KV writes are moderately constrained (weight: 1)
15
+ * - Requests are abundant but need tracking (weight: 0.001)
16
+ */
17
+
18
+ import type { FeatureMetrics } from '@littlebearapps/platform-consumer-sdk';
19
+
20
+ // =============================================================================
21
+ // TYPES
22
+ // =============================================================================
23
+
24
+ /**
25
+ * Resource type for BCU calculation.
26
+ */
27
+ export type ResourceType =
28
+ | 'aiNeurons'
29
+ | 'aiRequests'
30
+ | 'd1Writes'
31
+ | 'd1Reads'
32
+ | 'd1RowsWritten'
33
+ | 'd1RowsRead'
34
+ | 'kvWrites'
35
+ | 'kvReads'
36
+ | 'kvDeletes'
37
+ | 'kvLists'
38
+ | 'r2ClassA'
39
+ | 'r2ClassB'
40
+ | 'doRequests'
41
+ | 'doGbSeconds'
42
+ | 'queueMessages'
43
+ | 'vectorizeQueries'
44
+ | 'vectorizeInserts'
45
+ | 'workflowInvocations'
46
+ | 'requests'
47
+ | 'cpuMs';
48
+
49
+ /**
50
+ * BCU weights per resource type.
51
+ */
52
+ export type BCUWeights = Record<ResourceType, number>;
53
+
54
+ /**
55
+ * Result of BCU calculation.
56
+ */
57
+ export interface BCUResult {
58
+ /** Total BCU value */
59
+ total: number;
60
+ /** Breakdown by resource type */
61
+ breakdown: Partial<Record<ResourceType, number>>;
62
+ /** Dominant resource (highest BCU contribution) */
63
+ dominantResource: ResourceType | null;
64
+ /** Percentage contribution of dominant resource */
65
+ dominantPercentage: number;
66
+ }
67
+
68
+ /**
69
+ * Budget state with BCU tracking.
70
+ */
71
+ export interface BCUBudgetState {
72
+ /** Current period BCU consumption */
73
+ currentBCU: number;
74
+ /** Budget limit in BCU */
75
+ limitBCU: number;
76
+ /** Utilisation ratio (0.0-1.0+) */
77
+ utilisation: number;
78
+ /** Whether budget is exceeded */
79
+ exceeded: boolean;
80
+ }
81
+
82
+ // =============================================================================
83
+ // SCARCITY WEIGHTS
84
+ // =============================================================================
85
+
86
+ /**
87
+ * Default BCU weights reflecting resource scarcity and impact.
88
+ *
89
+ * Philosophy:
90
+ * - Expensive resources that are hard to scale get high weights
91
+ * - Cheap, abundant resources get low weights
92
+ * - Writes are weighted higher than reads (durability implications)
93
+ *
94
+ * TODO: Adjust these weights based on your specific scarcity constraints.
95
+ */
96
+ export const DEFAULT_BCU_WEIGHTS: BCUWeights = {
97
+ // AI Resources - Most expensive and scarce
98
+ aiNeurons: 100, // $0.011 per 1K neurons, compute-intensive
99
+ aiRequests: 50, // Each AI call is significant
100
+
101
+ // D1 Database - Writes are expensive, reads are cheap
102
+ d1Writes: 10, // Deprecated field (use d1RowsWritten)
103
+ d1Reads: 0.01, // Deprecated field (use d1RowsRead)
104
+ d1RowsWritten: 10, // $1.00 per million, durability critical
105
+ d1RowsRead: 0.01, // $0.001 per billion, cheap
106
+
107
+ // KV - Writes constrained, reads abundant
108
+ kvWrites: 1, // $5.00 per million
109
+ kvReads: 0.1, // $0.50 per million
110
+ kvDeletes: 1, // Same cost as writes
111
+ kvLists: 1, // Same cost as writes
112
+
113
+ // R2 - Operations are relatively cheap
114
+ r2ClassA: 0.5, // $4.50 per million (PUT, POST, LIST)
115
+ r2ClassB: 0.05, // $0.36 per million (GET, HEAD)
116
+
117
+ // Durable Objects - Request-based pricing
118
+ doRequests: 0.5, // $0.15 per million
119
+ doGbSeconds: 5, // $12.50 per million GB-seconds
120
+
121
+ // Queues - Moderate pricing
122
+ queueMessages: 0.5, // $0.40 per million
123
+
124
+ // Vectorize - Query-intensive
125
+ vectorizeQueries: 1, // $0.01 per million dimensions
126
+ vectorizeInserts: 2, // Writes more expensive
127
+
128
+ // Workflows - Still in beta
129
+ workflowInvocations: 1, // Placeholder
130
+
131
+ // General compute
132
+ requests: 0.001, // Very cheap, 10M included
133
+ cpuMs: 0.01, // $0.02 per million ms
134
+ };
135
+
136
+ // =============================================================================
137
+ // BCU CALCULATION
138
+ // =============================================================================
139
+
140
+ /**
141
+ * Calculate BCU (Budget Consumption Units) from metrics.
142
+ *
143
+ * BCU provides a normalised measure of resource consumption that accounts
144
+ * for scarcity, not just cost. This enables fair quota allocation across
145
+ * features with different resource profiles.
146
+ *
147
+ * @param metrics - Feature metrics from telemetry
148
+ * @param weights - BCU weights (defaults provided)
149
+ * @returns BCU result with total, breakdown, and dominant resource
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * const metrics = { aiNeurons: 1000, d1RowsWritten: 100, requests: 50 };
154
+ * const result = calculateBCU(metrics);
155
+ * // result.total = 100000 + 1000 + 0.05 = 101000.05
156
+ * // result.dominantResource = 'aiNeurons'
157
+ * // result.dominantPercentage = 99.01
158
+ * ```
159
+ */
160
+ export function calculateBCU(
161
+ metrics: FeatureMetrics,
162
+ weights: BCUWeights = DEFAULT_BCU_WEIGHTS
163
+ ): BCUResult {
164
+ const breakdown: Partial<Record<ResourceType, number>> = {};
165
+ let total = 0;
166
+ let maxContribution = 0;
167
+ let dominantResource: ResourceType | null = null;
168
+
169
+ // Calculate BCU for each non-zero metric
170
+ const metricEntries: [ResourceType, number | undefined][] = [
171
+ ['aiNeurons', metrics.aiNeurons],
172
+ ['aiRequests', metrics.aiRequests],
173
+ ['d1Writes', metrics.d1Writes],
174
+ ['d1Reads', metrics.d1Reads],
175
+ ['d1RowsWritten', metrics.d1RowsWritten],
176
+ ['d1RowsRead', metrics.d1RowsRead],
177
+ ['kvWrites', metrics.kvWrites],
178
+ ['kvReads', metrics.kvReads],
179
+ ['kvDeletes', metrics.kvDeletes],
180
+ ['kvLists', metrics.kvLists],
181
+ ['r2ClassA', metrics.r2ClassA],
182
+ ['r2ClassB', metrics.r2ClassB],
183
+ ['doRequests', metrics.doRequests],
184
+ // Note: doGbSeconds not in FeatureMetrics - omitted
185
+ ['queueMessages', metrics.queueMessages],
186
+ ['vectorizeQueries', metrics.vectorizeQueries],
187
+ ['vectorizeInserts', metrics.vectorizeInserts],
188
+ ['workflowInvocations', metrics.workflowInvocations],
189
+ ['requests', metrics.requests],
190
+ ['cpuMs', metrics.cpuMs],
191
+ ];
192
+
193
+ for (const [resource, value] of metricEntries) {
194
+ if (value && value > 0) {
195
+ const weight = weights[resource];
196
+ const contribution = value * weight;
197
+ breakdown[resource] = contribution;
198
+ total += contribution;
199
+
200
+ if (contribution > maxContribution) {
201
+ maxContribution = contribution;
202
+ dominantResource = resource;
203
+ }
204
+ }
205
+ }
206
+
207
+ const dominantPercentage = total > 0 ? (maxContribution / total) * 100 : 0;
208
+
209
+ return {
210
+ total,
211
+ breakdown,
212
+ dominantResource,
213
+ dominantPercentage,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Calculate BCU from raw metric values (not FeatureMetrics object).
219
+ * Useful when processing individual metric updates.
220
+ */
221
+ export function calculateBCUForResource(
222
+ resource: ResourceType,
223
+ value: number,
224
+ weights: BCUWeights = DEFAULT_BCU_WEIGHTS
225
+ ): number {
226
+ return value * weights[resource];
227
+ }
228
+
229
+ // =============================================================================
230
+ // BUDGET ENFORCEMENT
231
+ // =============================================================================
232
+
233
+ /**
234
+ * Check BCU budget state for a feature.
235
+ *
236
+ * @param currentBCU - Current BCU consumption
237
+ * @param limitBCU - Budget limit in BCU
238
+ * @returns Budget state with utilisation info
239
+ */
240
+ export function checkBCUBudget(currentBCU: number, limitBCU: number): BCUBudgetState {
241
+ const utilisation = limitBCU > 0 ? currentBCU / limitBCU : 0;
242
+ return {
243
+ currentBCU,
244
+ limitBCU,
245
+ utilisation,
246
+ exceeded: currentBCU > limitBCU,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Convert a USD budget to BCU budget.
252
+ * Useful for setting feature budgets based on dollar allocations.
253
+ *
254
+ * This is an approximation based on the most common resource mix.
255
+ * For precise conversion, you'd need the expected resource profile.
256
+ *
257
+ * @param usdBudget - Budget in USD
258
+ * @returns Approximate BCU budget
259
+ */
260
+ export function usdToBCU(usdBudget: number): number {
261
+ // Approximation: assume average workload is 60% requests, 20% D1, 10% KV, 10% AI
262
+ // Average BCU per dollar ~= $1 buys approximately 10000 BCU in this mix
263
+ return usdBudget * 10000;
264
+ }
265
+
266
+ /**
267
+ * Convert BCU to approximate USD.
268
+ * Inverse of usdToBCU for reporting.
269
+ */
270
+ export function bcuToUSD(bcu: number): number {
271
+ return bcu / 10000;
272
+ }
273
+
274
+ // =============================================================================
275
+ // UTILITY FUNCTIONS
276
+ // =============================================================================
277
+
278
+ /**
279
+ * Get human-readable description of dominant resource.
280
+ */
281
+ export function describeDominantResource(resource: ResourceType | null): string {
282
+ if (!resource) return 'none';
283
+
284
+ const descriptions: Record<ResourceType, string> = {
285
+ aiNeurons: 'AI compute (neurons)',
286
+ aiRequests: 'AI API calls',
287
+ d1Writes: 'D1 writes (legacy)',
288
+ d1Reads: 'D1 reads (legacy)',
289
+ d1RowsWritten: 'D1 rows written',
290
+ d1RowsRead: 'D1 rows read',
291
+ kvWrites: 'KV writes',
292
+ kvReads: 'KV reads',
293
+ kvDeletes: 'KV deletes',
294
+ kvLists: 'KV list operations',
295
+ r2ClassA: 'R2 Class A ops',
296
+ r2ClassB: 'R2 Class B ops',
297
+ doRequests: 'Durable Object requests',
298
+ doGbSeconds: 'Durable Object compute',
299
+ queueMessages: 'Queue messages',
300
+ vectorizeQueries: 'Vectorize queries',
301
+ vectorizeInserts: 'Vectorize inserts',
302
+ workflowInvocations: 'Workflow invocations',
303
+ requests: 'HTTP requests',
304
+ cpuMs: 'CPU time',
305
+ };
306
+
307
+ return descriptions[resource] || resource;
308
+ }
309
+
310
+ /**
311
+ * Format BCU result for logging.
312
+ */
313
+ export function formatBCUResult(result: BCUResult): string {
314
+ const dominant = result.dominantResource
315
+ ? `${describeDominantResource(result.dominantResource)} (${result.dominantPercentage.toFixed(1)}%)`
316
+ : 'none';
317
+ return `BCU: ${result.total.toFixed(2)}, dominant: ${dominant}`;
318
+ }
319
+
320
+ /**
321
+ * Get resource-specific BCU breakdown for detailed reporting.
322
+ */
323
+ export function getTopContributors(
324
+ result: BCUResult,
325
+ topN: number = 3
326
+ ): { resource: ResourceType; bcu: number; percentage: number }[] {
327
+ const entries = Object.entries(result.breakdown) as [ResourceType, number][];
328
+ return entries
329
+ .sort((a, b) => b[1] - a[1])
330
+ .slice(0, topN)
331
+ .map(([resource, bcu]) => ({
332
+ resource,
333
+ bcu,
334
+ percentage: result.total > 0 ? (bcu / result.total) * 100 : 0,
335
+ }));
336
+ }
337
+
338
+ /**
339
+ * Combine BCU results from multiple metrics.
340
+ */
341
+ export function combineBCUResults(results: BCUResult[]): BCUResult {
342
+ const combined: Partial<Record<ResourceType, number>> = {};
343
+ let total = 0;
344
+
345
+ for (const result of results) {
346
+ for (const [resource, value] of Object.entries(result.breakdown) as [ResourceType, number][]) {
347
+ combined[resource] = (combined[resource] || 0) + value;
348
+ }
349
+ total += result.total;
350
+ }
351
+
352
+ // Find dominant resource in combined
353
+ let maxContribution = 0;
354
+ let dominantResource: ResourceType | null = null;
355
+ for (const [resource, value] of Object.entries(combined) as [ResourceType, number][]) {
356
+ if (value > maxContribution) {
357
+ maxContribution = value;
358
+ dominantResource = resource;
359
+ }
360
+ }
361
+
362
+ return {
363
+ total,
364
+ breakdown: combined,
365
+ dominantResource,
366
+ dominantPercentage: total > 0 ? (maxContribution / total) * 100 : 0,
367
+ };
368
+ }
@@ -0,0 +1,103 @@
1
+ import type { D1Database } from '@cloudflare/workers-types';
2
+
3
+ export async function insertRevenueMetric(
4
+ db: D1Database,
5
+ metricType: string,
6
+ value: number,
7
+ metadata?: Record<string, unknown>
8
+ ): Promise<void> {
9
+ await db
10
+ .prepare(
11
+ `INSERT INTO revenue_metrics (id, metric_type, value, timestamp, metadata)
12
+ VALUES (?, ?, ?, ?, ?)`
13
+ )
14
+ .bind(
15
+ crypto.randomUUID(),
16
+ metricType,
17
+ value,
18
+ Math.floor(Date.now() / 1000),
19
+ metadata ? JSON.stringify(metadata) : null
20
+ )
21
+ .run();
22
+ }
23
+
24
+ export async function insertProductMetric(
25
+ db: D1Database,
26
+ source: string,
27
+ metricType: string,
28
+ value: number,
29
+ metadata?: Record<string, unknown>
30
+ ): Promise<void> {
31
+ await db
32
+ .prepare(
33
+ `INSERT INTO product_metrics (id, source, metric_type, value, timestamp, metadata)
34
+ VALUES (?, ?, ?, ?, ?, ?)`
35
+ )
36
+ .bind(
37
+ crypto.randomUUID(),
38
+ source,
39
+ metricType,
40
+ value,
41
+ Math.floor(Date.now() / 1000),
42
+ metadata ? JSON.stringify(metadata) : null
43
+ )
44
+ .run();
45
+ }
46
+
47
+ export async function createAlert(
48
+ db: D1Database,
49
+ category: 'revenue' | 'product',
50
+ severity: 'critical' | 'high' | 'medium' | 'low',
51
+ title: string,
52
+ description: string,
53
+ source: string
54
+ ): Promise<void> {
55
+ await db
56
+ .prepare(
57
+ `INSERT INTO alerts (id, category, severity, title, description, source, timestamp)
58
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
59
+ )
60
+ .bind(
61
+ crypto.randomUUID(),
62
+ category,
63
+ severity,
64
+ title,
65
+ description,
66
+ source,
67
+ Math.floor(Date.now() / 1000)
68
+ )
69
+ .run();
70
+ }
71
+
72
+ export async function getPreviousMetric(
73
+ db: D1Database,
74
+ table: 'revenue_metrics' | 'product_metrics',
75
+ metricType: string,
76
+ sinceSeconds: number,
77
+ source?: string
78
+ ): Promise<number | null> {
79
+ const clauses = ['metric_type = ?', 'timestamp > ?'];
80
+ const params: Array<string | number> = [metricType, Math.floor(Date.now() / 1000) - sinceSeconds];
81
+
82
+ if (table === 'product_metrics' && source) {
83
+ clauses.unshift('source = ?');
84
+ params.unshift(source);
85
+ }
86
+
87
+ const result = await db
88
+ .prepare(
89
+ `SELECT value FROM ${table}
90
+ WHERE ${clauses.join(' AND ')}
91
+ ORDER BY timestamp ASC
92
+ LIMIT 1`
93
+ )
94
+ .bind(...params)
95
+ .first<{ value: number }>();
96
+
97
+ if (!result) {
98
+ return null;
99
+ }
100
+
101
+ const value = result.value;
102
+ return typeof value === 'number' ? value : Number(value ?? NaN);
103
+ }