import type { AuthorRow, PluginAuditRow, PluginImageAuditRow, PluginRow, PluginSearchResult, PluginVersionRow, PluginWithAuthor, SearchOptions, ThemeRow, ThemeSearchOptions, ThemeWithAuthor, VersionStatus, } from "./types.js"; const RE_DASHES = /-/g; function generateId(): string { return crypto.randomUUID().replace(RE_DASHES, ""); } const DEFAULT_LIMIT = 20; const MAX_LIMIT = 100; function clampLimit(limit?: number): number { if (!limit || limit < 1) return DEFAULT_LIMIT; return Math.min(limit, MAX_LIMIT); } function encodeCursor(offset: number): string { return btoa(String(offset)); } function decodeCursor(cursor?: string): number { if (!cursor) return 0; try { const decoded = atob(cursor); const offset = parseInt(decoded, 10); return Number.isNaN(offset) || offset < 0 ? 0 : offset; } catch { return 0; } } // ── Plugin queries ────────────────────────────────────────────── export async function getPlugin(db: D1Database, id: string): Promise { return db.prepare("SELECT * FROM plugins WHERE id = ?").bind(id).first(); } export async function getPluginWithAuthor( db: D1Database, id: string, ): Promise { return db .prepare( `SELECT p.*, a.name AS author_name, a.avatar_url AS author_avatar_url, a.verified AS author_verified FROM plugins p JOIN authors a ON a.id = p.author_id WHERE p.id = ?`, ) .bind(id) .first(); } export async function searchPlugins( db: D1Database, opts: SearchOptions, ): Promise<{ items: PluginSearchResult[]; nextCursor?: string }> { const limit = clampLimit(opts.limit); const offset = decodeCursor(opts.cursor); const conditions: string[] = []; const bindings: unknown[] = []; if (opts.q) { conditions.push("(p.name LIKE ? OR p.description LIKE ? OR p.keywords LIKE ?)"); const pattern = `%${opts.q}%`; bindings.push(pattern, pattern, pattern); } if (opts.capability) { conditions.push("EXISTS (SELECT 1 FROM json_each(p.capabilities) WHERE json_each.value = ?)"); bindings.push(opts.capability); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; let orderBy: string; switch (opts.sort) { case "name": orderBy = "p.name ASC"; break; case "created": orderBy = "p.created_at DESC"; break; case "updated": orderBy = "p.updated_at DESC"; break; case "installs": default: orderBy = "p.install_count DESC, p.created_at DESC"; break; } const query = ` SELECT p.*, a.name AS author_name, a.avatar_url AS author_avatar_url, a.verified AS author_verified, lv.version AS latest_version, lv.status AS latest_status, lv.audit_verdict AS latest_audit_verdict, lv.image_audit_verdict AS latest_image_audit_verdict, pa.risk_score AS latest_audit_risk_score FROM plugins p JOIN authors a ON a.id = p.author_id JOIN ( SELECT pv.* FROM plugin_versions pv JOIN ( SELECT plugin_id, MAX(published_at) AS published_at FROM plugin_versions WHERE status IN ('published', 'flagged') GROUP BY plugin_id ) latest ON latest.plugin_id = pv.plugin_id AND latest.published_at = pv.published_at WHERE pv.status IN ('published', 'flagged') ) lv ON lv.plugin_id = p.id LEFT JOIN plugin_audits pa ON pa.id = lv.audit_id ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`; bindings.push(limit + 1, offset); const result = await db .prepare(query) .bind(...bindings) .all(); const items = result.results ?? []; let nextCursor: string | undefined; if (items.length > limit) { items.pop(); nextCursor = encodeCursor(offset + limit); } return { items, nextCursor }; } // ── Version queries ───────────────────────────────────────────── /** Public-facing: only returns published/flagged versions. */ export async function getPluginVersions( db: D1Database, pluginId: string, ): Promise { const result = await db .prepare( "SELECT * FROM plugin_versions WHERE plugin_id = ? AND status IN ('published', 'flagged') ORDER BY published_at DESC", ) .bind(pluginId) .all(); return result.results ?? []; } /** Returns all versions regardless of status (for author dashboard). */ export async function getAllPluginVersions( db: D1Database, pluginId: string, ): Promise { const result = await db .prepare("SELECT * FROM plugin_versions WHERE plugin_id = ? ORDER BY published_at DESC") .bind(pluginId) .all(); return result.results ?? []; } /** Public-facing: only returns the latest published/flagged version. */ export async function getLatestVersion( db: D1Database, pluginId: string, ): Promise { return db .prepare( "SELECT * FROM plugin_versions WHERE plugin_id = ? AND status IN ('published', 'flagged') ORDER BY published_at DESC LIMIT 1", ) .bind(pluginId) .first(); } export async function getPluginVersion( db: D1Database, pluginId: string, version: string, ): Promise { return db .prepare("SELECT * FROM plugin_versions WHERE plugin_id = ? AND version = ?") .bind(pluginId, version) .first(); } // ── Install queries ───────────────────────────────────────────── export async function upsertInstall( db: D1Database, data: { pluginId: string; siteHash: string; version: string }, ): Promise { // Run the install upsert and install_count recomputation together so the // plugin count stays consistent with the installs table. await db.batch([ db .prepare( `INSERT INTO installs (plugin_id, site_hash, version) VALUES (?, ?, ?) ON CONFLICT (plugin_id, site_hash) DO UPDATE SET version = excluded.version, installed_at = datetime('now')`, ) .bind(data.pluginId, data.siteHash, data.version), db .prepare( `UPDATE plugins SET install_count = (SELECT COUNT(*) FROM installs WHERE plugin_id = ?) WHERE id = ?`, ) .bind(data.pluginId, data.pluginId), ]); } // ── Write queries ─────────────────────────────────────────────── export async function createPlugin( db: D1Database, data: { id: string; name: string; description?: string; authorId: string; repositoryUrl?: string; homepageUrl?: string; license?: string; capabilities: string[]; keywords?: string[]; }, ): Promise { const id = data.id; const now = new Date().toISOString(); await db .prepare( `INSERT INTO plugins (id, name, description, author_id, repository_url, homepage_url, license, capabilities, keywords, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( id, data.name, data.description ?? null, data.authorId, data.repositoryUrl ?? null, data.homepageUrl ?? null, data.license ?? null, JSON.stringify(data.capabilities), data.keywords ? JSON.stringify(data.keywords) : null, now, now, ) .run(); return (await getPlugin(db, id))!; } export async function createVersion( db: D1Database, data: { pluginId: string; version: string; minEmDashVersion?: string; bundleKey: string; bundleSize: number; checksum: string; changelog?: string; readme?: string; hasIcon?: boolean; screenshotCount?: number; capabilities: string[]; status?: VersionStatus; }, ): Promise { const id = generateId(); await db .prepare( `INSERT INTO plugin_versions (id, plugin_id, version, min_emdash_version, bundle_key, bundle_size, checksum, changelog, readme, has_icon, screenshot_count, capabilities, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( id, data.pluginId, data.version, data.minEmDashVersion ?? null, data.bundleKey, data.bundleSize, data.checksum, data.changelog ?? null, data.readme ?? null, data.hasIcon ? 1 : 0, data.screenshotCount ?? 0, JSON.stringify(data.capabilities), data.status ?? "pending", ) .run(); return (await db .prepare("SELECT * FROM plugin_versions WHERE id = ?") .bind(id) .first())!; } /** * Update an existing version row for seed re-publishing. * Re-uploads overwrite the R2 bundle, so the DB row must match. */ export async function updateVersionForReseed( db: D1Database, versionId: string, data: { bundleKey: string; bundleSize: number; checksum: string; changelog?: string; readme?: string; hasIcon?: boolean; screenshotCount?: number; capabilities: string[]; }, ): Promise { await db .prepare( `UPDATE plugin_versions SET bundle_key = ?, bundle_size = ?, checksum = ?, changelog = ?, readme = ?, has_icon = ?, screenshot_count = ?, capabilities = ?, status = 'published', published_at = datetime('now') WHERE id = ?`, ) .bind( data.bundleKey, data.bundleSize, data.checksum, data.changelog ?? null, data.readme ?? null, data.hasIcon ? 1 : 0, data.screenshotCount ?? 0, JSON.stringify(data.capabilities), versionId, ) .run(); } /** Update a version's status (used after audit completes). */ export async function updateVersionStatus( db: D1Database, versionId: string, status: VersionStatus, ): Promise { await db .prepare("UPDATE plugin_versions SET status = ? WHERE id = ?") .bind(status, versionId) .run(); } /** Store the Workflow instance ID on a version row. */ export async function setVersionWorkflowId( db: D1Database, versionId: string, workflowId: string, ): Promise { await db .prepare("UPDATE plugin_versions SET workflow_id = ? WHERE id = ?") .bind(workflowId, versionId) .run(); } export async function updatePlugin( db: D1Database, id: string, data: { name?: string; description?: string; repositoryUrl?: string; homepageUrl?: string; license?: string; capabilities?: string[]; keywords?: string[]; hasIcon?: boolean; }, ): Promise { const sets: string[] = []; const bindings: unknown[] = []; if (data.name !== undefined) { sets.push("name = ?"); bindings.push(data.name); } if (data.description !== undefined) { sets.push("description = ?"); bindings.push(data.description); } if (data.repositoryUrl !== undefined) { sets.push("repository_url = ?"); bindings.push(data.repositoryUrl); } if (data.homepageUrl !== undefined) { sets.push("homepage_url = ?"); bindings.push(data.homepageUrl); } if (data.license !== undefined) { sets.push("license = ?"); bindings.push(data.license); } if (data.capabilities !== undefined) { sets.push("capabilities = ?"); bindings.push(JSON.stringify(data.capabilities)); } if (data.keywords !== undefined) { sets.push("keywords = ?"); bindings.push(JSON.stringify(data.keywords)); } if (data.hasIcon !== undefined) { sets.push("has_icon = ?"); bindings.push(data.hasIcon ? 1 : 0); } if (sets.length === 0) return getPlugin(db, id); sets.push("updated_at = datetime('now')"); bindings.push(id); await db .prepare(`UPDATE plugins SET ${sets.join(", ")} WHERE id = ?`) .bind(...bindings) .run(); return getPlugin(db, id); } // ── Author queries ────────────────────────────────────────────── export async function createAuthor( db: D1Database, data: { githubId: string; name: string; email?: string; avatarUrl?: string; }, ): Promise { const id = generateId(); await db .prepare(`INSERT INTO authors (id, github_id, name, email, avatar_url) VALUES (?, ?, ?, ?, ?)`) .bind(id, data.githubId, data.name, data.email ?? null, data.avatarUrl ?? null) .run(); return (await db.prepare("SELECT * FROM authors WHERE id = ?").bind(id).first())!; } export async function getAuthorByGithubId( db: D1Database, githubId: string, ): Promise { return db.prepare("SELECT * FROM authors WHERE github_id = ?").bind(githubId).first(); } const SYSTEM_AUTHOR_ID = "system"; /** * Find or create the system author used for seed token publishing. * The system author has no GitHub account -- it represents first-party * plugins published via the SEED_TOKEN in CI. */ export async function findOrCreateSystemAuthor(db: D1Database): Promise { // INSERT OR IGNORE handles concurrent creation safely (no TOCTOU race). await db .prepare( "INSERT OR IGNORE INTO authors (id, github_id, name, email, avatar_url, verified) VALUES (?, NULL, ?, NULL, NULL, 1)", ) .bind(SYSTEM_AUTHOR_ID, "EmDash") .run(); return (await db .prepare("SELECT * FROM authors WHERE id = ?") .bind(SYSTEM_AUTHOR_ID) .first())!; } // ── Audit queries ─────────────────────────────────────────────── export async function createAudit( db: D1Database, data: { pluginId: string; version: string; verdict: string; riskScore: number; summary: string; findings: unknown[]; model: string; durationMs: number; }, ): Promise { const id = generateId(); await db .prepare( `INSERT INTO plugin_audits (id, plugin_id, version, verdict, risk_score, summary, findings, model, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( id, data.pluginId, data.version, data.verdict, data.riskScore, data.summary, JSON.stringify(data.findings), data.model, data.durationMs, ) .run(); return (await db .prepare("SELECT * FROM plugin_audits WHERE id = ?") .bind(id) .first())!; } export async function createImageAudit( db: D1Database, data: { pluginId: string; version: string; verdict: string; findings: unknown[]; model: string; durationMs: number; }, ): Promise { const id = generateId(); await db .prepare( `INSERT INTO plugin_image_audits (id, plugin_id, version, verdict, findings, model, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .bind( id, data.pluginId, data.version, data.verdict, JSON.stringify(data.findings), data.model, data.durationMs, ) .run(); return (await db .prepare("SELECT * FROM plugin_image_audits WHERE id = ?") .bind(id) .first())!; } export async function linkAuditToVersion( db: D1Database, versionId: string, auditId: string, verdict: string, ): Promise { await db .prepare("UPDATE plugin_versions SET audit_id = ?, audit_verdict = ? WHERE id = ?") .bind(auditId, verdict, versionId) .run(); } export async function linkImageAuditToVersion( db: D1Database, versionId: string, imageAuditId: string, verdict: string, ): Promise { await db .prepare("UPDATE plugin_versions SET image_audit_id = ?, image_audit_verdict = ? WHERE id = ?") .bind(imageAuditId, verdict, versionId) .run(); } // ── Theme queries ─────────────────────────────────────────────── export async function getTheme(db: D1Database, id: string): Promise { return db.prepare("SELECT * FROM themes WHERE id = ?").bind(id).first(); } export async function getThemeWithAuthor( db: D1Database, id: string, ): Promise { return db .prepare( `SELECT t.*, a.name AS author_name, a.avatar_url AS author_avatar_url, a.verified AS author_verified FROM themes t JOIN authors a ON a.id = t.author_id WHERE t.id = ?`, ) .bind(id) .first(); } export async function searchThemes( db: D1Database, opts: ThemeSearchOptions, ): Promise<{ items: ThemeWithAuthor[]; nextCursor?: string }> { const limit = clampLimit(opts.limit); const offset = decodeCursor(opts.cursor); const conditions: string[] = []; const bindings: unknown[] = []; if (opts.q) { conditions.push("(t.name LIKE ? OR t.description LIKE ? OR t.keywords LIKE ?)"); const pattern = `%${opts.q}%`; bindings.push(pattern, pattern, pattern); } if (opts.keyword) { conditions.push("EXISTS (SELECT 1 FROM json_each(t.keywords) WHERE json_each.value = ?)"); bindings.push(opts.keyword); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; let orderBy: string; switch (opts.sort) { case "name": orderBy = "t.name ASC"; break; case "created": orderBy = "t.created_at DESC"; break; case "updated": default: orderBy = "t.updated_at DESC"; break; } const query = ` SELECT t.*, a.name AS author_name, a.avatar_url AS author_avatar_url, a.verified AS author_verified FROM themes t JOIN authors a ON a.id = t.author_id ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`; bindings.push(limit + 1, offset); const result = await db .prepare(query) .bind(...bindings) .all(); const items = result.results ?? []; let nextCursor: string | undefined; if (items.length > limit) { items.pop(); nextCursor = encodeCursor(offset + limit); } return { items, nextCursor }; } export async function createTheme( db: D1Database, data: { id: string; name: string; description?: string; authorId: string; previewUrl: string; demoUrl?: string; repositoryUrl?: string; homepageUrl?: string; license?: string; keywords?: string[]; }, ): Promise { const now = new Date().toISOString(); await db .prepare( `INSERT INTO themes (id, name, description, author_id, preview_url, demo_url, repository_url, homepage_url, license, keywords, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( data.id, data.name, data.description ?? null, data.authorId, data.previewUrl, data.demoUrl ?? null, data.repositoryUrl ?? null, data.homepageUrl ?? null, data.license ?? null, data.keywords ? JSON.stringify(data.keywords) : null, now, now, ) .run(); return (await getTheme(db, data.id))!; } export async function updateTheme( db: D1Database, id: string, data: { name?: string; description?: string; previewUrl?: string; demoUrl?: string; repositoryUrl?: string; homepageUrl?: string; license?: string; keywords?: string[]; hasThumbnail?: boolean; screenshotCount?: number; }, ): Promise { const sets: string[] = []; const bindings: unknown[] = []; if (data.name !== undefined) { sets.push("name = ?"); bindings.push(data.name); } if (data.description !== undefined) { sets.push("description = ?"); bindings.push(data.description); } if (data.previewUrl !== undefined) { sets.push("preview_url = ?"); bindings.push(data.previewUrl); } if (data.demoUrl !== undefined) { sets.push("demo_url = ?"); bindings.push(data.demoUrl); } if (data.repositoryUrl !== undefined) { sets.push("repository_url = ?"); bindings.push(data.repositoryUrl); } if (data.homepageUrl !== undefined) { sets.push("homepage_url = ?"); bindings.push(data.homepageUrl); } if (data.license !== undefined) { sets.push("license = ?"); bindings.push(data.license); } if (data.keywords !== undefined) { sets.push("keywords = ?"); bindings.push(JSON.stringify(data.keywords)); } if (data.hasThumbnail !== undefined) { sets.push("has_thumbnail = ?"); bindings.push(data.hasThumbnail ? 1 : 0); } if (data.screenshotCount !== undefined) { sets.push("screenshot_count = ?"); bindings.push(data.screenshotCount); } if (sets.length === 0) return getTheme(db, id); sets.push("updated_at = datetime('now')"); bindings.push(id); await db .prepare(`UPDATE themes SET ${sets.join(", ")} WHERE id = ?`) .bind(...bindings) .run(); return getTheme(db, id); }