Skip to content

g1t/services/projects/src/index.ts

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