Skip to content

g1t/services/projects/src/index.ts

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