@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.
- package/README.md +112 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +89 -0
- package/dist/prompts.d.ts +27 -0
- package/dist/prompts.js +80 -0
- package/dist/scaffold.d.ts +5 -0
- package/dist/scaffold.js +65 -0
- package/dist/templates.d.ts +16 -0
- package/dist/templates.js +131 -0
- package/package.json +46 -0
- package/templates/full/migrations/006_pattern_discovery.sql +199 -0
- package/templates/full/migrations/007_notifications_search.sql +127 -0
- package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
- package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
- package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
- package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
- package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
- package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
- package/templates/full/workers/pattern-discovery.ts +661 -0
- package/templates/full/workers/platform-alert-router.ts +1809 -0
- package/templates/full/workers/platform-notifications.ts +424 -0
- package/templates/full/workers/platform-search.ts +480 -0
- package/templates/full/workers/platform-settings.ts +436 -0
- package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
- package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
- package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
- package/templates/full/wrangler.search.jsonc.hbs +16 -0
- package/templates/full/wrangler.settings.jsonc.hbs +23 -0
- package/templates/shared/README.md.hbs +69 -0
- package/templates/shared/config/budgets.yaml.hbs +72 -0
- package/templates/shared/config/services.yaml.hbs +45 -0
- package/templates/shared/migrations/001_core_tables.sql +117 -0
- package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
- package/templates/shared/migrations/003_feature_tracking.sql +250 -0
- package/templates/shared/migrations/004_settings_alerts.sql +452 -0
- package/templates/shared/migrations/seed.sql.hbs +4 -0
- package/templates/shared/package.json.hbs +21 -0
- package/templates/shared/scripts/sync-config.ts +242 -0
- package/templates/shared/tsconfig.json +12 -0
- package/templates/shared/workers/lib/analytics-engine.ts +357 -0
- package/templates/shared/workers/lib/billing.ts +293 -0
- package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
- package/templates/shared/workers/lib/control.ts +292 -0
- package/templates/shared/workers/lib/economics.ts +368 -0
- package/templates/shared/workers/lib/metrics.ts +103 -0
- package/templates/shared/workers/lib/platform-settings.ts +407 -0
- package/templates/shared/workers/lib/shared/allowances.ts +333 -0
- package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
- package/templates/shared/workers/lib/shared/types.ts +58 -0
- package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
- package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
- package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
- package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
- package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
- package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
- package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
- package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
- package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
- package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
- package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
- package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
- package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
- package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
- package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
- package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
- package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
- package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
- package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
- package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
- package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
- package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
- package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
- package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
- package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
- package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
- package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
- package/templates/shared/workers/platform-usage.ts +1915 -0
- package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
- package/templates/standard/migrations/005_error_collection.sql +162 -0
- package/templates/standard/workers/error-collector.ts +2670 -0
- package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
- package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
- package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
- package/templates/standard/workers/lib/error-collector/github.ts +329 -0
- package/templates/standard/workers/lib/error-collector/types.ts +262 -0
- package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
- package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
- package/templates/standard/workers/platform-sentinel.ts +1744 -0
- package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
- package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Search Worker
|
|
3
|
+
*
|
|
4
|
+
* Platform-wide full-text search using SQLite FTS5.
|
|
5
|
+
* Searches across errors, patterns, settings, pages, and services.
|
|
6
|
+
*
|
|
7
|
+
* Storage:
|
|
8
|
+
* - D1: search_index table with FTS5 virtual table
|
|
9
|
+
*
|
|
10
|
+
* @module workers/platform-search
|
|
11
|
+
* @created 2026-02-03
|
|
12
|
+
* @task task-303.1
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
KVNamespace,
|
|
17
|
+
ExecutionContext,
|
|
18
|
+
D1Database,
|
|
19
|
+
} from '@cloudflare/workers-types';
|
|
20
|
+
import {
|
|
21
|
+
withFeatureBudget,
|
|
22
|
+
CircuitBreakerError,
|
|
23
|
+
createLoggerFromRequest,
|
|
24
|
+
} from '@littlebearapps/platform-consumer-sdk';
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// TYPES
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
interface Env {
|
|
31
|
+
PLATFORM_DB: D1Database;
|
|
32
|
+
PLATFORM_CACHE: KVNamespace;
|
|
33
|
+
CLOUDFLARE_ACCOUNT_ID: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SearchDocument {
|
|
37
|
+
id: string;
|
|
38
|
+
content_type: string;
|
|
39
|
+
project: string | null;
|
|
40
|
+
title: string;
|
|
41
|
+
content: string;
|
|
42
|
+
url: string;
|
|
43
|
+
metadata: string | null;
|
|
44
|
+
indexed_at: number;
|
|
45
|
+
source_updated_at: number | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SearchResult extends SearchDocument {
|
|
49
|
+
rank: number;
|
|
50
|
+
snippet: string;
|
|
51
|
+
parsed_metadata?: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface IndexDocumentRequest {
|
|
55
|
+
id: string;
|
|
56
|
+
content_type: string;
|
|
57
|
+
project?: string;
|
|
58
|
+
title: string;
|
|
59
|
+
content: string;
|
|
60
|
+
url: string;
|
|
61
|
+
metadata?: Record<string, unknown>;
|
|
62
|
+
source_updated_at?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// CONSTANTS
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
const FEATURE_ID = 'platform:search:api';
|
|
70
|
+
const VALID_CONTENT_TYPES = ['error', 'pattern', 'setting', 'page', 'service', 'opportunity', 'draft', 'project'];
|
|
71
|
+
const MAX_RESULTS = 100;
|
|
72
|
+
const DEFAULT_LIMIT = 20;
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// HELPERS
|
|
76
|
+
// =============================================================================
|
|
77
|
+
|
|
78
|
+
function sanitizeQuery(query: string): string {
|
|
79
|
+
// Escape special FTS5 characters and prepare for MATCH
|
|
80
|
+
// Remove potentially dangerous characters while preserving search intent
|
|
81
|
+
return query
|
|
82
|
+
.replace(/['"]/g, '') // Remove quotes
|
|
83
|
+
.replace(/[-+*()]/g, ' ') // Replace operators with spaces
|
|
84
|
+
.trim()
|
|
85
|
+
.split(/\s+/)
|
|
86
|
+
.filter((term) => term.length > 0)
|
|
87
|
+
.map((term) => `"${term}"*`) // Prefix match each term
|
|
88
|
+
.join(' ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractSnippet(content: string, query: string, maxLength: number = 200): string {
|
|
92
|
+
const lowerContent = content.toLowerCase();
|
|
93
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 2);
|
|
94
|
+
|
|
95
|
+
// Find the first matching term
|
|
96
|
+
let startIndex = 0;
|
|
97
|
+
for (const term of terms) {
|
|
98
|
+
const index = lowerContent.indexOf(term);
|
|
99
|
+
if (index !== -1) {
|
|
100
|
+
startIndex = Math.max(0, index - 50);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Extract snippet around the match
|
|
106
|
+
let snippet = content.substring(startIndex, startIndex + maxLength);
|
|
107
|
+
|
|
108
|
+
// Add ellipsis if truncated
|
|
109
|
+
if (startIndex > 0) {
|
|
110
|
+
snippet = '...' + snippet;
|
|
111
|
+
}
|
|
112
|
+
if (startIndex + maxLength < content.length) {
|
|
113
|
+
snippet = snippet + '...';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return snippet;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// API HANDLERS
|
|
121
|
+
// =============================================================================
|
|
122
|
+
|
|
123
|
+
async function handleSearch(env: Env, url: URL): Promise<Response> {
|
|
124
|
+
const query = url.searchParams.get('q');
|
|
125
|
+
if (!query || query.trim().length === 0) {
|
|
126
|
+
return Response.json({ error: 'Query parameter q is required' }, { status: 400 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const contentType = url.searchParams.get('type');
|
|
130
|
+
const project = url.searchParams.get('project');
|
|
131
|
+
const limit = Math.min(
|
|
132
|
+
parseInt(url.searchParams.get('limit') || String(DEFAULT_LIMIT), 10),
|
|
133
|
+
MAX_RESULTS
|
|
134
|
+
);
|
|
135
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
136
|
+
|
|
137
|
+
// Sanitize query for FTS5
|
|
138
|
+
const sanitizedQuery = sanitizeQuery(query);
|
|
139
|
+
if (sanitizedQuery.length === 0) {
|
|
140
|
+
return Response.json({ results: [], count: 0, query });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Build the search query
|
|
144
|
+
// FTS5 MATCH query with ranking by bm25
|
|
145
|
+
let sql = `
|
|
146
|
+
SELECT
|
|
147
|
+
search_index.*,
|
|
148
|
+
bm25(search_fts) as rank
|
|
149
|
+
FROM search_fts
|
|
150
|
+
JOIN search_index ON search_fts.rowid = search_index.rowid
|
|
151
|
+
WHERE search_fts MATCH ?
|
|
152
|
+
`;
|
|
153
|
+
const params: (string | number)[] = [sanitizedQuery];
|
|
154
|
+
|
|
155
|
+
if (contentType && VALID_CONTENT_TYPES.includes(contentType)) {
|
|
156
|
+
sql += ' AND search_index.content_type = ?';
|
|
157
|
+
params.push(contentType);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (project) {
|
|
161
|
+
sql += ' AND (search_index.project = ? OR search_index.project IS NULL)';
|
|
162
|
+
params.push(project);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
sql += ' ORDER BY rank LIMIT ? OFFSET ?';
|
|
166
|
+
params.push(limit, offset);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const result = await env.PLATFORM_DB.prepare(sql).bind(...params).all<SearchDocument & { rank: number }>();
|
|
170
|
+
const documents = result.results || [];
|
|
171
|
+
|
|
172
|
+
// Enrich results with snippets and parsed metadata
|
|
173
|
+
const results: SearchResult[] = documents.map((doc) => ({
|
|
174
|
+
...doc,
|
|
175
|
+
snippet: extractSnippet(doc.content, query),
|
|
176
|
+
parsed_metadata: doc.metadata ? JSON.parse(doc.metadata) : undefined,
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
// Group by content type for UI
|
|
180
|
+
const grouped: Record<string, SearchResult[]> = {};
|
|
181
|
+
for (const result of results) {
|
|
182
|
+
if (!grouped[result.content_type]) {
|
|
183
|
+
grouped[result.content_type] = [];
|
|
184
|
+
}
|
|
185
|
+
grouped[result.content_type].push(result);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return Response.json({
|
|
189
|
+
results,
|
|
190
|
+
grouped,
|
|
191
|
+
count: results.length,
|
|
192
|
+
query,
|
|
193
|
+
filters: {
|
|
194
|
+
type: contentType,
|
|
195
|
+
project,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// FTS5 query errors are common with malformed input
|
|
200
|
+
console.error('Search error:', error);
|
|
201
|
+
return Response.json({
|
|
202
|
+
results: [],
|
|
203
|
+
count: 0,
|
|
204
|
+
query,
|
|
205
|
+
error: 'Search query could not be processed',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function handleIndex(request: Request, env: Env): Promise<Response> {
|
|
211
|
+
const body = (await request.json()) as IndexDocumentRequest;
|
|
212
|
+
|
|
213
|
+
if (!body.id || !body.content_type || !body.title || !body.content || !body.url) {
|
|
214
|
+
return Response.json(
|
|
215
|
+
{ error: 'Missing required fields: id, content_type, title, content, url' },
|
|
216
|
+
{ status: 400 }
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!VALID_CONTENT_TYPES.includes(body.content_type)) {
|
|
221
|
+
return Response.json(
|
|
222
|
+
{ error: `Invalid content_type. Must be one of: ${VALID_CONTENT_TYPES.join(', ')}` },
|
|
223
|
+
{ status: 400 }
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const now = Math.floor(Date.now() / 1000);
|
|
228
|
+
const metadataJson = body.metadata ? JSON.stringify(body.metadata) : null;
|
|
229
|
+
|
|
230
|
+
// Upsert the document (triggers will handle FTS sync)
|
|
231
|
+
await env.PLATFORM_DB.prepare(
|
|
232
|
+
`INSERT INTO search_index (id, content_type, project, title, content, url, metadata, indexed_at, source_updated_at)
|
|
233
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
234
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
235
|
+
content_type = excluded.content_type,
|
|
236
|
+
project = excluded.project,
|
|
237
|
+
title = excluded.title,
|
|
238
|
+
content = excluded.content,
|
|
239
|
+
url = excluded.url,
|
|
240
|
+
metadata = excluded.metadata,
|
|
241
|
+
indexed_at = excluded.indexed_at,
|
|
242
|
+
source_updated_at = excluded.source_updated_at`
|
|
243
|
+
)
|
|
244
|
+
.bind(
|
|
245
|
+
body.id,
|
|
246
|
+
body.content_type,
|
|
247
|
+
body.project || null,
|
|
248
|
+
body.title,
|
|
249
|
+
body.content,
|
|
250
|
+
body.url,
|
|
251
|
+
metadataJson,
|
|
252
|
+
now,
|
|
253
|
+
body.source_updated_at || null
|
|
254
|
+
)
|
|
255
|
+
.run();
|
|
256
|
+
|
|
257
|
+
return Response.json({ success: true, id: body.id }, { status: 201 });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function handleBulkIndex(request: Request, env: Env): Promise<Response> {
|
|
261
|
+
const body = (await request.json()) as { documents: IndexDocumentRequest[] };
|
|
262
|
+
|
|
263
|
+
if (!body.documents || !Array.isArray(body.documents)) {
|
|
264
|
+
return Response.json({ error: 'Missing documents array' }, { status: 400 });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const now = Math.floor(Date.now() / 1000);
|
|
268
|
+
let indexed = 0;
|
|
269
|
+
let failed = 0;
|
|
270
|
+
|
|
271
|
+
for (const doc of body.documents) {
|
|
272
|
+
if (!doc.id || !doc.content_type || !doc.title || !doc.content || !doc.url) {
|
|
273
|
+
failed++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!VALID_CONTENT_TYPES.includes(doc.content_type)) {
|
|
277
|
+
failed++;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const metadataJson = doc.metadata ? JSON.stringify(doc.metadata) : null;
|
|
283
|
+
await env.PLATFORM_DB.prepare(
|
|
284
|
+
`INSERT INTO search_index (id, content_type, project, title, content, url, metadata, indexed_at, source_updated_at)
|
|
285
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
286
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
287
|
+
content_type = excluded.content_type,
|
|
288
|
+
project = excluded.project,
|
|
289
|
+
title = excluded.title,
|
|
290
|
+
content = excluded.content,
|
|
291
|
+
url = excluded.url,
|
|
292
|
+
metadata = excluded.metadata,
|
|
293
|
+
indexed_at = excluded.indexed_at,
|
|
294
|
+
source_updated_at = excluded.source_updated_at`
|
|
295
|
+
)
|
|
296
|
+
.bind(
|
|
297
|
+
doc.id,
|
|
298
|
+
doc.content_type,
|
|
299
|
+
doc.project || null,
|
|
300
|
+
doc.title,
|
|
301
|
+
doc.content,
|
|
302
|
+
doc.url,
|
|
303
|
+
metadataJson,
|
|
304
|
+
now,
|
|
305
|
+
doc.source_updated_at || null
|
|
306
|
+
)
|
|
307
|
+
.run();
|
|
308
|
+
indexed++;
|
|
309
|
+
} catch {
|
|
310
|
+
failed++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return Response.json({
|
|
315
|
+
success: failed === 0,
|
|
316
|
+
indexed,
|
|
317
|
+
failed,
|
|
318
|
+
total: body.documents.length,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function handleReindex(env: Env, contentType: string): Promise<Response> {
|
|
323
|
+
if (!VALID_CONTENT_TYPES.includes(contentType)) {
|
|
324
|
+
return Response.json(
|
|
325
|
+
{ error: `Invalid content_type. Must be one of: ${VALID_CONTENT_TYPES.join(', ')}` },
|
|
326
|
+
{ status: 400 }
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Delete all documents of this type (triggers will clean up FTS)
|
|
331
|
+
const result = await env.PLATFORM_DB.prepare(
|
|
332
|
+
'DELETE FROM search_index WHERE content_type = ?'
|
|
333
|
+
)
|
|
334
|
+
.bind(contentType)
|
|
335
|
+
.run();
|
|
336
|
+
|
|
337
|
+
return Response.json({
|
|
338
|
+
success: true,
|
|
339
|
+
content_type: contentType,
|
|
340
|
+
deleted: result.meta.changes,
|
|
341
|
+
message: 'Index cleared. Documents must be re-indexed by their source workers.',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function handleDelete(env: Env, id: string): Promise<Response> {
|
|
346
|
+
const result = await env.PLATFORM_DB.prepare('DELETE FROM search_index WHERE id = ?')
|
|
347
|
+
.bind(id)
|
|
348
|
+
.run();
|
|
349
|
+
|
|
350
|
+
if (result.meta.changes === 0) {
|
|
351
|
+
return Response.json({ error: 'Document not found' }, { status: 404 });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return Response.json({ success: true, deleted: id });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function handleStats(env: Env): Promise<Response> {
|
|
358
|
+
// Get counts by content type
|
|
359
|
+
const typeStats = await env.PLATFORM_DB.prepare(
|
|
360
|
+
`SELECT content_type, COUNT(*) as count
|
|
361
|
+
FROM search_index
|
|
362
|
+
GROUP BY content_type
|
|
363
|
+
ORDER BY count DESC`
|
|
364
|
+
).all<{ content_type: string; count: number }>();
|
|
365
|
+
|
|
366
|
+
// Get counts by project
|
|
367
|
+
const projectStats = await env.PLATFORM_DB.prepare(
|
|
368
|
+
`SELECT COALESCE(project, 'global') as project, COUNT(*) as count
|
|
369
|
+
FROM search_index
|
|
370
|
+
GROUP BY project
|
|
371
|
+
ORDER BY count DESC`
|
|
372
|
+
).all<{ project: string; count: number }>();
|
|
373
|
+
|
|
374
|
+
// Get total count
|
|
375
|
+
const totalResult = await env.PLATFORM_DB.prepare(
|
|
376
|
+
'SELECT COUNT(*) as count FROM search_index'
|
|
377
|
+
).first<{ count: number }>();
|
|
378
|
+
|
|
379
|
+
// Get oldest and newest indexed
|
|
380
|
+
const rangeResult = await env.PLATFORM_DB.prepare(
|
|
381
|
+
'SELECT MIN(indexed_at) as oldest, MAX(indexed_at) as newest FROM search_index'
|
|
382
|
+
).first<{ oldest: number; newest: number }>();
|
|
383
|
+
|
|
384
|
+
return Response.json({
|
|
385
|
+
total: totalResult?.count || 0,
|
|
386
|
+
by_type: typeStats.results || [],
|
|
387
|
+
by_project: projectStats.results || [],
|
|
388
|
+
index_range: {
|
|
389
|
+
oldest: rangeResult?.oldest ? new Date(rangeResult.oldest * 1000).toISOString() : null,
|
|
390
|
+
newest: rangeResult?.newest ? new Date(rangeResult.newest * 1000).toISOString() : null,
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// =============================================================================
|
|
396
|
+
// MAIN WORKER
|
|
397
|
+
// =============================================================================
|
|
398
|
+
|
|
399
|
+
export default {
|
|
400
|
+
async fetch(
|
|
401
|
+
request: Request,
|
|
402
|
+
env: Env,
|
|
403
|
+
ctx: ExecutionContext
|
|
404
|
+
): Promise<Response> {
|
|
405
|
+
const url = new URL(request.url);
|
|
406
|
+
|
|
407
|
+
// Health check (lightweight)
|
|
408
|
+
if (url.pathname === '/health') {
|
|
409
|
+
return Response.json({
|
|
410
|
+
status: 'ok',
|
|
411
|
+
service: 'platform-search',
|
|
412
|
+
timestamp: new Date().toISOString(),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const log = createLoggerFromRequest(request, env, 'platform-search', FEATURE_ID);
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const trackedEnv = withFeatureBudget(env, FEATURE_ID, { ctx });
|
|
420
|
+
|
|
421
|
+
// GET /search - Perform search
|
|
422
|
+
if (url.pathname === '/search' && request.method === 'GET') {
|
|
423
|
+
return await handleSearch(trackedEnv, url);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// POST /search/index - Index a document
|
|
427
|
+
if (url.pathname === '/search/index' && request.method === 'POST') {
|
|
428
|
+
return await handleIndex(request, trackedEnv);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// POST /search/index/bulk - Bulk index documents
|
|
432
|
+
if (url.pathname === '/search/index/bulk' && request.method === 'POST') {
|
|
433
|
+
return await handleBulkIndex(request, trackedEnv);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// POST /search/reindex/:type - Clear and reindex a content type
|
|
437
|
+
const reindexMatch = url.pathname.match(/^\/search\/reindex\/([^/]+)$/);
|
|
438
|
+
if (reindexMatch && request.method === 'POST') {
|
|
439
|
+
return await handleReindex(trackedEnv, reindexMatch[1]);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// DELETE /search/index/:id - Delete a document
|
|
443
|
+
const deleteMatch = url.pathname.match(/^\/search\/index\/([^/]+)$/);
|
|
444
|
+
if (deleteMatch && request.method === 'DELETE') {
|
|
445
|
+
return await handleDelete(trackedEnv, deleteMatch[1]);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// GET /search/stats - Get index statistics
|
|
449
|
+
if (url.pathname === '/search/stats' && request.method === 'GET') {
|
|
450
|
+
return await handleStats(trackedEnv);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// API index
|
|
454
|
+
return Response.json({
|
|
455
|
+
service: 'platform-search',
|
|
456
|
+
version: '1.0.0',
|
|
457
|
+
endpoints: [
|
|
458
|
+
'GET /health - Health check',
|
|
459
|
+
'GET /search?q=<query> - Search (with optional type, project, limit, offset)',
|
|
460
|
+
'POST /search/index - Index a document',
|
|
461
|
+
'POST /search/index/bulk - Bulk index documents',
|
|
462
|
+
'POST /search/reindex/:type - Clear index for content type',
|
|
463
|
+
'DELETE /search/index/:id - Delete a document',
|
|
464
|
+
'GET /search/stats - Index statistics',
|
|
465
|
+
],
|
|
466
|
+
valid_content_types: VALID_CONTENT_TYPES,
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error instanceof CircuitBreakerError) {
|
|
470
|
+
log.warn('Circuit breaker tripped', error);
|
|
471
|
+
return Response.json(
|
|
472
|
+
{ error: 'Service temporarily unavailable' },
|
|
473
|
+
{ status: 503, headers: { 'Retry-After': '60' } }
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
log.error('Request failed', error);
|
|
477
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
};
|