@littlebearapps/platform-admin-sdk 1.5.0 → 2.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 (86) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +112 -1
  3. package/package.json +1 -1
  4. package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
  5. package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
  6. package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
  7. package/templates/full/dashboard/src/components/patterns/index.ts +3 -0
  8. package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
  9. package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
  10. package/templates/full/dashboard/src/components/reports/index.ts +2 -0
  11. package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
  12. package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
  13. package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
  15. package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
  16. package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
  17. package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
  18. package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
  19. package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
  20. package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
  21. package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
  22. package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
  23. package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
  24. package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
  25. package/templates/full/scripts/ops/universal-backfill.ts +147 -0
  26. package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
  27. package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
  28. package/templates/shared/.github/workflows/security.yml +33 -0
  29. package/templates/shared/dashboard/src/components/Nav.astro.hbs +2 -0
  30. package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
  31. package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
  32. package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
  33. package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
  34. package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
  35. package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
  36. package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
  37. package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
  38. package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
  39. package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
  40. package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
  41. package/templates/shared/dashboard/src/components/ui/index.ts +6 -0
  42. package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
  43. package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
  44. package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
  45. package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
  46. package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
  47. package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
  48. package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
  49. package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
  50. package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
  51. package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
  52. package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
  53. package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
  54. package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
  55. package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
  56. package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
  57. package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
  58. package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
  59. package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
  60. package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
  61. package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
  62. package/templates/shared/docs/architecture.md +89 -0
  63. package/templates/shared/docs/post-deploy-runbook.md +126 -0
  64. package/templates/shared/docs/troubleshooting.md +91 -0
  65. package/templates/shared/package.json.hbs +5 -0
  66. package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
  67. package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
  68. package/templates/shared/scripts/ops/validate-controls.js +141 -0
  69. package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
  70. package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
  71. package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
  72. package/templates/shared/tests/helpers/mock-d1.ts +61 -0
  73. package/templates/shared/tests/helpers/mock-kv.ts +37 -0
  74. package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
  75. package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
  76. package/templates/shared/vitest.config.ts +18 -0
  77. package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
  78. package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
  79. package/templates/standard/dashboard/src/components/health/index.ts +2 -0
  80. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
  81. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
  82. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
  83. package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
  84. package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
  85. package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
  86. package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Monthly Rollup Backfill Script
