返回 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 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
318 lines TYPESCRIPT