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.

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