Skip to content

g1t/services/projects/src/index.ts

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