Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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