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

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