4
+ *
5
+ * Aggregates daily_usage_rollups into monthly_usage_rollups via the D1 REST API.
6
+ * Queries existing daily data and rolls it up to monthly granularity.
7
+ *
8
+ * Prerequisites:
9
+ * CLOUDFLARE_API_TOKEN — API token with D1:Write permissions
10
+ * CLOUDFLARE_ACCOUNT_ID — Your Cloudflare account ID
11
+ * D1_DATABASE_ID — Your platform-metrics D1 database ID
12
+ *
13
+ * Usage:
14
+ * npx tsx scripts/ops/backfill-monthly-rollups.ts
15
+ * npx tsx scripts/ops/backfill-monthly-rollups.ts --dry-run
16
+ * npx tsx scripts/ops/backfill-monthly-rollups.ts --start 2026-01 --end 2026-03
17
+ * npx tsx scripts/ops/backfill-monthly-rollups.ts --limit 12
18
+ */
19
+
20
+ const REST_API_BASE = 'https://api.cloudflare.com/client/v4';
21
+
22
+ interface Args {
23
+ start?: string;
24
+ end?: string;
25
+ dryRun: boolean;
26
+ limit: number;
27
+ }
28
+
29
+ function parseArgs(): Args {
30
+ const args = process.argv.slice(2);
31
+ const result: Args = { dryRun: false, limit: 12 };
32
+
33
+ for (let i = 0; i < args.length; i++) {
34
+ if (args[i] === '--start' && args[i + 1]) result.start = args[++i];
35
+ else if (args[i] === '--end' && args[i + 1]) result.end = args[++i];
36
+ else if (args[i] === '--limit' && args[i + 1]) result.limit = Number(args[++i]);
37
+ else if (args[i] === '--dry-run') result.dryRun = true;
38
+ }
39
+
40
+ if (!result.start) {
41
+ const d = new Date();
42
+ d.setMonth(d.getMonth() - 6);
43
+ result.start = d.toISOString().slice(0, 7);
44
+ }
45
+ if (!result.end) {
46
+ result.end = new Date().toISOString().slice(0, 7);
47
+ }
48
+
49
+ return result;
50
+ }
51
+
52
+ function getEnvOrThrow(key: string): string {
53
+ const val = process.env[key];
54
+ if (!val) throw new Error(`Missing required env var: ${key}`);
55
+ return val;
56
+ }
57
+
58
+ async function d1Query(accountId: string, dbId: string, token: string, sql: string, params: unknown[] = []) {
59
+ const res = await fetch(`${REST_API_BASE}/accounts/${accountId}/d1/database/${dbId}/query`, {
60
+ method: 'POST',
61
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ sql, params }),
63
+ });
64
+ if (!res.ok) {
65
+ const text = await res.text();
66
+ throw new Error(`D1 query failed (${res.status}): ${text}`);
67
+ }
68
+ return res.json() as Promise<{ result: Array<{ results: unknown[] }> }>;
69
+ }
70
+
71
+ async function main() {
72
+ const args = parseArgs();
73
+ const token = getEnvOrThrow('CLOUDFLARE_API_TOKEN');
74
+ const accountId = getEnvOrThrow('CLOUDFLARE_ACCOUNT_ID');
75
+ const dbId = getEnvOrThrow('D1_DATABASE_ID');
76
+
77
+ console.log(`Backfilling monthly rollups: ${args.start} → ${args.end} (limit: ${args.limit}, dry-run: ${args.dryRun})`);
78
+
79
+ // Generate month list
80
+ const months: string[] = [];
81
+ const current = new Date(args.start + '-01');
82
+ const endDate = new Date(args.end + '-01');
83
+ while (current <= endDate && months.length < args.limit) {
84
+ months.push(current.toISOString().slice(0, 7));
85
+ current.setMonth(current.getMonth() + 1);
86
+ }
87
+
88
+ let inserted = 0;
89
+ for (const month of months) {
90
+ const monthStart = `${month}-01`;
91
+ const nextMonth = new Date(monthStart);
92
+ nextMonth.setMonth(nextMonth.getMonth() + 1);
93
+ const monthEnd = nextMonth.toISOString().slice(0, 10);
94
+
95
+ if (args.dryRun) {
96
+ console.log(`[DRY-RUN] Would roll up ${month}`);
97
+ continue;
98
+ }
99
+
100
+ await d1Query(accountId, dbId, token, `
101
+ INSERT OR REPLACE INTO monthly_usage_rollups (
102
+ project, snapshot_month,
103
+ d1_reads, d1_writes, kv_reads, kv_writes,
104
+ r2_reads, r2_writes, worker_requests, total_cost_usd
105
+ )
106
+ SELECT
107
+ project, ? as snapshot_month,
108
+ SUM(d1_reads), SUM(d1_writes), SUM(kv_reads), SUM(kv_writes),
109
+ SUM(r2_reads), SUM(r2_writes), SUM(worker_requests), SUM(total_cost_usd)
110
+ FROM daily_usage_rollups
111
+ WHERE project = 'all' AND snapshot_date >= ? AND snapshot_date < ?
112
+ GROUP BY project
113
+ `, [month, monthStart, monthEnd]);
114
+
115
+ inserted++;
116
+ console.log(` Rolled up ${month} (${inserted}/${months.length})`);
117
+ }
118
+
119
+ console.log(`Done. Inserted ${inserted} monthly rollup rows.`);
120
+ }
121
+
122
+ main().catch((err) => {
123
+ console.error('Fatal error:', err);
124
+ process.exit(1);
125
+ });
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate Controls Script
4
+ *
5
+ * Validates budgets.yaml configuration:
6
+ * - No zero or negative limits
7
+ * - All features have d1_write_limit
8
+ * - Warns on suspiciously high/low values
9
+ * - Checks for orphaned entries (feature keys not in services.yaml)
10
+ *
11
+ * Usage:
12
+ * node scripts/ops/validate-controls.js
13
+ * node scripts/ops/validate-controls.js --strict
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { resolve } from 'node:path';
18
+ import { parse } from 'yaml';
19
+
20
+ const ROOT = resolve(import.meta.dirname, '..', '..');
21
+ const strict = process.argv.includes('--strict');
22
+
23
+ let exitCode = 0;
24
+ let warnings = 0;
25
+ let errors = 0;
26
+
27
+ function warn(msg) {
28
+ console.warn(` WARN: ${msg}`);
29
+ warnings++;
30
+ if (strict) exitCode = 1;
31
+ }
32
+
33
+ function error(msg) {
34
+ console.error(` ERROR: ${msg}`);
35
+ errors++;
36
+ exitCode = 1;
37
+ }
38
+
39
+ function normaliseBudgetValue(val) {
40
+ if (typeof val === 'number') return val;
41
+ if (typeof val === 'string') return Number(val.replace(/_/g, ''));
42
+ return NaN;
43
+ }
44
+
45
+ // Load budgets.yaml
46
+ let budgets;
47
+ try {
48
+ const raw = readFileSync(resolve(ROOT, 'platform/config/budgets.yaml'), 'utf-8');
49
+ budgets = parse(raw);
50
+ } catch (err) {
51
+ console.error('Failed to load budgets.yaml:', err.message);
52
+ process.exit(1);
53
+ }
54
+
55
+ // Load services.yaml for cross-reference
56
+ let services;
57
+ try {
58
+ const raw = readFileSync(resolve(ROOT, 'platform/config/services.yaml'), 'utf-8');
59
+ services = parse(raw);
60
+ } catch {
61
+ services = null;
62
+ }
63
+
64
+ console.log('Validating budgets.yaml...\n');
65
+
66
+ // Validate feature budgets
67
+ const featureBudgets = budgets?.features ?? budgets?.feature_budgets ?? {};
68
+ const featureKeys = Object.keys(featureBudgets);
69
+
70
+ console.log(`Found ${featureKeys.length} feature budget entries\n`);
71
+
72
+ for (const key of featureKeys) {
73
+ const budget = featureBudgets[key];
74
+ console.log(` Checking ${key}...`);
75
+
76
+ if (!budget || typeof budget !== 'object') {
77
+ error(`${key}: budget is not an object`);
78
+ continue;
79
+ }
80
+
81
+ // Check for zero limits
82
+ for (const [field, rawValue] of Object.entries(budget)) {
83
+ const value = normaliseBudgetValue(rawValue);
84
+ if (isNaN(value)) {
85
+ error(`${key}.${field}: value "${rawValue}" is not a valid number`);
86
+ } else if (value <= 0) {
87
+ error(`${key}.${field}: value is ${value} (must be positive)`);
88
+ } else if (value < 100 && field.includes('d1')) {
89
+ warn(`${key}.${field}: suspiciously low D1 limit (${value})`);
90
+ } else if (value > 100_000_000_000) {
91
+ warn(`${key}.${field}: suspiciously high limit (${value})`);
92
+ }
93
+ }
94
+
95
+ // Check for d1_write_limit
96
+ const hasD1Write = Object.keys(budget).some(
97
+ (f) => f.includes('d1') && (f.includes('write') || f.includes('written'))
98
+ );
99
+ if (!hasD1Write) {
100
+ warn(`${key}: no d1_write_limit or d1_rows_written field`);
101
+ }
102
+
103
+ // Check feature key format (project:category:feature)
104
+ const parts = key.split(':');
105
+ if (parts.length < 3) {
106
+ warn(`${key}: feature key should have format project:category:feature (has ${parts.length} parts)`);
107
+ }
108
+ }
109
+
110
+ // Cross-reference with services.yaml
111
+ if (services) {
112
+ const registeredProjects = new Set();
113
+ const projects = services?.projects ?? [];
114
+ for (const p of Array.isArray(projects) ? projects : Object.keys(projects)) {
115
+ const name = typeof p === 'string' ? p : p?.name ?? p?.id;
116
+ if (name) registeredProjects.add(name);
117
+ }
118
+
119
+ for (const key of featureKeys) {
120
+ const project = key.split(':')[0];
121
+ if (registeredProjects.size > 0 && !registeredProjects.has(project)) {
122
+ warn(`${key}: project "${project}" not found in services.yaml`);
123
+ }
124
+ }
125
+ }
126
+
127
+ // Global budget check
128
+ const globalBudget = budgets?.global ?? budgets?.global_budget;
129
+ if (!globalBudget) {
130
+ warn('No global budget section found');
131
+ } else {
132
+ for (const [field, rawValue] of Object.entries(globalBudget)) {
133
+ const value = normaliseBudgetValue(rawValue);
134
+ if (isNaN(value) || value <= 0) {
135
+ error(`global.${field}: invalid value "${rawValue}"`);
136
+ }
137
+ }
138
+ }
139
+
140
+ console.log(`\nResults: ${errors} errors, ${warnings} warnings`);
141
+ process.exit(exitCode);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Contract Tests for JSON Schemas
3
+ *
4
+ * Validates telemetry envelope + error report schemas against fixtures.
5
+ * Ensures contracts remain valid as schemas evolve.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ function loadJson(relativePath: string): unknown {
13
+ const fullPath = resolve(__dirname, '..', '..', relativePath);
14
+ return JSON.parse(readFileSync(fullPath, 'utf-8'));
15
+ }
16
+
17
+ // ===========================================================================
18
+ // Envelope Schema
19
+ // ===========================================================================
20
+
21
+ describe('telemetry envelope schema', () => {
22
+ const schema = loadJson('contracts/schemas/envelope.v1.schema.json') as {
23
+ required?: string[];
24
+ properties?: Record<string, unknown>;
25
+ };
26
+
27
+ it('has required version 1 schema structure', () => {
28
+ expect(schema).toBeDefined();
29
+ expect(schema.required).toBeDefined();
30
+ expect(Array.isArray(schema.required)).toBe(true);
31
+ });
32
+
33
+ it('requires core fields', () => {
34
+ const required = schema.required ?? [];
35
+ expect(required).toContain('version');
36
+ expect(required).toContain('project');
37
+ expect(required).toContain('feature');
38
+ });
39
+
40
+ it('defines resource_usage properties', () => {
41
+ const props = schema.properties ?? {};
42
+ expect(props.resource_usage).toBeDefined();
43
+ });
44
+ });
45
+
46
+ // ===========================================================================
47
+ // Valid Fixture
48
+ // ===========================================================================
49
+
50
+ describe('valid telemetry envelope fixture', () => {
51
+ const fixture = loadJson('tests/fixtures/telemetry-envelope-valid.json') as Record<string, unknown>;
52
+
53
+ it('has version 1', () => {
54
+ expect(fixture.version).toBe(1);
55
+ });
56
+
57
+ it('has valid project and feature format', () => {
58
+ expect(typeof fixture.project).toBe('string');
59
+ expect(typeof fixture.feature).toBe('string');
60
+ const feature = fixture.feature as string;
61
+ expect(feature.split(':').length).toBeGreaterThanOrEqual(3);
62
+ });
63
+
64
+ it('has resource_usage with numeric values', () => {
65
+ const usage = fixture.resource_usage as Record<string, unknown>;
66
+ expect(usage).toBeDefined();
67
+ expect(typeof usage.d1_reads).toBe('number');
68
+ expect(typeof usage.d1_writes).toBe('number');
69
+ expect(usage.d1_reads).toBeGreaterThanOrEqual(0);
70
+ expect(usage.d1_writes).toBeGreaterThanOrEqual(0);
71
+ });
72
+
73
+ it('has valid handler_type', () => {
74
+ expect(['fetch', 'scheduled', 'queue', 'alarm', 'tail']).toContain(fixture.handler_type);
75
+ });
76
+
77
+ it('has valid outcome', () => {
78
+ expect(['ok', 'error', 'exception', 'canceled']).toContain(fixture.outcome);
79
+ });
80
+ });
81
+
82
+ // ===========================================================================
83
+ // Invalid Fixture
84
+ // ===========================================================================
85
+
86
+ describe('invalid telemetry envelope fixture', () => {
87
+ const fixture = loadJson('tests/fixtures/telemetry-envelope-invalid.json') as Record<string, unknown>;
88
+
89
+ it('has wrong version', () => {
90
+ expect(fixture.version).not.toBe(1);
91
+ });
92
+
93
+ it('has empty project', () => {
94
+ expect(fixture.project).toBe('');
95
+ });
96
+
97
+ it('has malformed feature ID (missing colons)', () => {
98
+ const feature = fixture.feature as string;
99
+ expect(feature.split(':').length).toBeLessThan(3);
100
+ });
101
+
102
+ it('has invalid resource_usage values', () => {
103
+ const usage = fixture.resource_usage as Record<string, unknown>;
104
+ const hasInvalid = Object.values(usage).some(
105
+ (v) => typeof v !== 'number' || v < 0
106
+ );
107
+ expect(hasInvalid).toBe(true);
108
+ });
109
+ });
110
+
111
+ // ===========================================================================
112
+ // Error Report Schema
113
+ // ===========================================================================
114
+
115
+ describe('error report schema', () => {
116
+ const schema = loadJson('contracts/schemas/error_report.v1.schema.json') as {
117
+ required?: string[];
118
+ properties?: Record<string, unknown>;
119
+ };
120
+
121
+ it('has required schema structure', () => {
122
+ expect(schema).toBeDefined();
123
+ expect(schema.required).toBeDefined();
124
+ });
125
+
126
+ it('requires error identification fields', () => {
127
+ const required = schema.required ?? [];
128
+ expect(required.length).toBeGreaterThan(0);
129
+ });
130
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": 99,
3
+ "project": "",
4
+ "feature": "missing-colon-format",
5
+ "resource_usage": {
6
+ "d1_reads": -1,
7
+ "d1_writes": "not-a-number"
8
+ }
9
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": 1,
3
+ "timestamp": "2026-03-05T00:00:00Z",
4
+ "sdk_version": "1.4.0",
5
+ "project": "test-project",
6
+ "feature": "test-project:api:endpoint",
7
+ "handler_type": "fetch",
8
+ "outcome": "ok",
9
+ "duration_ms": 42,
10
+ "resource_usage": {
11
+ "d1_reads": 10,
12
+ "d1_writes": 2,
13
+ "kv_reads": 5,
14
+ "kv_writes": 1,
15
+ "r2_reads": 0,
16
+ "r2_writes": 0,
17
+ "ai_requests": 0,
18
+ "vectorize_reads": 0,
19
+ "queue_messages": 0,
20
+ "do_requests": 0,
21
+ "worker_subrequests": 0
22
+ },
23
+ "metadata": {
24
+ "worker_name": "test-worker",
25
+ "environment": "production"
26
+ }
27
+ }
@@ -0,0 +1,61 @@
1
+ import { vi } from 'vitest';
2
+
3
+ interface D1RunResult {
4
+ success: boolean;
5
+ results?: unknown[];
6
+ meta?: Record<string, unknown>;
7
+ }
8
+
9
+ export class MockD1Database {
10
+ private runResults: D1RunResult[] = [];
11
+ private allResults: Array<{ results: unknown[] }> = [];
12
+ private firstResults: Array<unknown | null> = [];
13
+ statements: string[] = [];
14
+ bindings: unknown[][] = [];
15
+
16
+ queueRunResult(result: D1RunResult): void {
17
+ this.runResults.push(result);
18
+ }
19
+
20
+ queueAllResult(results: unknown[]): void {
21
+ this.allResults.push({ results });
22
+ }
23
+
24
+ queueFirstResult(result: unknown | null): void {
25
+ this.firstResults.push(result);
26
+ }
27
+
28
+ prepare(sql: string) {
29
+ this.statements.push(sql);
30
+ const self = this;
31
+ let boundValues: unknown[] = [];
32
+
33
+ const stmt = {
34
+ bind(...args: unknown[]) {
35
+ boundValues = args;
36
+ self.bindings.push(args);
37
+ return stmt;
38
+ },
39
+ run: vi.fn(async () => {
40
+ return self.runResults.shift() ?? { success: true, results: [] };
41
+ }),
42
+ all: vi.fn(async () => {
43
+ return self.allResults.shift() ?? { results: [] };
44
+ }),
45
+ first: vi.fn(async () => {
46
+ return self.firstResults.shift() ?? null;
47
+ }),
48
+ };
49
+
50
+ return stmt;
51
+ }
52
+
53
+ async batch(statements: unknown[]): Promise<D1RunResult[]> {
54
+ return statements.map(() => this.runResults.shift() ?? { success: true, results: [] });
55
+ }
56
+
57
+ async exec(sql: string): Promise<D1RunResult> {
58
+ this.statements.push(sql);
59
+ return { success: true };
60
+ }
61
+ }
@@ -0,0 +1,37 @@
1
+ import { vi } from 'vitest';
2
+
3
+ export interface MockKV {
4
+ get: ReturnType<typeof vi.fn>;
5
+ put: ReturnType<typeof vi.fn>;
6
+ delete: ReturnType<typeof vi.fn>;
7
+ list: ReturnType<typeof vi.fn>;
8
+ _store: Map<string, string>;
9
+ }
10
+
11
+ export function createMockKV(initialData: Record<string, string> = {}): MockKV {
12
+ const store = new Map<string, string>(Object.entries(initialData));
13
+ return {
14
+ get: vi.fn((key: string, type?: string) => {
15
+ const raw = store.get(key) ?? null;
16
+ if (raw === null) return Promise.resolve(null);
17
+ if (type === 'json') return Promise.resolve(JSON.parse(raw));
18
+ return Promise.resolve(raw);
19
+ }),
20
+ put: vi.fn((key: string, value: string, _opts?: unknown) => {
21
+ store.set(key, typeof value === 'string' ? value : JSON.stringify(value));
22
+ return Promise.resolve();
23
+ }),
24
+ delete: vi.fn((key: string) => {
25
+ store.delete(key);
26
+ return Promise.resolve();
27
+ }),
28
+ list: vi.fn((opts: { prefix?: string; limit?: number } = {}) => {
29
+ const keys = [...store.keys()]
30
+ .filter((k) => !opts.prefix || k.startsWith(opts.prefix))
31
+ .slice(0, opts.limit ?? 1000)
32
+ .map((name) => ({ name }));
33
+ return Promise.resolve({ keys, list_complete: true });
34
+ }),
35
+ _store: store,
36
+ };
37
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Unit Tests for Batch Resource Snapshot Persistence
3
+ *
4
+ * Tests the batch D1 insert logic in persistResourceUsageSnapshots():
5
+ * - Rows are batched in groups of 25
6
+ * - Fallback to individual inserts on batch failure
7
+ * - SQL uses ON CONFLICT DO UPDATE
8
+ */
9
+
10
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
11
+ import { MockD1Database } from '../../helpers/mock-d1';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Module mocks
15
+ // ---------------------------------------------------------------------------
16
+
17
+ vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
18
+ const actual = await importOriginal();
19
+ return {
20
+ ...actual,
21
+ createLoggerFromEnv: () => ({
22
+ info: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ debug: vi.fn(),
26
+ }),
27
+ };
28
+ });
29
+
30
+ import { persistResourceUsageSnapshots } from '../../../workers/lib/usage/scheduled/data-collection';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // BatchCapturingD1 — extends MockD1Database to track batch() calls
34
+ // ---------------------------------------------------------------------------
35
+
36
+ class BatchCapturingD1 extends MockD1Database {
37
+ batchCalls: unknown[][] = [];
38
+ batchShouldFail = false;
39
+ private batchFailCount = 0;
40
+ private batchFailMax = Infinity;
41
+
42
+ failBatchTimes(n: number): void {
43
+ this.batchShouldFail = true;
44
+ this.batchFailMax = n;
45
+ this.batchFailCount = 0;
46
+ }
47
+
48
+ async batch(statements: unknown[]): Promise<unknown[]> {
49
+ this.batchCalls.push([...statements]);
50
+ if (this.batchShouldFail && this.batchFailCount < this.batchFailMax) {
51
+ this.batchFailCount++;
52
+ throw new Error('Simulated batch failure');
53
+ }
54
+ return statements.map(() => ({ success: true, results: [] }));
55
+ }
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Fixture factory
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function makeWorkerEntry(name: string) {
63
+ return {
64
+ scriptName: name,
65
+ requests: 100,
66
+ errors: 0,
67
+ cpuTimeMs: 50,
68
+ duration50thMs: 1,
69
+ duration99thMs: 5,
70
+ };
71
+ }
72
+
73
+ function makeUsageData(workerCount: number) {
74
+ return {
75
+ workers: Array.from({ length: workerCount }, (_, i) => makeWorkerEntry(`worker-${i}`)),
76
+ d1: { reads: 1000, writes: 100 },
77
+ kv: { reads: 500, writes: 50 },
78
+ r2: { reads: 10, writes: 2, storage: 1024 },
79
+ };
80
+ }
81
+
82
+ // ===========================================================================
83
+ // Tests
84
+ // ===========================================================================
85
+
86
+ describe('persistResourceUsageSnapshots', () => {
87
+ it('batches rows in groups of 25', async () => {
88
+ const db = new BatchCapturingD1();
89
+ const usageData = makeUsageData(30);
90
+
91
+ await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
92
+
93
+ // Should have called batch at least once
94
+ expect(db.batchCalls.length).toBeGreaterThanOrEqual(1);
95
+ });
96
+
97
+ it('falls back to individual inserts on batch failure', async () => {
98
+ const db = new BatchCapturingD1();
99
+ db.failBatchTimes(1);
100
+ // Queue individual run results for fallback
101
+ for (let i = 0; i < 30; i++) {
102
+ db.queueRunResult({ success: true });
103
+ }
104
+
105
+ const usageData = makeUsageData(5);
106
+
107
+ await expect(
108
+ persistResourceUsageSnapshots(db as never, usageData as never, 'all')
109
+ ).resolves.not.toThrow();
110
+ });
111
+
112
+ it('handles empty usage data', async () => {
113
+ const db = new BatchCapturingD1();
114
+ const usageData = makeUsageData(0);
115
+
116
+ await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
117
+
118
+ // Should not fail with empty data
119
+ expect(db.batchCalls.length).toBe(0);
120
+ });
121
+
122
+ it('uses ON CONFLICT in SQL statements', async () => {
123
+ const db = new BatchCapturingD1();
124
+ const usageData = makeUsageData(1);
125
+
126
+ await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
127
+
128
+ const hasConflict = db.statements.some((s: string) =>
129
+ s.toUpperCase().includes('ON CONFLICT')
130
+ );
131
+ expect(hasConflict).toBe(true);
132
+ });
133
+ });