g1t/services/projects/src/index.ts

744 lines32,335 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Projects: what a workspace builds and runs, first on every page1/**
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
Project dependencies: addresses, preview stacks, Affects, and agents who know15import { parse as parseYaml } from "yaml";
16
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17import { NEEDS, repoRef } from "./access";
Fast pages, required checks on the branch, self-hosted runners, honest incidents18import { effectiveDescription, ownDescription } from "./description";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains19import { renameStatements } from "./rename";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look20import { moveStatements, slugOf, strandedQuery } from "./transfer";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains21
Projects: what a workspace builds and runs, first on every page22import {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23 can,
24 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains25 currentWorkspaceSlug,
Projects: what a workspace builds and runs, first on every page26 fail,
27 identityClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains28 staleSlugs,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look29 needs,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member30 isProtectedWorkspace,
Projects: what a workspace builds and runs, first on every page31 newId,
32 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents33 openD1,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look34 permission,
35 repoMove,
Projects: what a workspace builds and runs, first on every page36 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 staleMovedPaths,
Project dependencies: addresses, preview stacks, Affects, and agents who know38 type Dependencies,
39 type DependencyLink,
Projects: what a workspace builds and runs, first on every page40 type G1tEvent,
41 type NewProject,
42 type Project,
Project dependencies: addresses, preview stacks, Affects, and agents who know43 type ProjectGraph,
Projects: what a workspace builds and runs, first on every page44 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;
Fast pages, required checks on the branch, self-hosted runners, honest incidents62 /** The project's own description; null while it follows its repository's. */
Projects: what a workspace builds and runs, first on every page63 description: string | null;
Fast pages, required checks on the branch, self-hosted runners, honest incidents64 /** Its repository's description, kept from repos. */
65 repo_description?: string | null;
Projects: what a workspace builds and runs, first on every page66 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;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look77 /** 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;
Projects: what a workspace builds and runs, first on every page81};
82
83const now = () => new Date().toISOString();
84
Project dependencies: addresses, preview stacks, Affects, and agents who know85/** 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look90type LinkRow = { slug: string; name: string; alias: string | null; source: "ui" | "file"; repo_id: string; repo_namespace: string; repo_private: number };
Project dependencies: addresses, preview stacks, Affects, and agents who know91type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
92
Projects: what a workspace builds and runs, first on every page93function toProject(row: Row): Project {
Fast pages, required checks on the branch, self-hosted runners, honest incidents94 const { description, inherited } = effectiveDescription(row);
Projects: what a workspace builds and runs, first on every page95 return {
96 id: row.id,
97 workspace: row.workspace,
98 slug: row.slug,
99 name: row.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents100 description,
101 descriptionInherited: inherited,
Projects: what a workspace builds and runs, first on every page102 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look110 archived: !!row.repo_archived_at,
Projects: what a workspace builds and runs, first on every page111 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look161 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents162 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ?, repo_description = ? WHERE repo_id = ?",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look163 )
Fast pages, required checks on the branch, self-hosted runners, honest incidents164 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.description ?? null, repo.id)
Projects: what a workspace builds and runs, first on every page165 .run();
166 return;
167 }
168 const at = now();
169 await this.db
170 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents171 // 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look173 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
Fast pages, required checks on the branch, self-hosted runners, honest incidents174 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
Projects: what a workspace builds and runs, first on every page175 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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents182 repo.description ?? null,
Projects: what a workspace builds and runs, first on every page183 repo.id,
184 repo.namespace,
185 repo.name,
186 repo.isPrivate ? 1 : 0,
187 repo.defaultBranch,
188 createdBy,
189 at,
190 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look191 repo.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page192 )
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look209 /**
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 */
Projects: what a workspace builds and runs, first on every page214 private visible(row: Row, viewer: Viewer): boolean {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look215 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);
Projects: what a workspace builds and runs, first on every page227 }
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look233 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
Projects: what a workspace builds and runs, first on every page234 .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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look243 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page244 .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
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member250 /** How many projects a workspace shows: what deleting it would take with it. */
251 async count(a: { workspace: string }): Promise<number> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look252 const row = await this.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member253 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL")
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look254 .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> {
Projects: what a workspace builds and runs, first on every page261 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
262 method: "POST",
263 headers: { "content-type": "application/json" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look264 body: JSON.stringify({ id: repoId }),
Projects: what a workspace builds and runs, first on every page265 });
266 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look267 if (!repoPath) return null;
Projects: what a workspace builds and runs, first on every page268 const actor = await this.workspaceActor(repoPath.namespace);
269 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look270 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>();
Projects: what a workspace builds and runs, first on every page288 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;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look299 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
Projects: what a workspace builds and runs, first on every page300 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(
Fast pages, required checks on the branch, self-hosted runners, honest incidents314 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look315 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
Fast pages, required checks on the branch, self-hosted runners, honest incidents316 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Projects: what a workspace builds and runs, first on every page317 )
318 .bind(
319 id,
320 workspace,
321 slug,
322 name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents323 ownDescription(null, a.input.description ?? null),
324 repo.value.description ?? null,
Projects: what a workspace builds and runs, first on every page325 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look335 repo.value.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page336 )
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look349 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page350 .bind(workspace, a.slug.toLowerCase())
351 .first<Row>();
352 if (!row) return fail("not_found", "There is no such project.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 const allowed = this.changeable(row, a.actor, NEEDS.update);
354 if (!allowed.ok) return allowed;
Projects: what a workspace builds and runs, first on every page355 const name = a.changes.name?.trim() || row.name;
Fast pages, required checks on the branch, self-hosted runners, honest incidents356 // Blank or null goes back to following the repository's description.
357 const description = ownDescription(row.description, a.changes.description);
Projects: what a workspace builds and runs, first on every page358 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
Project dependencies: addresses, preview stacks, Affects, and agents who know367 // ---- Dependencies ------------------------------------------------------
368
369 private async row(workspace: string, slug: string): Promise<Row | null> {
370 return this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look371 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Project dependencies: addresses, preview stacks, Affects, and agents who know372 .bind(workspace.toLowerCase(), slug.toLowerCase())
373 .first<Row>();
374 }
375
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look376 /** 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> {
Project dependencies: addresses, preview stacks, Affects, and agents who know378 const [out, into] = await Promise.all([
379 this.db
380 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look381 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know383 )
384 .bind(projectId)
385 .all<LinkRow>(),
386 this.db
387 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look388 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know390 )
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 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look395 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) };
Project dependencies: addresses, preview stacks, Affects, and agents who know397 }
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.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look424 return ok(await this.links(row.id, a.viewer));
Project dependencies: addresses, preview stacks, Affects, and agents who know425 }
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.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look454 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}.`);
Project dependencies: addresses, preview stacks, Affects, and agents who know458 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;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look466 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know467 }
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)]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look471 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.");
Project dependencies: addresses, preview stacks, Affects, and agents who know475 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.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look480 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know481 }
482
483 async graph(a: { projectId: string }): Promise<ProjectGraph> {
484 const [out, into] = await Promise.all([
485 this.db
486 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look487 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know489 )
490 .bind(a.projectId)
491 .all<NodeRow>(),
492 this.db
493 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look494 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know496 )
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 }[]> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look506 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>();
Project dependencies: addresses, preview stacks, Affects, and agents who know510 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
Projects: what a workspace builds and runs, first on every page553 async onEvent(event: G1tEvent): Promise<void> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains554 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look560 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();
Fast pages, required checks on the branch, self-hosted runners, honest incidents613 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look624 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 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member642 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look665 if (event.type === "workspace.deleted") {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member666 // 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 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look676 return;
677 }
Project dependencies: addresses, preview stacks, Affects, and agents who know678 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 }
Projects: what a workspace builds and runs, first on every page683 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents691/** 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));
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member700 case "count":
701 return Response.json(await service.count(args));
Fast pages, required checks on the branch, self-hosted runners, honest incidents702 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
Projects: what a workspace builds and runs, first on every page721export 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 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents725 // 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);
Projects: what a workspace builds and runs, first on every page728 const args = (await request.json().catch(() => ({}))) as any;
Fast pages, required checks on the branch, self-hosted runners, honest incidents729 return opened.finish(await answer(service, match[1], args));
Projects: what a workspace builds and runs, first on every page730 },
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>;