Skip to content
1,118 linesCodeBlameRaw
1/**
2 * The projects service: what a workspace builds and runs.
3 *
4 * A project has one source, where its code lives: today a repository hosted
5 * on g1t and a root directory in it. Everything about running it
6 * (deployments, environments, domains, secrets and variables) hangs off the
7 * project; other services key their data by its id. Every repository gets
8 * a project of its own name: when it is created (from `repo.created`), and
9 * for repositories made before projects existed, the first time their
10 * workspace's projects are asked for.
11 *
12 * Reached through service bindings: `POST /rpc/<method>`.
13 */
14
15
16import { NEEDS, repoRef } from "./access";
17import { effectiveDescription, ownDescription } from "./description";
18import {
19 type KindFacts,
20 type RootFiles,
21 MANIFESTS,
22 detectKind,
23 detectedKind,
24 goFilesToRead,
25 kindSetting,
26 neverRuns,
27 nextSetting,
28 resolveKind,
29 runsSetting,
30} from "./kind";
31import { cleanLinks, cleanUrl, projectLinks } from "./links";
32import { ACTIVE, decayed, raised } from "./activity";
33import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
34import { renameStatements } from "./rename";
35import { moveStatements, slugOf, strandedQuery } from "./transfer";
36import { checkProductionImage, productionImageKey, productionImageOf, sha256, today, wantsScreenshot } from "./production-image";
37
38import {
39 can,
40 currentMovedPath,
41 currentWorkspaceSlug,
42 fail,
43 identityClient,
44 staleSlugs,
45 needs,
46 isProtectedWorkspace,
47 newId,
48 ok,
49 openD1,
50 packagesClient,
51 permission,
52 repoMove,
53 reposClient,
54 staleMovedPaths,
55 type Ecosystem,
56 type G1tEvent,
57 type NewProject,
58 type Project,
59 type ProjectChanges,
60 type ProductionImageChange,
61 type ProjectKind,
62 type ProjectEcosystem,
63 type ProjectShortcuts,
64 type Repo,
65 type Result,
66 type ServiceBinding,
67 type User,
68 type Viewer,
69} from "@g1t/contracts";
70
71type Env = {
72 DB: D1Database;
73 REPOS: ServiceBinding;
74 IDENTITY: ServiceBinding;
75 PACKAGES: ServiceBinding;
76 DEPLOYMENTS: ServiceBinding;
77 /** The avatars namespace: uploaded production images, under `production/<sha256>`, which the usercontent origin serves. */
78 AVATARS: KVNamespace;
79 /**
80 * The og service's `Screenshots` entrypoint: a screenshot of a production
81 * deployed elsewhere, taken when its address is set or changed and when
82 * a production deployment is reported. Absent: none is asked for.
83 */
84 SCREENSHOTS?: { captureExternal(input: { url: string; commit: string }): Promise<boolean> };
85};
86
87type Row = {
88 id: string;
89 workspace: string;
90 slug: string;
91 name: string;
92 /** The project's own description; null while it follows its repository's. */
93 description: string | null;
94 /** Its repository's description, kept from repos. */
95 repo_description?: string | null;
96 source_kind: string;
97 repo_id: string;
98 repo_namespace: string;
99 repo_name: string;
100 repo_private: number;
101 default_branch: string;
102 root_dir: string;
103 is_primary: number;
104 created_by: string;
105 created_at: string;
106 updated_at: string;
107 /** Set while its repository is deleted; the project is hidden until it is restored. */
108 repo_deleted_at: string | null;
109 /** When its repository was archived; null while it is not. */
110 repo_archived_at?: string | null;
111 /** What a person set it to be and where it runs; null left to detection (migrations/0010_kind_and_links.sql). */
112 kind_setting?: string | null;
113 runs_setting?: string | null;
114 production_url?: string | null;
115 /** An uploaded image of production, by its SHA-256 (migrations/0012_production_image.sql). */
116 production_image?: string | null;
117 production_image_type?: string | null;
118 production_image_preferred?: number | null;
119 /** Its own homepage; null follows `repo_website`. */
120 homepage?: string | null;
121 repo_website?: string | null;
122 docs_url?: string | null;
123 /** Its other links, as JSON (src/links.ts). */
124 links?: string | null;
125 detected_kind?: string | null;
126 detected_detail?: string | null;
127 detected_ecosystem?: string | null;
128 /** The default branch's commit its files were read at; null until they have been. */
129 detected_commit?: string | null;
130 linked_package?: string | null;
131 deployments_on?: number | null;
132 /** When its repository was last pushed to (migrations/0009_activity.sql). */
133 pushed_at?: string | null;
134 /** How active it is lately, as of `active_at` (src/activity.ts). */
135 activity?: number | null;
136 active_at?: string | null;
137};
138
139/** How a package its repository publishes is named in why a project is a library. */
140const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
141 container: "container image",
142 npm: "npm package",
143 composer: "Composer package",
144 cargo: "crate",
145 go: "Go module",
146 maven: "Maven package",
147 nuget: "NuGet package",
148 rubygems: "gem",
149};
150
151/** How many projects one listing reads the files of, in the background, before it has. */
152const DETECT_PER_LIST = 10;
153
154const now = () => new Date().toISOString();
155
156function toProject(row: Row): Project {
157 const { description, inherited } = effectiveDescription(row);
158 const setting = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
159 const facts: Omit<KindFacts, "setting"> = {
160 deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
161 linkedPackage: row.linked_package ?? null,
162 detected: row.detected_commit == null ? null : { kind: kindSetting(row.detected_kind), detail: row.detected_detail ?? "" },
163 };
164 const { kind, reason, runs } = resolveKind({ setting, ...facts });
165 return {
166 id: row.id,
167 workspace: row.workspace,
168 slug: row.slug,
169 name: row.name,
170 description,
171 descriptionInherited: inherited,
172 source: {
173 kind: "hosted",
174 repoId: row.repo_id,
175 repo: { namespace: row.repo_namespace, name: row.repo_name },
176 rootDir: row.root_dir,
177 defaultBranch: row.default_branch,
178 },
179 private: !!row.repo_private,
180 archived: !!row.repo_archived_at,
181 primary: !!row.is_primary,
182 setting,
183 kind,
184 kindReason: reason,
185 runs,
186 productionUrl: row.production_url ?? null,
187 productionImage: productionImageOf(row),
188 detected: detectedKind(facts),
189 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
190 links: projectLinks(row),
191 createdBy: row.created_by,
192 createdAt: row.created_at,
193 updatedAt: row.updated_at,
194 pushedAt: row.pushed_at ?? null,
195 activity: decayed(row.activity ?? 0, row.active_at ?? null, now()),
196 };
197}
198
199function isMember(viewer: Viewer, workspace: string): boolean {
200 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
201}
202
203class Projects {
204 /** `defer` runs work after the answer is sent: the request's waitUntil. */
205 constructor(
206 private readonly env: Env,
207 private readonly defer: (work: Promise<unknown>) => void = () => {},
208 ) {}
209
210 private get db() {
211 return this.env.DB;
212 }
213
214 private async workspaceActor(slug: string): Promise<User | null> {
215 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
216 if (!workspace) return null;
217 return {
218 id: workspace.id,
219 username: workspace.slug,
220 kind: "workspace",
221 verified: true,
222 workspaces: [{ slug: workspace.slug, role: "member" }],
223 };
224 }
225
226 /** A free slug for `wanted` in the workspace. */
227 private async freeSlug(workspace: string, wanted: string): Promise<string> {
228 const base = slugOf(wanted) || "project";
229 for (let n = 1; n < 100; n++) {
230 const slug = n === 1 ? base : `${base}-${n}`;
231 const taken = await this.db
232 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
233 .bind(workspace, slug)
234 .first();
235 if (!taken) return slug;
236 }
237 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
238 }
239
240 /** Gives a repository its own project, unless it has one. */
241 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
242 if (repo.forkOf) return;
243 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
244 if (existing) {
245 await this.db
246 .prepare(
247 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ?, repo_description = ?, repo_website = ? WHERE repo_id = ?",
248 )
249 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.description ?? null, repo.website ?? null, repo.id)
250 .run();
251 return;
252 }
253 const at = now();
254 await this.db
255 .prepare(
256 // No description of its own: it shows the repository's, as that changes.
257 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_website, repo_id, repo_namespace, repo_name, repo_private,
258 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
259 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
260 ON CONFLICT (workspace, slug) DO NOTHING`,
261 )
262 .bind(
263 newId("prj"),
264 repo.namespace.toLowerCase(),
265 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
266 repo.name,
267 repo.description ?? null,
268 repo.website ?? null,
269 repo.id,
270 repo.namespace,
271 repo.name,
272 repo.isPrivate ? 1 : 0,
273 repo.defaultBranch,
274 createdBy,
275 at,
276 at,
277 repo.archivedAt ?? null,
278 )
279 .run();
280 }
281
282 /** Projects for a workspace's repositories made before projects existed. Once. */
283 private async backfill(workspace: string): Promise<void> {
284 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
285 if (done) return;
286 const actor = await this.workspaceActor(workspace);
287 if (!actor) return;
288 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
289 for (const repo of list) {
290 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
291 }
292 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
293 }
294
295 /**
296 * Whether the viewer can read the project's repository. The workspace's
297 * own token sees all its projects, also one left building from a
298 * repository that moved to another workspace.
299 */
300 private visible(row: Row, viewer: Viewer): boolean {
301 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
302 return permission(viewer, repoRef(row)) != null;
303 }
304
305 /**
306 * Whether the actor may change the project: not found when they cannot
307 * read its repository, refused when their role there is too low.
308 */
309 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
310 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
311 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
312 return ok(true);
313 }
314
315 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
316 const workspace = a.workspace.toLowerCase();
317 await this.backfill(workspace);
318 const rows = await this.db
319 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
320 .bind(workspace)
321 .all<Row>();
322 // Projects whose files have not been read yet are read after this answer,
323 // a few at a time; until then they show as apps, as before.
324 const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
325 if (unread.length) this.defer(Promise.all(unread.map((row) => this.detect(row).catch((error) => console.warn("detect", row.slug, error)))));
326 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
327 }
328
329 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
330 const workspace = a.workspace.toLowerCase();
331 await this.backfill(workspace);
332 const row = await this.db
333 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
334 .bind(workspace, a.slug.toLowerCase())
335 .first<Row>();
336 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
337 if (row.detected_commit == null) {
338 // Once per project: what its files say, read now so its first page is right.
339 const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
340 if (read) return ok(toProject(read));
341 }
342 return ok(toProject(row));
343 }
344
345 /** How many projects a workspace shows: what deleting it would take with it. */
346 async count(a: { workspace: string }): Promise<number> {
347 const row = await this.db
348 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL")
349 .bind(a.workspace.toLowerCase())
350 .first<{ n: number }>();
351 return row?.n ?? 0;
352 }
353
354 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
355 private async repoById(repoId: string): Promise<Repo | null> {
356 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
357 method: "POST",
358 headers: { "content-type": "application/json" },
359 body: JSON.stringify({ id: repoId }),
360 });
361 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
362 if (!repoPath) return null;
363 const actor = await this.workspaceActor(repoPath.namespace);
364 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
365 return repo?.ok ? repo.value : null;
366 }
367
368 async byRepo(a: { repoId: string }): Promise<Project[]> {
369 const rows = await this.db
370 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
371 .bind(a.repoId)
372 .all<Row>();
373 // A deleted repository's projects are hidden until it is restored.
374 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
375 // A repository from before projects: give it its own now.
376 const repo = await this.repoById(a.repoId);
377 if (!repo) return [];
378 await this.ensureFor(repo, "g1t");
379 const again = await this.db
380 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
381 .bind(a.repoId)
382 .all<Row>();
383 return again.results.map(toProject);
384 }
385
386 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
387 const workspace = a.workspace.toLowerCase();
388 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
389 if (a.input.repo.namespace.toLowerCase() !== workspace) {
390 return fail("invalid", "A project builds from one of its own workspace's repositories.");
391 }
392 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
393 if (!repo.ok) return repo;
394 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
395 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
396 const name = a.input.name.trim();
397 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
398 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
399 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
400 const slug = slugOf(name);
401 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
402 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
403 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
404 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
405 const at = now();
406 const id = newId("prj");
407 await this.db
408 .prepare(
409 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
410 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
411 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
412 )
413 .bind(
414 id,
415 workspace,
416 slug,
417 name,
418 ownDescription(null, a.input.description ?? null),
419 repo.value.description ?? null,
420 repo.value.id,
421 repo.value.namespace,
422 repo.value.name,
423 repo.value.isPrivate ? 1 : 0,
424 repo.value.defaultBranch,
425 rootDir,
426 primary ? 1 : 0,
427 a.actor.username,
428 at,
429 at,
430 repo.value.archivedAt ?? null,
431 )
432 .run();
433 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
434 }
435
436 async update(a: {
437 actor: User;
438 workspace: string;
439 slug: string;
440 changes: ProjectChanges;
441 }): Promise<Result<Project>> {
442 const changes = a.changes ?? {};
443 const workspace = a.workspace.toLowerCase();
444 const row = await this.db
445 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
446 .bind(workspace, a.slug.toLowerCase())
447 .first<Row>();
448 if (!row) return fail("not_found", "There is no such project.");
449 const allowed = this.changeable(row, a.actor, NEEDS.update);
450 if (!allowed.ok) return allowed;
451 const name = changes.name?.trim() || row.name;
452 // Blank or null goes back to following the repository's description.
453 const description = ownDescription(row.description, changes.description);
454 const rootDir = changes.rootDir === undefined ? row.root_dir : String(changes.rootDir).trim().replace(/^\/+|\/+$/g, "");
455 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
456 const known = (value: unknown, allowed: readonly string[], what: string) =>
457 value === undefined || allowed.includes(value as string) ? null : fail("invalid", `${what} is one of ${allowed.join(", ")}.`);
458 const badKind = known(changes.kind, ["auto", "app", "library", "tool", "docs", "other"], "kind");
459 if (badKind) return badKind;
460 const badRuns = known(changes.runs, ["auto", "g1t", "elsewhere"], "runs");
461 if (badRuns) return badRuns;
462 const before = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
463 const setting = nextSetting(before, { kind: changes.kind as ProjectKind | "auto" | undefined, runs: changes.runs });
464 // Something that never runs, while Deployments run, would leave apps up
465 // that no page offers: a person turns them off first.
466 if (neverRuns(setting) && !neverRuns(before) && (row.deployments_on ?? (await this.deploymentsOn(row.id)))) {
467 return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
468 }
469 const url = (value: string | null | undefined, current: string | null | undefined, what: string) =>
470 value === undefined ? { ok: true as const, value: current ?? null } : cleanUrl(value, what);
471 const production = url(changes.productionUrl, row.production_url, "The production address");
472 if (!production.ok) return fail("invalid", production.message);
473 const homepage = url(changes.homepage, row.homepage, "The homepage");
474 if (!homepage.ok) return fail("invalid", homepage.message);
475 const docs = url(changes.docsUrl, row.docs_url, "The docs address");
476 if (!docs.ok) return fail("invalid", docs.message);
477 let linksJson = row.links ?? null;
478 if (changes.links !== undefined) {
479 const links = cleanLinks(changes.links);
480 if (!links.ok) return fail("invalid", links.message);
481 linksJson = links.value.length ? JSON.stringify(links.value) : null;
482 }
483 await this.db
484 .prepare(
485 `UPDATE projects SET name = ?, description = ?, root_dir = ?, kind_setting = ?, runs_setting = ?, production_url = ?,
486 homepage = ?, docs_url = ?, links = ?, updated_at = ? WHERE id = ?`,
487 )
488 .bind(
489 name.slice(0, 100),
490 description,
491 rootDir,
492 setting.kind,
493 setting.runs,
494 production.value,
495 homepage.value,
496 docs.value,
497 linksJson,
498 now(),
499 row.id,
500 )
501 .run();
502 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
503 // Another root has other files: read them again.
504 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
505 const project = toProject(saved);
506 // Production elsewhere given an address, or a new one: its screenshot, now.
507 const was = toProject(row);
508 if (wantsScreenshot(project) && (project.productionUrl !== was.productionUrl || was.runs !== "elsewhere")) {
509 this.screenshot(project.productionUrl!, today());
510 }
511 return ok(project);
512 }
513
514 /**
515 * Asks og for a screenshot of a production deployed elsewhere (og takes
516 * it in the background). Never fails: the overview and the daily
517 * refresh take it later anyway.
518 */
519 private async capture(url: string, commit: string): Promise<void> {
520 await this.env.SCREENSHOTS?.captureExternal({ url, commit }).catch((error) => console.warn("could not ask for a screenshot", url, error));
521 }
522
523 /** The same, after the answer is sent. */
524 private screenshot(url: string, commit: string): void {
525 this.defer(this.capture(url, commit));
526 }
527
528 async setProductionImage(a: {
529 actor: User;
530 workspace: string;
531 slug: string;
532 change: ProductionImageChange;
533 }): Promise<Result<Project>> {
534 const change = a.change ?? {};
535 const row = await this.db
536 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
537 .bind(a.workspace.toLowerCase(), a.slug.toLowerCase())
538 .first<Row>();
539 if (!row) return fail("not_found", "There is no such project.");
540 // Whoever may change its production address.
541 const allowed = this.changeable(row, a.actor, NEEDS.update);
542 if (!allowed.ok) return allowed;
543 let file = row.production_image ?? null;
544 let type = row.production_image_type ?? null;
545 let preferred = !!row.production_image_preferred;
546 if (change.image === null) {
547 file = null;
548 type = null;
549 preferred = false;
550 } else if (change.image !== undefined) {
551 const checked = checkProductionImage(change.image);
552 if (!checked.ok) return fail("invalid", checked.message);
553 file = await sha256(checked.bytes);
554 type = checked.contentType;
555 // Kept by its hash, so the same image stored twice is one file; its
556 // type is the one read from its bytes (usercontent serves only that).
557 await this.env.AVATARS.put(productionImageKey(file), checked.bytes, { metadata: { contentType: type } });
558 }
559 if (change.preferred !== undefined) {
560 if (change.preferred && !file) return fail("invalid", "Upload an image first.");
561 preferred = !!change.preferred;
562 }
563 await this.db
564 .prepare("UPDATE projects SET production_image = ?, production_image_type = ?, production_image_preferred = ?, updated_at = ? WHERE id = ?")
565 .bind(file, type, preferred ? 1 : 0, now(), row.id)
566 .run();
567 // The image it replaced goes, unless another project shows it too.
568 const old = row.production_image;
569 if (old && old !== file) {
570 const used = await this.db.prepare("SELECT 1 FROM projects WHERE production_image = ? LIMIT 1").bind(old).first();
571 if (!used) this.defer(this.env.AVATARS.delete(productionImageKey(old)).catch((error) => console.warn("could not delete an image", error)));
572 }
573 const saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
574 const project = toProject(saved);
575 // No longer shown instead of a screenshot: one is taken, if none is fresh.
576 if (row.production_image_preferred && !preferred && wantsScreenshot(project)) this.screenshot(project.productionUrl!, today());
577 return ok(project);
578 }
579
580 /** Every production address deployed elsewhere that shows a screenshot, once each. */
581 async externalProductions(): Promise<string[]> {
582 const rows = await this.db
583 .prepare(
584 `SELECT * FROM projects WHERE production_url IS NOT NULL AND repo_deleted_at IS NULL
585 AND (runs_setting IS NULL OR runs_setting = 'elsewhere') AND production_image_preferred = 0`,
586 )
587 .all<Row>();
588 const urls = rows.results.map(toProject).filter(wantsScreenshot).map((project) => project.productionUrl!);
589 return [...new Set(urls)];
590 }
591
592 /**
593 * A production deployment reported from outside g1t.page succeeded: a
594 * screenshot of each of its repository's projects deployed elsewhere,
595 * at the address the deployment gives, else the project's own.
596 */
597 private async productionReported(data: Extract<G1tEvent, { type: "deployment_status.created" }>["data"]): Promise<void> {
598 const { deployment, deploymentStatus } = data;
599 if (deploymentStatus.state !== "success" || !deployment.production_environment || deployment.source === "g1t_page") return;
600 const rows = await this.db
601 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL ORDER BY is_primary DESC")
602 .bind(data.repoId)
603 .all<Row>();
604 // The repository's own project first: the overview shows the reported
605 // production on it, as running elsewhere when nobody has said otherwise.
606 const project = rows.results
607 .map(toProject)
608 .find((one) => one.runs === "elsewhere" || (one.runs === null && one.kind === "app"));
609 if (!project || project.productionImage?.preferred) return;
610 const url = deploymentStatus.environment_url ?? deployment.environment_url ?? project.productionUrl;
611 if (url) await this.capture(url, deployment.sha);
612 }
613
614 async publicLinks(a: { repos: { namespace: string; name: string }[] }): Promise<Record<string, string>> {
615 const keys = [...new Set((Array.isArray(a.repos) ? a.repos : []).slice(0, 100).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))];
616 if (keys.length === 0) return {};
617 const rows = await this.db
618 .prepare(
619 `SELECT * FROM projects WHERE is_primary = 1 AND repo_private = 0 AND repo_deleted_at IS NULL
620 AND lower(repo_namespace || '/' || repo_name) IN (${keys.map(() => "?").join(", ")})`,
621 )
622 .bind(...keys)
623 .all<Row>();
624 const out: Record<string, string> = {};
625 for (const row of rows.results) {
626 const project = toProject(row);
627 const link = (project.runs === "elsewhere" ? project.productionUrl : null) ?? project.links.homepage ?? project.links.docs;
628 if (link) out[`${row.repo_namespace}/${row.repo_name}`.toLowerCase()] = link;
629 }
630 return out;
631 }
632
633 async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
634 // Turned on, it runs on g1t: one said to run elsewhere moved here.
635 await this.db
636 .prepare(
637 "UPDATE projects SET deployments_on = ?1, runs_setting = CASE WHEN ?1 = 1 AND runs_setting = 'elsewhere' THEN 'g1t' ELSE runs_setting END WHERE id = ?2",
638 )
639 .bind(a.enabled ? 1 : 0, a.projectId)
640 .run();
641 }
642
643 // ---- Pinned and recent ---------------------------------------------------
644
645 /** The person's pins in the workspace, in order, as rows: hidden projects left out. */
646 private async pinRows(userId: string, workspace: string): Promise<Row[]> {
647 const rows = await this.db
648 .prepare(
649 `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id
650 WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`,
651 )
652 .bind(userId, workspace.toLowerCase())
653 .all<Row>();
654 return rows.results;
655 }
656
657 /** Writes the person's pins in the workspace in this order, by project id. */
658 private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> {
659 const at = now();
660 await this.db.batch([
661 ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)),
662 ...order.map((id, position) =>
663 this.db
664 .prepare(
665 `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?)
666 ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`,
667 )
668 .bind(userId, id, position, at),
669 ),
670 ]);
671 }
672
673 /** The person behind a call about their own pins, or why there is none. */
674 private person(viewer: Viewer): Result<User> {
675 if (!viewer) return fail("unauthenticated", "Sign in to pin projects.");
676 if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token.");
677 return ok(viewer);
678 }
679
680 /** A project the person can see, for pinning. */
681 private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> {
682 const row = await this.row(workspace, slug);
683 if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project.");
684 return ok(row);
685 }
686
687 async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> {
688 const who = this.person(a.viewer);
689 if (!who.ok) return { pinned: [], recent: [] };
690 const workspace = a.workspace.toLowerCase();
691 const [pinned, visited] = await Promise.all([
692 this.pinRows(who.value.id, workspace),
693 this.db
694 .prepare(
695 `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id
696 WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`,
697 )
698 .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT)
699 .all<Row>(),
700 ]);
701 const shown = (row: Row) => this.visible(row, who.value);
702 const pins = pinned.filter(shown);
703 return {
704 pinned: pins.map(toProject),
705 recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject),
706 };
707 }
708
709 async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> {
710 const who = this.person(a.actor);
711 if (!who.ok) return who;
712 const found = await this.pinnable(who.value, a.workspace, a.slug);
713 if (!found.ok) return found;
714 const current = await this.pinRows(who.value.id, found.value.workspace);
715 const order = placePin(
716 current.map((row) => row.id),
717 found.value.id,
718 a.position,
719 );
720 if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`);
721 await this.writePins(who.value.id, order);
722 return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
723 }
724
725 async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> {
726 const who = this.person(a.actor);
727 if (!who.ok) return who;
728 const row = await this.row(a.workspace, a.slug);
729 if (!row) return fail("not_found", "There is no such project.");
730 const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id);
731 await this.writePins(who.value.id, rest, [row.id]);
732 return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject));
733 }
734
735 async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> {
736 const who = this.person(a.actor);
737 if (!who.ok) return who;
738 if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order.");
739 const workspace = a.workspace.toLowerCase();
740 const current = await this.pinRows(who.value.id, workspace);
741 const idOf = new Map(current.map((row) => [row.slug, row.id]));
742 const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug));
743 const order = reorderPins(
744 current.map((row) => row.id),
745 wanted,
746 );
747 if (!order.ok) return fail("invalid", order.message);
748 await this.writePins(who.value.id, order.order);
749 return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
750 }
751
752 async visited(a: { actor: User; projectId: string }): Promise<void> {
753 const who = this.person(a.actor);
754 if (!who.ok || !a.projectId) return;
755 await this.db.batch([
756 this.db
757 .prepare(
758 `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?)
759 ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`,
760 )
761 .bind(who.value.id, a.projectId, now()),
762 // Only their latest few are kept.
763 this.db
764 .prepare(
765 `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN
766 (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`,
767 )
768 .bind(who.value.id, MAX_VISITS),
769 ]);
770 }
771
772 // ---- App or library --------------------------------------------------
773
774 /**
775 * Reads what decides whether the project is a library: the manifests at
776 * its root at `commit` (the default branch's head when null), a package
777 * its repository publishes, and, the first time, whether Deployments are
778 * on. Files are read again only for a commit they were not read at.
779 * `packages` reads only the packages. Returns the row as it is now.
780 */
781 private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
782 const actor = await this.workspaceActor(row.workspace);
783 if (!actor) return row;
784 const repo = { namespace: row.repo_namespace, name: row.repo_name };
785 const sets: string[] = [];
786 const values: unknown[] = [];
787 const set = (column: string, value: unknown) => {
788 sets.push(`${column} = ?`);
789 values.push(value);
790 };
791 const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
792 const [files, linked, deploying] = await Promise.all([
793 filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
794 this.linkedPackage(row, actor),
795 row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
796 ]);
797 if (files) {
798 const found = files.commit ? detectKind(files.root) : null;
799 set("detected_kind", found?.kind ?? null);
800 set("detected_detail", found?.detail ?? null);
801 set("detected_ecosystem", found?.ecosystem ?? null);
802 set("detected_commit", files.commit);
803 }
804 if (linked !== undefined) set("linked_package", linked);
805 if (deploying != null) set("deployments_on", deploying ? 1 : 0);
806 if (sets.length === 0) return row;
807 await this.db
808 .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
809 .bind(...values, row.id)
810 .run();
811 return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
812 }
813
814 /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
815 private async readRoot(
816 repo: { namespace: string; name: string },
817 actor: User,
818 commit: string | null,
819 rootDir: string,
820 ): Promise<{ commit: string; root: RootFiles } | null> {
821 const client = reposClient(this.env.REPOS);
822 const empty = { commit: "", root: { entries: [], text: {} } };
823 const tree = await client.tree(repo, actor, commit, rootDir);
824 if (!tree.ok) return null;
825 const head = commit ?? tree.value.head?.hash ?? "";
826 if (!head) return empty;
827 const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
828 const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
829 const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
830 const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
831 const [texts, src, pub] = await Promise.all([
832 Promise.all(
833 names.map(async (name) => {
834 const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
835 return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
836 }),
837 ),
838 entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
839 entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
840 ]);
841 const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
842 return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
843 }
844
845 /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
846 private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
847 const listed = await packagesClient(this.env.PACKAGES)
848 .list(row.workspace, actor, { repoId: row.repo_id })
849 .catch(() => null);
850 if (!listed?.ok) return undefined;
851 // An image is how an app ships too; it says nothing about being a library.
852 const pkg = listed.value.find((p) => p.ecosystem !== "container");
853 if (!pkg) return null;
854 return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
855 }
856
857 /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
858 private async deploymentsOn(projectId: string): Promise<boolean | null> {
859 const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
860 method: "POST",
861 headers: { "content-type": "application/json" },
862 body: JSON.stringify({ projectId }),
863 }).catch(() => null);
864 if (!answer?.ok) return null;
865 const value = (await answer.json().catch(() => null)) as unknown;
866 return typeof value === "boolean" ? value : null;
867 }
868
869 private async row(workspace: string, slug: string): Promise<Row | null> {
870 return this.db
871 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
872 .bind(workspace.toLowerCase(), slug.toLowerCase())
873 .first<Row>();
874 }
875
876 /** Something happened to a repository's code or work: its projects are that much more active. */
877 private async active(repoId: string, at: string, pushed: boolean): Promise<void> {
878 const rows = await this.db
879 .prepare("SELECT id, activity, active_at FROM projects WHERE repo_id = ?")
880 .bind(repoId)
881 .all<{ id: string; activity: number | null; active_at: string | null }>();
882 if (rows.results.length === 0) return;
883 await this.db.batch(
884 rows.results.map((row) => {
885 const next = raised(row.activity ?? 0, row.active_at, at);
886 return pushed
887 ? this.db
888 .prepare("UPDATE projects SET activity = ?, active_at = ?, pushed_at = MAX(COALESCE(pushed_at, ''), ?) WHERE id = ?")
889 .bind(next.score, next.at, at, row.id)
890 : this.db.prepare("UPDATE projects SET activity = ?, active_at = ? WHERE id = ?").bind(next.score, next.at, row.id);
891 }),
892 );
893 }
894
895 async onEvent(event: G1tEvent): Promise<void> {
896 if (ACTIVE.has(event.type)) {
897 const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
898 if (repoId) await this.active(repoId, event.time, event.type === "git.push");
899 }
900 if (event.type === "deployment_status.created") {
901 await this.productionReported(event.data);
902 return;
903 }
904 if (event.type === "workspace.renamed") {
905 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
906 const statements = renameStatements(staleSlugs(event.data, current), current);
907 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
908 return;
909 }
910 const move = repoMove(event);
911 if (move) {
912 const current = await currentMovedPath(this.env.REPOS, move);
913 const stale = staleMovedPaths(move, current);
914 if (stale.length === 0) return;
915 const statements = moveStatements(stale, current, move.repoId);
916 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
917 // A project whose slug the destination already had moves under a free one.
918 const query = strandedQuery(stale, current, move.repoId);
919 if (!query) return;
920 const workspace = current.split("/")[0]!;
921 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
922 for (const row of stranded.results) {
923 const slug = await this.freeSlug(workspace, row.slug);
924 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
925 await this.db
926 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
927 .bind(workspace, slug, workspace, now(), row.id)
928 .run();
929 }
930 return;
931 }
932 if (event.type === "repo.deleted") {
933 // Hidden, not gone: a restore brings its projects back as they were.
934 await this.db
935 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
936 .bind(now(), event.data.repoId)
937 .run();
938 return;
939 }
940 if (event.type === "repo.restored") {
941 await this.db
942 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
943 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
944 .run();
945 return;
946 }
947 if (event.type === "repo.purged") {
948 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
949 await this.db.batch([
950 // Pins and visits of what is gone go with it.
951 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
952 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
953 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
954 ]);
955 return;
956 }
957 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
958 // Who may see its projects follows who may see the repository.
959 await this.db
960 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
961 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
962 .run();
963 if (event.type === "repo.updated") {
964 // Projects without a description of their own show the repository's.
965 // Asked of repos, so changes delivered out of order end the same.
966 const repo = await this.repoById(event.data.repoId);
967 if (repo) {
968 await this.db
969 .prepare("UPDATE projects SET repo_description = ?, repo_website = ? WHERE repo_id = ?")
970 .bind(repo.description ?? null, repo.website ?? null, event.data.repoId)
971 .run();
972 }
973 }
974 return;
975 }
976 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
977 // Asked of repos, so changes delivered out of order end the same.
978 const repo = await this.repoById(event.data.repoId);
979 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
980 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
981 return;
982 }
983 if (event.type === "repo.default_branch_changed") {
984 // Asked of repos, so changes delivered out of order end the same.
985 const repo = await this.repoById(event.data.repoId);
986 await this.db
987 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
988 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
989 .run();
990 // Another branch has other files: they are read again from its head.
991 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
992 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
993 return;
994 }
995 if (
996 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
997 event.data.repoId
998 ) {
999 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1000 for (const row of rows.results) await this.detect(row, null, "packages");
1001 return;
1002 }
1003 if (event.type === "workspace.deleting") {
1004 // Hidden, not gone: every project it shows, including any building
1005 // from a repository it transferred away, until staff restore it or it
1006 // is purged. Those already hidden stay as they were.
1007 if (isProtectedWorkspace(event.data.slug)) return;
1008 const at = now();
1009 await this.db
1010 .prepare(
1011 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
1012 )
1013 .bind(at, event.data.slug.toLowerCase())
1014 .run();
1015 return;
1016 }
1017 if (event.type === "workspace.restored") {
1018 await this.db
1019 .prepare(
1020 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
1021 )
1022 .bind(event.data.slug.toLowerCase())
1023 .run();
1024 return;
1025 }
1026 if (event.type === "workspace.deleted") {
1027 // Its own repositories' projects go with them (`repo.purged`); any
1028 // building from a repository it transferred away goes now. It is not
1029 // backfilled again.
1030 const slug = event.data.slug.toLowerCase();
1031 const ids = "SELECT id FROM projects WHERE workspace = ?1";
1032 await this.db.batch([
1033 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
1034 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug),
1035 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
1036 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
1037 ]);
1038 return;
1039 }
1040 if (event.type === "git.push" && event.data.defaultBranch) {
1041 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1042 for (const row of rows.results) {
1043 await this.detect(row, event.data.after);
1044 }
1045 return;
1046 }
1047 if (event.type !== "repo.created") return;
1048 const actor = await this.workspaceActor(event.data.namespace);
1049 if (!actor) return;
1050 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
1051 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
1052 }
1053}
1054
1055/** One RPC method's answer. */
1056async function answer(service: Projects, method: string, args: any): Promise<Response> {
1057 switch (method) {
1058 case "list":
1059 return Response.json(await service.list(args));
1060 case "get":
1061 return Response.json(await service.get(args));
1062 case "by_repo":
1063 return Response.json(await service.byRepo(args));
1064 case "count":
1065 return Response.json(await service.count(args));
1066 case "create":
1067 return Response.json(await service.create(args));
1068 case "update":
1069 return Response.json(await service.update(args));
1070 case "set_production_image":
1071 return Response.json(await service.setProductionImage(args));
1072 case "external_productions":
1073 return Response.json(await service.externalProductions());
1074 case "deployments_changed":
1075 await service.deploymentsChanged(args);
1076 return Response.json(null);
1077 case "shortcuts":
1078 return Response.json(await service.shortcuts(args));
1079 case "pin":
1080 return Response.json(await service.pin(args));
1081 case "unpin":
1082 return Response.json(await service.unpin(args));
1083 case "reorder_pins":
1084 return Response.json(await service.reorderPins(args));
1085 case "visited":
1086 await service.visited(args);
1087 return Response.json(null);
1088 case "public_links":
1089 return Response.json(await service.publicLinks(args));
1090 default:
1091 return new Response("Unknown method\n", { status: 404 });
1092 }
1093}
1094
1095export default {
1096 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1097 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
1098 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1099 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1100 const opened = openD1(env.DB, request);
1101 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
1102 const args = (await request.json().catch(() => ({}))) as any;
1103 return opened.finish(await answer(service, match[1], args));
1104 },
1105
1106 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1107 const service = new Projects(env);
1108 for (const message of batch.messages) {
1109 try {
1110 await service.onEvent(message.body);
1111 message.ack();
1112 } catch (error) {
1113 console.error("projects could not handle", message.body.type, error);
1114 message.retry();
1115 }
1116 }
1117 },
1118} satisfies ExportedHandler<Env, G1tEvent>;