| 1 | import type { PackageKind, PackageRow, VersionRow, RegistryUser } from "../types"; |
| 2 | import type { PublishInput } from "../lib/validation"; |
| 3 | import { ApiError } from "../http/errors"; |
| 4 | |
| 5 | const TRENDING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; |
| 6 | |
| 7 | export interface ListParams { |
| 8 | kind: PackageKind | "all"; |
| 9 | q: string; |
| 10 | sort: "new" | "trending" | "installs"; |
| 11 | limit: number; |
| 12 | offset: number; |
| 13 | now: string; |
| 14 | } |
| 15 | |
| 16 | export interface PublishResult { |
| 17 | row: PackageRow; |
| 18 | created: boolean; |
| 19 | version: string; |
| 20 | } |
| 21 | |
| 22 | export class PackageRepo { |
| 23 | constructor(private readonly db: D1Database) {} |
| 24 | |
| 25 | async list(p: ListParams): Promise<PackageRow[]> { |
| 26 | const where: string[] = ["p.status = 'active'"]; |
| 27 | const binds: unknown[] = []; |
| 28 | |
| 29 | if (p.kind !== "all") { |
| 30 | where.push(`p.kind = ?${binds.length + 1}`); |
| 31 | binds.push(p.kind); |
| 32 | } |
| 33 | if (p.q) { |
| 34 | const like = `%${p.q.toLowerCase()}%`; |
| 35 | const a = binds.length + 1; |
| 36 | where.push(`(lower(p.name) LIKE ?${a} OR lower(p.summary) LIKE ?${a + 1} OR lower(p.tags) LIKE ?${a + 2})`); |
| 37 | binds.push(like, like, like); |
| 38 | } |
| 39 | |
| 40 | let select = "SELECT p.* FROM packages p"; |
| 41 | let order: string; |
| 42 | if (p.sort === "trending") { |
| 43 | const windowStart = new Date(new Date(p.now).getTime() - TRENDING_WINDOW_MS).toISOString(); |
| 44 | select = `SELECT p.*, COALESCE(e.c, 0) AS trend FROM packages p |
| 45 | LEFT JOIN ( |
| 46 | SELECT package_id, COUNT(*) AS c FROM events |
| 47 | WHERE type = 'install' AND created_at > ?${binds.length + 1} |
| 48 | GROUP BY package_id |
| 49 | ) e ON e.package_id = p.id`; |
| 50 | binds.push(windowStart); |
| 51 | order = "ORDER BY trend DESC, p.install_count DESC, p.created_at DESC"; |
| 52 | } else if (p.sort === "installs") { |
| 53 | order = "ORDER BY p.install_count DESC, p.created_at DESC"; |
| 54 | } else { |
| 55 | order = "ORDER BY p.created_at DESC"; |
| 56 | } |
| 57 | |
| 58 | const sql = `${select} WHERE ${where.join(" AND ")} ${order} LIMIT ?${binds.length + 1} OFFSET ?${binds.length + 2}`; |
| 59 | binds.push(p.limit, p.offset); |
| 60 | |
| 61 | const res = await this.db.prepare(sql).bind(...binds).all<PackageRow>(); |
| 62 | return res.results ?? []; |
| 63 | } |
| 64 | |
| 65 | async bySlug(slug: string): Promise<PackageRow | null> { |
| 66 | return this.db.prepare("SELECT * FROM packages WHERE slug = ?1").bind(slug).first<PackageRow>(); |
| 67 | } |
| 68 | |
| 69 | async versions(packageId: number): Promise<VersionRow[]> { |
| 70 | const res = await this.db |
| 71 | .prepare( |
| 72 | `SELECT version, source, content_hash, risk_level, created_at |
| 73 | FROM package_versions WHERE package_id = ?1 ORDER BY created_at DESC`, |
| 74 | ) |
| 75 | .bind(packageId) |
| 76 | .all<VersionRow>(); |
| 77 | return res.results ?? []; |
| 78 | } |
| 79 | |
| 80 | // Create a new package or append a version to an owned one. New packages and |
| 81 | // updates from non-admins land as 'pending' (hidden until an admin approves). |
| 82 | // Every accepted update appends an immutable version, so its source, manifest, |
| 83 | // metadata, and capability kind must all cross the same moderation boundary. |
| 84 | // Republishing an existing version is refused (409). |
| 85 | async publish(user: RegistryUser, input: PublishInput, now: string): Promise<PublishResult> { |
| 86 | const slug = `${user.handle}/${input.name}`; |
| 87 | const existing = await this.bySlug(slug); |
| 88 | |
| 89 | if (existing) { |
| 90 | if (existing.publisher_id !== user.id && user.role !== "admin") { |
| 91 | throw new ApiError(403, "not_owner", "That name belongs to another publisher."); |
| 92 | } |
| 93 | // A new version may change executable source or manifest content even |
| 94 | // when its public kind stays the same. Only trusted admin updates bypass |
| 95 | // re-review; publisher updates always lose verification until approved. |
| 96 | const publisherNeedsReview = user.role !== "admin"; |
| 97 | const status = publisherNeedsReview ? "pending" : existing.status; |
| 98 | const verified = publisherNeedsReview ? 0 : existing.verified; |
| 99 | const version = input.version || nextPatch(existing.latest_version); |
| 100 | await this.insertVersion(existing.id, version, input, now); |
| 101 | await this.db |
| 102 | .prepare( |
| 103 | `UPDATE packages SET kind = ?1, summary = ?2, description = ?3, source = ?4, install_kind = ?5, |
| 104 | homepage = ?6, repo_url = ?7, tags = ?8, latest_version = ?9, updated_at = ?10, |
| 105 | status = ?11, verified = ?12 |
| 106 | WHERE id = ?13`, |
| 107 | ) |
| 108 | .bind( |
| 109 | input.kind, |
| 110 | input.summary, |
| 111 | input.description, |
| 112 | input.source, |
| 113 | input.installKind, |
| 114 | input.homepage, |
| 115 | input.repoUrl, |
| 116 | input.tags.join(","), |
| 117 | version, |
| 118 | now, |
| 119 | status, |
| 120 | verified, |
| 121 | existing.id, |
| 122 | ) |
| 123 | .run(); |
| 124 | const row = await this.bySlug(slug); |
| 125 | if (!row) throw new ApiError(500, "publish_failed", "Package not found after update."); |
| 126 | return { row, created: false, version }; |
| 127 | } |
| 128 | |
| 129 | const version = input.version || "0.1.0"; |
| 130 | const status = user.role === "admin" ? "active" : "pending"; |
| 131 | const inserted = await this.db |
| 132 | .prepare( |
| 133 | `INSERT INTO packages |
| 134 | (kind, scope_handle, name, slug, summary, description, source, install_kind, |
| 135 | homepage, repo_url, tags, latest_version, status, publisher_id, created_at, updated_at) |
| 136 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?15) |
| 137 | RETURNING id`, |
| 138 | ) |
| 139 | .bind( |
| 140 | input.kind, |
| 141 | user.handle, |
| 142 | input.name, |
| 143 | slug, |
| 144 | input.summary, |
| 145 | input.description, |
| 146 | input.source, |
| 147 | input.installKind, |
| 148 | input.homepage, |
| 149 | input.repoUrl, |
| 150 | input.tags.join(","), |
| 151 | version, |
| 152 | status, |
| 153 | user.id, |
| 154 | now, |
| 155 | ) |
| 156 | .first<{ id: number }>(); |
| 157 | if (!inserted) throw new ApiError(500, "publish_failed", "Insert returned no id."); |
| 158 | await this.insertVersion(inserted.id, version, input, now); |
| 159 | const row = await this.bySlug(slug); |
| 160 | if (!row) throw new ApiError(500, "publish_failed", "Package not found after insert."); |
| 161 | return { row, created: true, version }; |
| 162 | } |
| 163 | |
| 164 | private async insertVersion(packageId: number, version: string, input: PublishInput, now: string): Promise<void> { |
| 165 | const res = await this.db |
| 166 | .prepare( |
| 167 | `INSERT OR IGNORE INTO package_versions |
| 168 | (package_id, version, source, manifest, content_hash, risk_level, created_at) |
| 169 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, |
| 170 | ) |
| 171 | .bind(packageId, version, input.source, input.manifest, input.contentHash, input.riskLevel, now) |
| 172 | .run(); |
| 173 | if ((res.meta.changes ?? 0) === 0) { |
| 174 | throw new ApiError(409, "version_exists", `Version ${version} is already published.`); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // Best-effort install tally. Returns the new count, or null when no active |
| 179 | // package matches the slug. |
| 180 | async recordInstall(slug: string): Promise<number | null> { |
| 181 | const res = await this.db |
| 182 | .prepare("UPDATE packages SET install_count = install_count + 1 WHERE slug = ?1 AND status = 'active'") |
| 183 | .bind(slug) |
| 184 | .run(); |
| 185 | if ((res.meta.changes ?? 0) === 0) return null; |
| 186 | const row = await this.bySlug(slug); |
| 187 | return row?.install_count ?? null; |
| 188 | } |
| 189 | |
| 190 | // Toggle a star. Returns the resulting state, or null when the slug is unknown. |
| 191 | async toggleStar(slug: string, userId: number, now: string): Promise<{ starred: boolean; count: number } | null> { |
| 192 | const pkg = await this.bySlug(slug); |
| 193 | if (!pkg || pkg.status !== "active") return null; |
| 194 | |
| 195 | const added = await this.db |
| 196 | .prepare("INSERT OR IGNORE INTO stars (package_id, user_id, created_at) VALUES (?1, ?2, ?3)") |
| 197 | .bind(pkg.id, userId, now) |
| 198 | .run(); |
| 199 | |
| 200 | if ((added.meta.changes ?? 0) > 0) { |
| 201 | await this.db.prepare("UPDATE packages SET star_count = star_count + 1 WHERE id = ?1").bind(pkg.id).run(); |
| 202 | return { starred: true, count: pkg.star_count + 1 }; |
| 203 | } |
| 204 | await this.db.prepare("DELETE FROM stars WHERE package_id = ?1 AND user_id = ?2").bind(pkg.id, userId).run(); |
| 205 | await this.db |
| 206 | .prepare("UPDATE packages SET star_count = MAX(0, star_count - 1) WHERE id = ?1") |
| 207 | .bind(pkg.id) |
| 208 | .run(); |
| 209 | return { starred: false, count: Math.max(0, pkg.star_count - 1) }; |
| 210 | } |
| 211 | |
| 212 | // Admin: packages awaiting (or past) review, newest first. |
| 213 | async listByStatus(status: string, limit: number): Promise<PackageRow[]> { |
| 214 | const res = await this.db |
| 215 | .prepare("SELECT * FROM packages WHERE status = ?1 ORDER BY created_at DESC LIMIT ?2") |
| 216 | .bind(status, limit) |
| 217 | .all<PackageRow>(); |
| 218 | return res.results ?? []; |
| 219 | } |
| 220 | |
| 221 | // Admin: move a package between statuses (approve → active, reject, hide). |
| 222 | async setStatus(slug: string, status: string, now: string): Promise<PackageRow | null> { |
| 223 | const res = await this.db |
| 224 | .prepare("UPDATE packages SET status = ?1, updated_at = ?2 WHERE slug = ?3") |
| 225 | .bind(status, now, slug) |
| 226 | .run(); |
| 227 | if ((res.meta.changes ?? 0) === 0) return null; |
| 228 | return this.bySlug(slug); |
| 229 | } |
| 230 | |
| 231 | // Admin approval must be bound to the exact row the reviewer inspected. |
| 232 | // The version protects publisher updates, while updated_at + status also |
| 233 | // fence concurrent moderation actions. D1 evaluates the predicate and write |
| 234 | // atomically, so a package cannot change between a preflight read and approval. |
| 235 | async setStatusIfCurrent( |
| 236 | slug: string, |
| 237 | status: string, |
| 238 | expectedVersion: string, |
| 239 | expectedUpdatedAt: string, |
| 240 | expectedStatus: string, |
| 241 | now: string, |
| 242 | ): Promise<PackageRow | null> { |
| 243 | return this.db |
| 244 | .prepare( |
| 245 | `UPDATE packages SET status = ?1, updated_at = ?2 |
| 246 | WHERE slug = ?3 AND latest_version = ?4 AND updated_at = ?5 AND status = ?6 |
| 247 | RETURNING *`, |
| 248 | ) |
| 249 | .bind(status, now, slug, expectedVersion, expectedUpdatedAt, expectedStatus) |
| 250 | .first<PackageRow>(); |
| 251 | } |
| 252 | |
| 253 | // Admin: grant or revoke the verified trust badge. |
| 254 | async setVerified(slug: string, verified: boolean, now: string): Promise<PackageRow | null> { |
| 255 | const res = await this.db |
| 256 | .prepare("UPDATE packages SET verified = ?1, updated_at = ?2 WHERE slug = ?3") |
| 257 | .bind(verified ? 1 : 0, now, slug) |
| 258 | .run(); |
| 259 | if ((res.meta.changes ?? 0) === 0) return null; |
| 260 | return this.bySlug(slug); |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // Bump the patch component so an update without an explicit version still lands |
| 265 | // as a distinct, immutable version row. Non-semver latest values restart at 0.1.0. |
| 266 | function nextPatch(latest: string): string { |
| 267 | const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(latest.trim()); |
| 268 | if (!m) return "0.1.0"; |
| 269 | return `${m[1]}.${m[2]}.${Number(m[3]) + 1}`; |
| 270 | } |
| 271 |