g1t/services/projects/src/index.ts

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