返回 DeepSeek-Reasonix
packages.ts
根目录 / workers / crash-report / src / registry / db / packages.ts
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
271 lines TYPESCRIPT