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