g1t/services/projects/src/index.ts

717 lines31,165 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,
Projects: what a workspace builds and runs, first on every page30 newId,
31 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents32 openD1,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33 permission,
34 repoMove,
Projects: what a workspace builds and runs, first on every page35 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look36 staleMovedPaths,
Project dependencies: addresses, preview stacks, Affects, and agents who know37 type Dependencies,
38 type DependencyLink,
Projects: what a workspace builds and runs, first on every page39 type G1tEvent,
40 type NewProject,
41 type Project,
Project dependencies: addresses, preview stacks, Affects, and agents who know42 type ProjectGraph,
Projects: what a workspace builds and runs, first on every page43 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;
Fast pages, required checks on the branch, self-hosted runners, honest incidents61 /** The project's own description; null while it follows its repository's. */
Projects: what a workspace builds and runs, first on every page62 description: string | null;
Fast pages, required checks on the branch, self-hosted runners, honest incidents63 /** Its repository's description, kept from repos. */
64 repo_description?: string | null;
Projects: what a workspace builds and runs, first on every page65 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;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look76 /** 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;
Projects: what a workspace builds and runs, first on every page80};
81
82const now = () => new Date().toISOString();
83
Project dependencies: addresses, preview stacks, Affects, and agents who know84/** 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look89type 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 know90type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
91
Projects: what a workspace builds and runs, first on every page92function toProject(row: Row): Project {
Fast pages, required checks on the branch, self-hosted runners, honest incidents93 const { description, inherited } = effectiveDescription(row);
Projects: what a workspace builds and runs, first on every page94 return {
95 id: row.id,
96 workspace: row.workspace,
97 slug: row.slug,
98 name: row.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents99 description,
100 descriptionInherited: inherited,
Projects: what a workspace builds and runs, first on every page101 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look109 archived: !!row.repo_archived_at,
Projects: what a workspace builds and runs, first on every page110 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look160 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents161 "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 look162 )
Fast pages, required checks on the branch, self-hosted runners, honest incidents163 .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 page164 .run();
165 return;
166 }
167 const at = now();
168 await this.db
169 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents170 // 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look172 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 incidents173 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
Projects: what a workspace builds and runs, first on every page174 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,
Fast pages, required checks on the branch, self-hosted runners, honest incidents181 repo.description ?? null,
Projects: what a workspace builds and runs, first on every page182 repo.id,
183 repo.namespace,
184 repo.name,
185 repo.isPrivate ? 1 : 0,
186 repo.defaultBranch,
187 createdBy,
188 at,
189 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look190 repo.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page191 )
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look208 /**
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 */
Projects: what a workspace builds and runs, first on every page213 private visible(row: Row, viewer: Viewer): boolean {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look214 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);
Projects: what a workspace builds and runs, first on every page226 }
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look232 .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 page233 .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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look242 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page243 .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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look249 /**
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> {
Projects: what a workspace builds and runs, first on every page265 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
266 method: "POST",
267 headers: { "content-type": "application/json" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look268 body: JSON.stringify({ id: repoId }),
Projects: what a workspace builds and runs, first on every page269 });
270 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 look271 if (!repoPath) return null;
Projects: what a workspace builds and runs, first on every page272 const actor = await this.workspaceActor(repoPath.namespace);
273 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 look274 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>();
Projects: what a workspace builds and runs, first on every page292 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;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look303 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
Projects: what a workspace builds and runs, first on every page304 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(
Fast pages, required checks on the branch, self-hosted runners, honest incidents318 `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 look319 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 incidents320 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Projects: what a workspace builds and runs, first on every page321 )
322 .bind(
323 id,
324 workspace,
325 slug,
326 name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents327 ownDescription(null, a.input.description ?? null),
328 repo.value.description ?? null,
Projects: what a workspace builds and runs, first on every page329 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look339 repo.value.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page340 )
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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page354 .bind(workspace, a.slug.toLowerCase())
355 .first<Row>();
356 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 look357 const allowed = this.changeable(row, a.actor, NEEDS.update);
358 if (!allowed.ok) return allowed;
Projects: what a workspace builds and runs, first on every page359 const name = a.changes.name?.trim() || row.name;
Fast pages, required checks on the branch, self-hosted runners, honest incidents360 // Blank or null goes back to following the repository's description.
361 const description = ownDescription(row.description, a.changes.description);
Projects: what a workspace builds and runs, first on every page362 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
Project dependencies: addresses, preview stacks, Affects, and agents who know371 // ---- Dependencies ------------------------------------------------------
372
373 private async row(workspace: string, slug: string): Promise<Row | null> {
374 return this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look375 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Project dependencies: addresses, preview stacks, Affects, and agents who know376 .bind(workspace.toLowerCase(), slug.toLowerCase())
377 .first<Row>();
378 }
379
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look380 /** 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> {
Project dependencies: addresses, preview stacks, Affects, and agents who know382 const [out, into] = await Promise.all([
383 this.db
384 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look385 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know387 )
388 .bind(projectId)
389 .all<LinkRow>(),
390 this.db
391 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look392 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know394 )
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 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look399 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) };
Project dependencies: addresses, preview stacks, Affects, and agents who know401 }
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.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look428 return ok(await this.links(row.id, a.viewer));
Project dependencies: addresses, preview stacks, Affects, and agents who know429 }
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.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look458 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}.`);
Project dependencies: addresses, preview stacks, Affects, and agents who know462 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;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look470 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know471 }
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)]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look475 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.");
Project dependencies: addresses, preview stacks, Affects, and agents who know479 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.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look484 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know485 }
486
487 async graph(a: { projectId: string }): Promise<ProjectGraph> {
488 const [out, into] = await Promise.all([
489 this.db
490 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look491 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know493 )
494 .bind(a.projectId)
495 .all<NodeRow>(),
496 this.db
497 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look498 `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`,
Project dependencies: addresses, preview stacks, Affects, and agents who know500 )
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 }[]> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look510 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>();
Project dependencies: addresses, preview stacks, Affects, and agents who know514 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
Projects: what a workspace builds and runs, first on every page557 async onEvent(event: G1tEvent): Promise<void> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains558 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look564 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();
Fast pages, required checks on the branch, self-hosted runners, honest incidents617 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 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look628 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 }
Project dependencies: addresses, preview stacks, Affects, and agents who know651 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 }
Projects: what a workspace builds and runs, first on every page656 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents664/** 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
Projects: what a workspace builds and runs, first on every page694export 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 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents698 // 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);
Projects: what a workspace builds and runs, first on every page701 const args = (await request.json().catch(() => ({}))) as any;
Fast pages, required checks on the branch, self-hosted runners, honest incidents702 return opened.finish(await answer(service, match[1], args));
Projects: what a workspace builds and runs, first on every page703 },
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>;