flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/projects/src/index.ts

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