g1t/services/projects/src/index.ts

744 lines32,335 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 { renameStatements } from "./rename";
20import { moveStatements, slugOf, strandedQuery } from "./transfer";
21
22import {
23 can,
24 currentMovedPath,
25 currentWorkspaceSlug,
26 fail,
27 identityClient,
28 staleSlugs,
29 needs,
30 isProtectedWorkspace,
31 newId,
32 ok,
33 openD1,
34 permission,
35 repoMove,
36 reposClient,
37 staleMovedPaths,
38 type Dependencies,
39 type DependencyLink,
40 type G1tEvent,
41 type NewProject,
42 type Project,
43 type ProjectGraph,
44 type Repo,
45 type Result,
46 type ServiceBinding,
47 type User,
48 type Viewer,
49} from "@g1t/contracts";
50
51type Env = {
52 DB: D1Database;
53 REPOS: ServiceBinding;
54 IDENTITY: ServiceBinding;
55};
56
57type Row = {
58 id: string;
59 workspace: string;
60 slug: string;
61 name: string;
62 /** The project's own description; null while it follows its repository's. */
63 description: string | null;
64 /** Its repository's description, kept from repos. */
65 repo_description?: string | null;
66 source_kind: string;
67 repo_id: string;
68 repo_namespace: string;
69 repo_name: string;
70 repo_private: number;
71 default_branch: string;
72 root_dir: string;
73 is_primary: number;
74 created_by: string;
75 created_at: string;
76 updated_at: string;
77 /** Set while its repository is deleted; the project is hidden until it is restored. */
78 repo_deleted_at: string | null;
79 /** When its repository was archived; null while it is not. */
80 repo_archived_at?: string | null;
81};
82
83const now = () => new Date().toISOString();
84
85/** A variable's name for a dependency's address, spelled as secrets' names are. */
86const ALIAS = /^[A-Z_][A-Z0-9_]{0,99}$/;
87/** How many dependencies a project may declare. */
88const MAX_DEPENDENCIES = 50;
89
90type LinkRow = { slug: string; name: string; alias: string | null; source: "ui" | "file"; repo_id: string; repo_namespace: string; repo_private: number };
91type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
92
93function toProject(row: Row): Project {
94 const { description, inherited } = effectiveDescription(row);
95 return {
96 id: row.id,
97 workspace: row.workspace,
98 slug: row.slug,
99 name: row.name,
100 description,
101 descriptionInherited: inherited,
102 source: {
103 kind: "hosted",
104 repoId: row.repo_id,
105 repo: { namespace: row.repo_namespace, name: row.repo_name },
106 rootDir: row.root_dir,
107 defaultBranch: row.default_branch,
108 },
109 private: !!row.repo_private,
110 archived: !!row.repo_archived_at,
111 primary: !!row.is_primary,
112 createdBy: row.created_by,
113 createdAt: row.created_at,
114 updatedAt: row.updated_at,
115 };
116}
117
118function isMember(viewer: Viewer, workspace: string): boolean {
119 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
120}
121
122class Projects {
123 constructor(private readonly env: Env) {}
124
125 private get db() {
126 return this.env.DB;
127 }
128
129 private async workspaceActor(slug: string): Promise<User | null> {
130 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
131 if (!workspace) return null;
132 return {
133 id: workspace.id,
134 username: workspace.slug,
135 kind: "workspace",
136 verified: true,
137 workspaces: [{ slug: workspace.slug, role: "member" }],
138 };
139 }
140
141 /** A free slug for `wanted` in the workspace. */
142 private async freeSlug(workspace: string, wanted: string): Promise<string> {
143 const base = slugOf(wanted) || "project";
144 for (let n = 1; n < 100; n++) {
145 const slug = n === 1 ? base : `${base}-${n}`;
146 const taken = await this.db
147 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
148 .bind(workspace, slug)
149 .first();
150 if (!taken) return slug;
151 }
152 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
153 }
154
155 /** Gives a repository its own project, unless it has one. */
156 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
157 if (repo.forkOf) return;
158 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
159 if (existing) {
160 await this.db
161 .prepare(
162 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ?, repo_description = ? WHERE repo_id = ?",
163 )
164 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.description ?? null, repo.id)
165 .run();
166 return;
167 }
168 const at = now();
169 await this.db
170 .prepare(
171 // No description of its own: it shows the repository's, as that changes.
172 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
173 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
174 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
175 ON CONFLICT (workspace, slug) DO NOTHING`,
176 )
177 .bind(
178 newId("prj"),
179 repo.namespace.toLowerCase(),
180 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
181 repo.name,
182 repo.description ?? null,
183 repo.id,
184 repo.namespace,
185 repo.name,
186 repo.isPrivate ? 1 : 0,
187 repo.defaultBranch,
188 createdBy,
189 at,
190 at,
191 repo.archivedAt ?? null,
192 )
193 .run();
194 }
195
196 /** Projects for a workspace's repositories made before projects existed. Once. */
197 private async backfill(workspace: string): Promise<void> {
198 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
199 if (done) return;
200 const actor = await this.workspaceActor(workspace);
201 if (!actor) return;
202 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
203 for (const repo of list) {
204 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
205 }
206 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
207 }
208
209 /**
210 * Whether the viewer can read the project's repository. The workspace's
211 * own token sees all its projects, also one left building from a
212 * repository that moved to another workspace.
213 */
214 private visible(row: Row, viewer: Viewer): boolean {
215 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
216 return permission(viewer, repoRef(row)) != null;
217 }
218
219 /**
220 * Whether the actor may change the project: not found when they cannot
221 * read its repository, refused when their role there is too low.
222 */
223 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
224 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
225 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
226 return ok(true);
227 }
228
229 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
230 const workspace = a.workspace.toLowerCase();
231 await this.backfill(workspace);
232 const rows = await this.db
233 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
234 .bind(workspace)
235 .all<Row>();
236 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
237 }
238
239 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
240 const workspace = a.workspace.toLowerCase();
241 await this.backfill(workspace);
242 const row = await this.db
243 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
244 .bind(workspace, a.slug.toLowerCase())
245 .first<Row>();
246 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
247 return ok(toProject(row));
248 }
249
250 /** How many projects a workspace shows: what deleting it would take with it. */
251 async count(a: { workspace: string }): Promise<number> {
252 const row = await this.db
253 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL")
254 .bind(a.workspace.toLowerCase())
255 .first<{ n: number }>();
256 return row?.n ?? 0;
257 }
258
259 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
260 private async repoById(repoId: string): Promise<Repo | null> {
261 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
262 method: "POST",
263 headers: { "content-type": "application/json" },
264 body: JSON.stringify({ id: repoId }),
265 });
266 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
267 if (!repoPath) return null;
268 const actor = await this.workspaceActor(repoPath.namespace);
269 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
270 return repo?.ok ? repo.value : null;
271 }
272
273 async byRepo(a: { repoId: string }): Promise<Project[]> {
274 const rows = await this.db
275 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
276 .bind(a.repoId)
277 .all<Row>();
278 // A deleted repository's projects are hidden until it is restored.
279 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
280 // A repository from before projects: give it its own now.
281 const repo = await this.repoById(a.repoId);
282 if (!repo) return [];
283 await this.ensureFor(repo, "g1t");
284 const again = await this.db
285 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
286 .bind(a.repoId)
287 .all<Row>();
288 return again.results.map(toProject);
289 }
290
291 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
292 const workspace = a.workspace.toLowerCase();
293 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
294 if (a.input.repo.namespace.toLowerCase() !== workspace) {
295 return fail("invalid", "A project builds from one of its own workspace's repositories.");
296 }
297 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
298 if (!repo.ok) return repo;
299 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
300 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
301 const name = a.input.name.trim();
302 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
303 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
304 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
305 const slug = slugOf(name);
306 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
307 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
308 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
309 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
310 const at = now();
311 const id = newId("prj");
312 await this.db
313 .prepare(
314 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
315 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
316 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
317 )
318 .bind(
319 id,
320 workspace,
321 slug,
322 name,
323 ownDescription(null, a.input.description ?? null),
324 repo.value.description ?? null,
325 repo.value.id,
326 repo.value.namespace,
327 repo.value.name,
328 repo.value.isPrivate ? 1 : 0,
329 repo.value.defaultBranch,
330 rootDir,
331 primary ? 1 : 0,
332 a.actor.username,
333 at,
334 at,
335 repo.value.archivedAt ?? null,
336 )
337 .run();
338 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
339 }
340
341 async update(a: {
342 actor: User;
343 workspace: string;
344 slug: string;
345 changes: { name?: string; description?: string | null; rootDir?: string };
346 }): Promise<Result<Project>> {
347 const workspace = a.workspace.toLowerCase();
348 const row = await this.db
349 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
350 .bind(workspace, a.slug.toLowerCase())
351 .first<Row>();
352 if (!row) return fail("not_found", "There is no such project.");
353 const allowed = this.changeable(row, a.actor, NEEDS.update);
354 if (!allowed.ok) return allowed;
355 const name = a.changes.name?.trim() || row.name;
356 // Blank or null goes back to following the repository's description.
357 const description = ownDescription(row.description, a.changes.description);
358 const rootDir = a.changes.rootDir === undefined ? row.root_dir : a.changes.rootDir.trim().replace(/^\/+|\/+$/g, "");
359 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
360 await this.db
361 .prepare("UPDATE projects SET name = ?, description = ?, root_dir = ?, updated_at = ? WHERE id = ?")
362 .bind(name.slice(0, 100), description, rootDir, now(), row.id)
363 .run();
364 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!));
365 }
366
367 // ---- Dependencies ------------------------------------------------------
368
369 private async row(workspace: string, slug: string): Promise<Row | null> {
370 return this.db
371 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
372 .bind(workspace.toLowerCase(), slug.toLowerCase())
373 .first<Row>();
374 }
375
376 /** A project's dependencies; for a viewer, only the projects whose repositories they can read. */
377 private async links(projectId: string, viewer?: Viewer): Promise<Dependencies> {
378 const [out, into] = await Promise.all([
379 this.db
380 .prepare(
381 `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
382 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL ORDER BY p.name COLLATE NOCASE`,
383 )
384 .bind(projectId)
385 .all<LinkRow>(),
386 this.db
387 .prepare(
388 `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
389 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL ORDER BY p.name COLLATE NOCASE`,
390 )
391 .bind(projectId)
392 .all<LinkRow>(),
393 ]);
394 const link = (r: LinkRow): DependencyLink => ({ slug: r.slug, name: r.name, as: r.alias, source: r.source });
395 const shown = (r: LinkRow) => viewer === undefined || permission(viewer, repoRef(r)) != null;
396 return { dependsOn: out.results.filter(shown).map(link), usedBy: into.results.filter(shown).map(link) };
397 }
398
399 /** Whether `from` already reaches `to` through dependencies. */
400 private async reaches(from: string, to: string): Promise<boolean> {
401 const seen = new Set<string>([from]);
402 let frontier = [from];
403 while (frontier.length > 0) {
404 const marks = frontier.map(() => "?").join(", ");
405 const next = await this.db
406 .prepare(`SELECT depends_on_id AS id FROM dependencies WHERE project_id IN (${marks})`)
407 .bind(...frontier)
408 .all<{ id: string }>();
409 frontier = [];
410 for (const { id } of next.results) {
411 if (id === to) return true;
412 if (!seen.has(id)) {
413 seen.add(id);
414 frontier.push(id);
415 }
416 }
417 }
418 return false;
419 }
420
421 async dependencies(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Dependencies>> {
422 const row = await this.row(a.workspace, a.slug);
423 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
424 return ok(await this.links(row.id, a.viewer));
425 }
426
427 /** Records `row` using `target`, after the checks every way of declaring one shares. */
428 private async declare(row: Row, target: Row, alias: string | null, source: "ui" | "file", by: string): Promise<Result<true>> {
429 if (target.id === row.id) return fail("invalid", "A project cannot depend on itself.");
430 if (alias != null && !ALIAS.test(alias)) {
431 return fail("invalid", "The variable's name is capital letters, digits and underscores, such as API_URL.");
432 }
433 if (await this.reaches(target.id, row.id)) {
434 return fail("conflict", `${target.slug} already depends on ${row.slug}, directly or through others; that would be a cycle.`);
435 }
436 const count = await this.db
437 .prepare("SELECT COUNT(*) AS n FROM dependencies WHERE project_id = ?")
438 .bind(row.id)
439 .first<{ n: number }>();
440 if ((count?.n ?? 0) >= MAX_DEPENDENCIES) return fail("invalid", `A project can depend on at most ${MAX_DEPENDENCIES} others.`);
441 await this.db
442 .prepare(
443 `INSERT INTO dependencies (project_id, depends_on_id, alias, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)
444 ON CONFLICT (project_id, depends_on_id) DO UPDATE SET alias = excluded.alias, source = excluded.source`,
445 )
446 .bind(row.id, target.id, alias, source, by, now())
447 .run();
448 return ok(true);
449 }
450
451 async addDependency(a: { actor: User; workspace: string; slug: string; on: string; as: string | null }): Promise<Result<Dependencies>> {
452 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
453 if (!row) return fail("not_found", "There is no such project.");
454 const allowed = this.changeable(row, a.actor, NEEDS.addDependency);
455 if (!allowed.ok) return allowed;
456 // A project the actor cannot read is not there for them to depend on.
457 if (!target || !this.visible(target, a.actor)) return fail("not_found", `${a.workspace} has no project called ${a.on}.`);
458 const existing = await this.db
459 .prepare("SELECT source FROM dependencies WHERE project_id = ? AND depends_on_id = ?")
460 .bind(row.id, target.id)
461 .first<{ source: string }>();
462 if (existing?.source === "file") return fail("conflict", "This dependency is declared in .g1t/project.yml; change it there.");
463 const alias = a.as?.trim() ? a.as.trim().toUpperCase() : null;
464 const done = await this.declare(row, target, alias, "ui", a.actor.username);
465 if (!done.ok) return done;
466 return ok(await this.links(row.id, a.actor));
467 }
468
469 async removeDependency(a: { actor: User; workspace: string; slug: string; on: string }): Promise<Result<Dependencies>> {
470 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
471 if (!row) return fail("not_found", "There is no such project.");
472 const allowed = this.changeable(row, a.actor, NEEDS.removeDependency);
473 if (!allowed.ok) return allowed;
474 if (!target) return fail("not_found", "There is no such dependency.");
475 const removed = await this.db
476 .prepare("DELETE FROM dependencies WHERE project_id = ? AND depends_on_id = ? AND source = 'ui' RETURNING project_id")
477 .bind(row.id, target.id)
478 .first();
479 if (!removed) return fail("conflict", "This dependency is declared in .g1t/project.yml, or does not exist; change the file.");
480 return ok(await this.links(row.id, a.actor));
481 }
482
483 async graph(a: { projectId: string }): Promise<ProjectGraph> {
484 const [out, into] = await Promise.all([
485 this.db
486 .prepare(
487 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
488 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL`,
489 )
490 .bind(a.projectId)
491 .all<NodeRow>(),
492 this.db
493 .prepare(
494 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.project_id
495 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL`,
496 )
497 .bind(a.projectId)
498 .all<NodeRow>(),
499 ]);
500 const node = (r: NodeRow) => ({ id: r.id, slug: r.slug, workspace: r.workspace, as: r.alias });
501 return { dependsOn: out.results.map(node), usedBy: into.results.map(node) };
502 }
503
504 /** For the runner: each project on a repository, with what it uses and what uses it. */
505 async contextForRepo(a: { repoId: string }): Promise<{ slug: string; name: string; dependencies: Dependencies }[]> {
506 const rows = await this.db
507 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
508 .bind(a.repoId)
509 .all<Row>();
510 return Promise.all(rows.results.map(async (row) => ({ slug: row.slug, name: row.name, dependencies: await this.links(row.id) })));
511 }
512
513 /**
514 * A project's `.g1t/project.yml` at a commit of its default branch:
515 *
516 * dependsOn:
517 * - project: api
518 * as: API_URL
519 *
520 * Its dependencies replace the ones the file declared before. Ones that
521 * cannot be kept (an unknown project, a cycle) are left out.
522 */
523 private async syncFile(row: Row, commit: string): Promise<void> {
524 const actor = await this.workspaceActor(row.workspace);
525 if (!actor) return;
526 const path = row.root_dir ? `${row.root_dir}/.g1t/project.yml` : ".g1t/project.yml";
527 const blob = await reposClient(this.env.REPOS).blob({ namespace: row.repo_namespace, name: row.repo_name }, actor, commit, path);
528 if (!blob.ok || blob.value.text == null) {
529 // No file (any more): what it declared goes with it.
530 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
531 return;
532 }
533 let declared: unknown[] = [];
534 try {
535 const parsed = parseYaml(blob.value.text) as { dependsOn?: unknown } | null;
536 if (Array.isArray(parsed?.dependsOn)) declared = parsed.dependsOn;
537 } catch (error) {
538 console.error("could not read", path, "of", row.slug, error);
539 return;
540 }
541 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
542 for (const entry of declared.slice(0, MAX_DEPENDENCIES)) {
543 const item = entry as { project?: unknown; as?: unknown } | string;
544 const on = typeof item === "string" ? item : typeof item?.project === "string" ? item.project : null;
545 if (!on) continue;
546 const target = await this.row(row.workspace, on);
547 if (!target) continue;
548 const alias = typeof item === "object" && typeof item.as === "string" ? item.as.trim().toUpperCase() : null;
549 await this.declare(row, target, alias, "file", "g1t");
550 }
551 }
552
553 async onEvent(event: G1tEvent): Promise<void> {
554 if (event.type === "workspace.renamed") {
555 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
556 const statements = renameStatements(staleSlugs(event.data, current), current);
557 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
558 return;
559 }
560 const move = repoMove(event);
561 if (move) {
562 const current = await currentMovedPath(this.env.REPOS, move);
563 const stale = staleMovedPaths(move, current);
564 if (stale.length === 0) return;
565 const statements = moveStatements(stale, current, move.repoId);
566 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
567 // A project whose slug the destination already had moves under a free one.
568 const query = strandedQuery(stale, current, move.repoId);
569 if (!query) return;
570 const workspace = current.split("/")[0]!;
571 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
572 for (const row of stranded.results) {
573 const slug = await this.freeSlug(workspace, row.slug);
574 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
575 await this.db
576 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
577 .bind(workspace, slug, workspace, now(), row.id)
578 .run();
579 }
580 return;
581 }
582 if (event.type === "repo.deleted") {
583 // Hidden, not gone: a restore brings its projects back as they were.
584 await this.db
585 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
586 .bind(now(), event.data.repoId)
587 .run();
588 return;
589 }
590 if (event.type === "repo.restored") {
591 await this.db
592 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
593 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
594 .run();
595 return;
596 }
597 if (event.type === "repo.purged") {
598 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
599 await this.db.batch([
600 this.db
601 .prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`)
602 .bind(event.data.repoId),
603 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
604 ]);
605 return;
606 }
607 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
608 // Who may see its projects follows who may see the repository.
609 await this.db
610 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
611 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
612 .run();
613 if (event.type === "repo.updated") {
614 // Projects without a description of their own show the repository's.
615 // Asked of repos, so changes delivered out of order end the same.
616 const repo = await this.repoById(event.data.repoId);
617 if (repo) {
618 await this.db
619 .prepare("UPDATE projects SET repo_description = ? WHERE repo_id = ?")
620 .bind(repo.description ?? null, event.data.repoId)
621 .run();
622 }
623 }
624 return;
625 }
626 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
627 // Asked of repos, so changes delivered out of order end the same.
628 const repo = await this.repoById(event.data.repoId);
629 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
630 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
631 return;
632 }
633 if (event.type === "repo.default_branch_changed") {
634 // Asked of repos, so changes delivered out of order end the same.
635 const repo = await this.repoById(event.data.repoId);
636 await this.db
637 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
638 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
639 .run();
640 return;
641 }
642 if (event.type === "workspace.deleting") {
643 // Hidden, not gone: every project it shows, including any building
644 // from a repository it transferred away, until staff restore it or it
645 // is purged. Those already hidden stay as they were.
646 if (isProtectedWorkspace(event.data.slug)) return;
647 const at = now();
648 await this.db
649 .prepare(
650 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
651 )
652 .bind(at, event.data.slug.toLowerCase())
653 .run();
654 return;
655 }
656 if (event.type === "workspace.restored") {
657 await this.db
658 .prepare(
659 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
660 )
661 .bind(event.data.slug.toLowerCase())
662 .run();
663 return;
664 }
665 if (event.type === "workspace.deleted") {
666 // Its own repositories' projects go with them (`repo.purged`); any
667 // building from a repository it transferred away goes now. It is not
668 // backfilled again.
669 const slug = event.data.slug.toLowerCase();
670 const ids = "SELECT id FROM projects WHERE workspace = ?1";
671 await this.db.batch([
672 this.db.prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`).bind(slug),
673 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
674 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
675 ]);
676 return;
677 }
678 if (event.type === "git.push" && event.data.defaultBranch) {
679 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
680 for (const row of rows.results) await this.syncFile(row, event.data.after);
681 return;
682 }
683 if (event.type !== "repo.created") return;
684 const actor = await this.workspaceActor(event.data.namespace);
685 if (!actor) return;
686 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
687 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
688 }
689}
690
691/** One RPC method's answer. */
692async function answer(service: Projects, method: string, args: any): Promise<Response> {
693 switch (method) {
694 case "list":
695 return Response.json(await service.list(args));
696 case "get":
697 return Response.json(await service.get(args));
698 case "by_repo":
699 return Response.json(await service.byRepo(args));
700 case "count":
701 return Response.json(await service.count(args));
702 case "create":
703 return Response.json(await service.create(args));
704 case "update":
705 return Response.json(await service.update(args));
706 case "dependencies":
707 return Response.json(await service.dependencies(args));
708 case "add_dependency":
709 return Response.json(await service.addDependency(args));
710 case "remove_dependency":
711 return Response.json(await service.removeDependency(args));
712 case "graph":
713 return Response.json(await service.graph(args));
714 case "context_for_repo":
715 return Response.json(await service.contextForRepo(args));
716 default:
717 return new Response("Unknown method\n", { status: 404 });
718 }
719}
720
721export default {
722 async fetch(request: Request, env: Env): Promise<Response> {
723 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
724 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
725 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
726 const opened = openD1(env.DB, request);
727 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env);
728 const args = (await request.json().catch(() => ({}))) as any;
729 return opened.finish(await answer(service, match[1], args));
730 },
731
732 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
733 const service = new Projects(env);
734 for (const message of batch.messages) {
735 try {
736 await service.onEvent(message.body);
737 message.ack();
738 } catch (error) {
739 console.error("projects could not handle", message.body.type, error);
740 message.retry();
741 }
742 }
743 },
744} satisfies ExportedHandler<Env, G1tEvent>;