g1t/services/projects/src/index.ts

929 lines41,899 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";
A project is an app or a library: libraries show their package and how to ship a release, not production19import { type KindFacts, type RootFiles, MANIFESTS, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains20import { renameStatements } from "./rename";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21import { moveStatements, slugOf, strandedQuery } from "./transfer";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains22
Projects: what a workspace builds and runs, first on every page23import {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24 can,
25 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains26 currentWorkspaceSlug,
Projects: what a workspace builds and runs, first on every page27 fail,
28 identityClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains29 staleSlugs,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look30 needs,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member31 isProtectedWorkspace,
Projects: what a workspace builds and runs, first on every page32 newId,
33 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents34 openD1,
A project is an app or a library: libraries show their package and how to ship a release, not production35 packagesClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look36 permission,
37 repoMove,
Projects: what a workspace builds and runs, first on every page38 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39 staleMovedPaths,
Project dependencies: addresses, preview stacks, Affects, and agents who know40 type Dependencies,
41 type DependencyLink,
A project is an app or a library: libraries show their package and how to ship a release, not production42 type DeploysSetting,
43 type Ecosystem,
Projects: what a workspace builds and runs, first on every page44 type G1tEvent,
45 type NewProject,
46 type Project,
A project is an app or a library: libraries show their package and how to ship a release, not production47 type ProjectEcosystem,
Project dependencies: addresses, preview stacks, Affects, and agents who know48 type ProjectGraph,
Projects: what a workspace builds and runs, first on every page49 type Repo,
50 type Result,
51 type ServiceBinding,
52 type User,
53 type Viewer,
54} from "@g1t/contracts";
55
56type Env = {
57 DB: D1Database;
58 REPOS: ServiceBinding;
59 IDENTITY: ServiceBinding;
A project is an app or a library: libraries show their package and how to ship a release, not production60 PACKAGES: ServiceBinding;
61 DEPLOYMENTS: ServiceBinding;
Projects: what a workspace builds and runs, first on every page62};
63
64type Row = {
65 id: string;
66 workspace: string;
67 slug: string;
68 name: string;
Fast pages, required checks on the branch, self-hosted runners, honest incidents69 /** The project's own description; null while it follows its repository's. */
Projects: what a workspace builds and runs, first on every page70 description: string | null;
Fast pages, required checks on the branch, self-hosted runners, honest incidents71 /** Its repository's description, kept from repos. */
72 repo_description?: string | null;
Projects: what a workspace builds and runs, first on every page73 source_kind: string;
74 repo_id: string;
75 repo_namespace: string;
76 repo_name: string;
77 repo_private: number;
78 default_branch: string;
79 root_dir: string;
80 is_primary: number;
81 created_by: string;
82 created_at: string;
83 updated_at: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look84 /** Set while its repository is deleted; the project is hidden until it is restored. */
85 repo_deleted_at: string | null;
86 /** When its repository was archived; null while it is not. */
87 repo_archived_at?: string | null;
A project is an app or a library: libraries show their package and how to ship a release, not production88 /** auto, yes or no: whether it deploys (migrations/0007_deploys.sql). */
89 deploys?: string | null;
90 detected_kind?: string | null;
91 detected_detail?: string | null;
92 detected_ecosystem?: string | null;
93 /** The default branch's commit its files were read at; null until they have been. */
94 detected_commit?: string | null;
95 linked_package?: string | null;
96 deployments_on?: number | null;
Projects: what a workspace builds and runs, first on every page97};
98
A project is an app or a library: libraries show their package and how to ship a release, not production99/** How a package its repository publishes is named in why a project is a library. */
100const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
101 container: "container image",
102 npm: "npm package",
103 composer: "Composer package",
104 cargo: "crate",
105 go: "Go module",
106};
107
108/** How many projects one listing reads the files of, in the background, before it has. */
109const DETECT_PER_LIST = 10;
110
Projects: what a workspace builds and runs, first on every page111const now = () => new Date().toISOString();
112
Project dependencies: addresses, preview stacks, Affects, and agents who know113/** A variable's name for a dependency's address, spelled as secrets' names are. */
114const ALIAS = /^[A-Z_][A-Z0-9_]{0,99}$/;
115/** How many dependencies a project may declare. */
116const MAX_DEPENDENCIES = 50;
117
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look118type 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 know119type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
120
Projects: what a workspace builds and runs, first on every page121function toProject(row: Row): Project {
Fast pages, required checks on the branch, self-hosted runners, honest incidents122 const { description, inherited } = effectiveDescription(row);
A project is an app or a library: libraries show their package and how to ship a release, not production123 const deploys = deploysSetting(row.deploys);
124 const facts: Omit<KindFacts, "deploys"> = {
125 deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
126 linkedPackage: row.linked_package ?? null,
127 detected:
128 row.detected_commit == null
129 ? null
130 : { kind: row.detected_kind === "app" || row.detected_kind === "library" ? row.detected_kind : null, detail: row.detected_detail ?? "" },
131 };
132 const { kind, reason } = resolveKind({ deploys, ...facts });
133 const auto = resolveKind({ deploys: "auto", ...facts });
Projects: what a workspace builds and runs, first on every page134 return {
135 id: row.id,
136 workspace: row.workspace,
137 slug: row.slug,
138 name: row.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents139 description,
140 descriptionInherited: inherited,
Projects: what a workspace builds and runs, first on every page141 source: {
142 kind: "hosted",
143 repoId: row.repo_id,
144 repo: { namespace: row.repo_namespace, name: row.repo_name },
145 rootDir: row.root_dir,
146 defaultBranch: row.default_branch,
147 },
148 private: !!row.repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look149 archived: !!row.repo_archived_at,
Projects: what a workspace builds and runs, first on every page150 primary: !!row.is_primary,
A project is an app or a library: libraries show their package and how to ship a release, not production151 deploys,
152 kind,
153 kindReason: reason,
154 detected: auto,
155 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
Projects: what a workspace builds and runs, first on every page156 createdBy: row.created_by,
157 createdAt: row.created_at,
158 updatedAt: row.updated_at,
159 };
160}
161
162function isMember(viewer: Viewer, workspace: string): boolean {
163 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
164}
165
166class Projects {
A project is an app or a library: libraries show their package and how to ship a release, not production167 /** `defer` runs work after the answer is sent: the request's waitUntil. */
168 constructor(
169 private readonly env: Env,
170 private readonly defer: (work: Promise<unknown>) => void = () => {},
171 ) {}
Projects: what a workspace builds and runs, first on every page172
173 private get db() {
174 return this.env.DB;
175 }
176
177 private async workspaceActor(slug: string): Promise<User | null> {
178 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
179 if (!workspace) return null;
180 return {
181 id: workspace.id,
182 username: workspace.slug,
183 kind: "workspace",
184 verified: true,
185 workspaces: [{ slug: workspace.slug, role: "member" }],
186 };
187 }
188
189 /** A free slug for `wanted` in the workspace. */
190 private async freeSlug(workspace: string, wanted: string): Promise<string> {
191 const base = slugOf(wanted) || "project";
192 for (let n = 1; n < 100; n++) {
193 const slug = n === 1 ? base : `${base}-${n}`;
194 const taken = await this.db
195 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
196 .bind(workspace, slug)
197 .first();
198 if (!taken) return slug;
199 }
200 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
201 }
202
203 /** Gives a repository its own project, unless it has one. */
204 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
205 if (repo.forkOf) return;
206 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
207 if (existing) {
208 await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look209 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents210 "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 look211 )
Fast pages, required checks on the branch, self-hosted runners, honest incidents212 .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 page213 .run();
214 return;
215 }
216 const at = now();
217 await this.db
218 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents219 // No description of its own: it shows the repository's, as that changes.
220 `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 look221 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 incidents222 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
Projects: what a workspace builds and runs, first on every page223 ON CONFLICT (workspace, slug) DO NOTHING`,
224 )
225 .bind(
226 newId("prj"),
227 repo.namespace.toLowerCase(),
228 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
229 repo.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents230 repo.description ?? null,
Projects: what a workspace builds and runs, first on every page231 repo.id,
232 repo.namespace,
233 repo.name,
234 repo.isPrivate ? 1 : 0,
235 repo.defaultBranch,
236 createdBy,
237 at,
238 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look239 repo.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page240 )
241 .run();
242 }
243
244 /** Projects for a workspace's repositories made before projects existed. Once. */
245 private async backfill(workspace: string): Promise<void> {
246 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
247 if (done) return;
248 const actor = await this.workspaceActor(workspace);
249 if (!actor) return;
250 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
251 for (const repo of list) {
252 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
253 }
254 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
255 }
256
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look257 /**
258 * Whether the viewer can read the project's repository. The workspace's
259 * own token sees all its projects, also one left building from a
260 * repository that moved to another workspace.
261 */
Projects: what a workspace builds and runs, first on every page262 private visible(row: Row, viewer: Viewer): boolean {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look263 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
264 return permission(viewer, repoRef(row)) != null;
265 }
266
267 /**
268 * Whether the actor may change the project: not found when they cannot
269 * read its repository, refused when their role there is too low.
270 */
271 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
272 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
273 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
274 return ok(true);
Projects: what a workspace builds and runs, first on every page275 }
276
277 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
278 const workspace = a.workspace.toLowerCase();
279 await this.backfill(workspace);
280 const rows = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look281 .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 page282 .bind(workspace)
283 .all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production284 // Projects whose files have not been read yet are read after this answer,
285 // a few at a time; until then they show as apps, as before.
286 const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
287 if (unread.length) this.defer(Promise.all(unread.map((row) => this.detect(row).catch((error) => console.warn("detect", row.slug, error)))));
Projects: what a workspace builds and runs, first on every page288 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
289 }
290
291 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
292 const workspace = a.workspace.toLowerCase();
293 await this.backfill(workspace);
294 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look295 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page296 .bind(workspace, a.slug.toLowerCase())
297 .first<Row>();
298 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
A project is an app or a library: libraries show their package and how to ship a release, not production299 if (row.detected_commit == null) {
300 // Once per project: what its files say, read now so its first page is right.
301 const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
302 if (read) return ok(toProject(read));
303 }
Projects: what a workspace builds and runs, first on every page304 return ok(toProject(row));
305 }
306
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member307 /** How many projects a workspace shows: what deleting it would take with it. */
308 async count(a: { workspace: string }): Promise<number> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look309 const row = await this.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member310 .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 look311 .bind(a.workspace.toLowerCase())
312 .first<{ n: number }>();
313 return row?.n ?? 0;
314 }
315
316 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
317 private async repoById(repoId: string): Promise<Repo | null> {
Projects: what a workspace builds and runs, first on every page318 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
319 method: "POST",
320 headers: { "content-type": "application/json" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look321 body: JSON.stringify({ id: repoId }),
Projects: what a workspace builds and runs, first on every page322 });
323 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 look324 if (!repoPath) return null;
Projects: what a workspace builds and runs, first on every page325 const actor = await this.workspaceActor(repoPath.namespace);
326 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 look327 return repo?.ok ? repo.value : null;
328 }
329
330 async byRepo(a: { repoId: string }): Promise<Project[]> {
331 const rows = await this.db
332 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
333 .bind(a.repoId)
334 .all<Row>();
335 // A deleted repository's projects are hidden until it is restored.
336 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
337 // A repository from before projects: give it its own now.
338 const repo = await this.repoById(a.repoId);
339 if (!repo) return [];
340 await this.ensureFor(repo, "g1t");
341 const again = await this.db
342 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
343 .bind(a.repoId)
344 .all<Row>();
Projects: what a workspace builds and runs, first on every page345 return again.results.map(toProject);
346 }
347
348 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
349 const workspace = a.workspace.toLowerCase();
350 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
351 if (a.input.repo.namespace.toLowerCase() !== workspace) {
352 return fail("invalid", "A project builds from one of its own workspace's repositories.");
353 }
354 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
355 if (!repo.ok) return repo;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look356 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
Projects: what a workspace builds and runs, first on every page357 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
358 const name = a.input.name.trim();
359 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
360 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
361 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
362 const slug = slugOf(name);
363 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
364 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
365 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
366 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
367 const at = now();
368 const id = newId("prj");
369 await this.db
370 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents371 `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 look372 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 incidents373 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Projects: what a workspace builds and runs, first on every page374 )
375 .bind(
376 id,
377 workspace,
378 slug,
379 name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents380 ownDescription(null, a.input.description ?? null),
381 repo.value.description ?? null,
Projects: what a workspace builds and runs, first on every page382 repo.value.id,
383 repo.value.namespace,
384 repo.value.name,
385 repo.value.isPrivate ? 1 : 0,
386 repo.value.defaultBranch,
387 rootDir,
388 primary ? 1 : 0,
389 a.actor.username,
390 at,
391 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look392 repo.value.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page393 )
394 .run();
395 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
396 }
397
398 async update(a: {
399 actor: User;
400 workspace: string;
401 slug: string;
A project is an app or a library: libraries show their package and how to ship a release, not production402 changes: { name?: string; description?: string | null; rootDir?: string; deploys?: DeploysSetting };
Projects: what a workspace builds and runs, first on every page403 }): Promise<Result<Project>> {
404 const workspace = a.workspace.toLowerCase();
405 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page407 .bind(workspace, a.slug.toLowerCase())
408 .first<Row>();
409 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 look410 const allowed = this.changeable(row, a.actor, NEEDS.update);
411 if (!allowed.ok) return allowed;
Projects: what a workspace builds and runs, first on every page412 const name = a.changes.name?.trim() || row.name;
Fast pages, required checks on the branch, self-hosted runners, honest incidents413 // Blank or null goes back to following the repository's description.
414 const description = ownDescription(row.description, a.changes.description);
Projects: what a workspace builds and runs, first on every page415 const rootDir = a.changes.rootDir === undefined ? row.root_dir : a.changes.rootDir.trim().replace(/^\/+|\/+$/g, "");
416 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
A project is an app or a library: libraries show their package and how to ship a release, not production417 const deploys = a.changes.deploys === undefined ? deploysSetting(row.deploys) : deploysSetting(a.changes.deploys);
418 // Not deploying while Deployments run would leave apps up that no page
419 // offers: a person turns them off first, in Deployments settings.
420 const deploying = deploys === "no" && deploysSetting(row.deploys) !== "no" ? (row.deployments_on ?? (await this.deploymentsOn(row.id))) : false;
421 if (deploying) {
422 return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
423 }
Projects: what a workspace builds and runs, first on every page424 await this.db
A project is an app or a library: libraries show their package and how to ship a release, not production425 .prepare("UPDATE projects SET name = ?, description = ?, root_dir = ?, deploys = ?, updated_at = ? WHERE id = ?")
426 .bind(name.slice(0, 100), description, rootDir, deploys, now(), row.id)
Projects: what a workspace builds and runs, first on every page427 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production428 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
429 // Another root has other files: read them again.
430 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
431 return ok(toProject(saved));
Projects: what a workspace builds and runs, first on every page432 }
433
A project is an app or a library: libraries show their package and how to ship a release, not production434 async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
435 await this.db.prepare("UPDATE projects SET deployments_on = ? WHERE id = ?").bind(a.enabled ? 1 : 0, a.projectId).run();
436 }
437
438 // ---- App or library --------------------------------------------------
439
440 /**
441 * Reads what decides whether the project is a library: the manifests at
442 * its root at `commit` (the default branch's head when null), a package
443 * its repository publishes, and, the first time, whether Deployments are
444 * on. Files are read again only for a commit they were not read at.
445 * `packages` reads only the packages. Returns the row as it is now.
446 */
447 private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
448 const actor = await this.workspaceActor(row.workspace);
449 if (!actor) return row;
450 const repo = { namespace: row.repo_namespace, name: row.repo_name };
451 const sets: string[] = [];
452 const values: unknown[] = [];
453 const set = (column: string, value: unknown) => {
454 sets.push(`${column} = ?`);
455 values.push(value);
456 };
457 const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
458 const [files, linked, deploying] = await Promise.all([
459 filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
460 this.linkedPackage(row, actor),
461 row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
462 ]);
463 if (files) {
464 const found = files.commit ? detectKind(files.root) : null;
465 set("detected_kind", found?.kind ?? null);
466 set("detected_detail", found?.detail ?? null);
467 set("detected_ecosystem", found?.ecosystem ?? null);
468 set("detected_commit", files.commit);
469 }
470 if (linked !== undefined) set("linked_package", linked);
471 if (deploying != null) set("deployments_on", deploying ? 1 : 0);
472 if (sets.length === 0) return row;
473 await this.db
474 .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
475 .bind(...values, row.id)
476 .run();
477 return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
478 }
479
480 /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
481 private async readRoot(
482 repo: { namespace: string; name: string },
483 actor: User,
484 commit: string | null,
485 rootDir: string,
486 ): Promise<{ commit: string; root: RootFiles } | null> {
487 const client = reposClient(this.env.REPOS);
488 const empty = { commit: "", root: { entries: [], text: {} } };
489 const tree = await client.tree(repo, actor, commit, rootDir);
490 if (!tree.ok) return null;
491 const head = commit ?? tree.value.head?.hash ?? "";
492 if (!head) return empty;
493 const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
494 const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
495 const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
496 const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
497 const [texts, src, pub] = await Promise.all([
498 Promise.all(
499 names.map(async (name) => {
500 const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
501 return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
502 }),
503 ),
504 entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
505 entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
506 ]);
507 const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
508 return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
509 }
510
511 /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
512 private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
513 const listed = await packagesClient(this.env.PACKAGES)
514 .list(row.workspace, actor, { repoId: row.repo_id })
515 .catch(() => null);
516 if (!listed?.ok) return undefined;
517 // An image is how an app ships too; it says nothing about being a library.
518 const pkg = listed.value.find((p) => p.ecosystem !== "container");
519 if (!pkg) return null;
520 return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
521 }
522
523 /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
524 private async deploymentsOn(projectId: string): Promise<boolean | null> {
525 const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
526 method: "POST",
527 headers: { "content-type": "application/json" },
528 body: JSON.stringify({ projectId }),
529 }).catch(() => null);
530 if (!answer?.ok) return null;
531 const value = (await answer.json().catch(() => null)) as unknown;
532 return typeof value === "boolean" ? value : null;
533 }
534
Project dependencies: addresses, preview stacks, Affects, and agents who know535 // ---- Dependencies ------------------------------------------------------
536
537 private async row(workspace: string, slug: string): Promise<Row | null> {
538 return this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look539 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Project dependencies: addresses, preview stacks, Affects, and agents who know540 .bind(workspace.toLowerCase(), slug.toLowerCase())
541 .first<Row>();
542 }
543
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look544 /** A project's dependencies; for a viewer, only the projects whose repositories they can read. */
545 private async links(projectId: string, viewer?: Viewer): Promise<Dependencies> {
Project dependencies: addresses, preview stacks, Affects, and agents who know546 const [out, into] = await Promise.all([
547 this.db
548 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look549 `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
550 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 know551 )
552 .bind(projectId)
553 .all<LinkRow>(),
554 this.db
555 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look556 `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
557 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 know558 )
559 .bind(projectId)
560 .all<LinkRow>(),
561 ]);
562 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 look563 const shown = (r: LinkRow) => viewer === undefined || permission(viewer, repoRef(r)) != null;
564 return { dependsOn: out.results.filter(shown).map(link), usedBy: into.results.filter(shown).map(link) };
Project dependencies: addresses, preview stacks, Affects, and agents who know565 }
566
567 /** Whether `from` already reaches `to` through dependencies. */
568 private async reaches(from: string, to: string): Promise<boolean> {
569 const seen = new Set<string>([from]);
570 let frontier = [from];
571 while (frontier.length > 0) {
572 const marks = frontier.map(() => "?").join(", ");
573 const next = await this.db
574 .prepare(`SELECT depends_on_id AS id FROM dependencies WHERE project_id IN (${marks})`)
575 .bind(...frontier)
576 .all<{ id: string }>();
577 frontier = [];
578 for (const { id } of next.results) {
579 if (id === to) return true;
580 if (!seen.has(id)) {
581 seen.add(id);
582 frontier.push(id);
583 }
584 }
585 }
586 return false;
587 }
588
589 async dependencies(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Dependencies>> {
590 const row = await this.row(a.workspace, a.slug);
591 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 look592 return ok(await this.links(row.id, a.viewer));
Project dependencies: addresses, preview stacks, Affects, and agents who know593 }
594
595 /** Records `row` using `target`, after the checks every way of declaring one shares. */
596 private async declare(row: Row, target: Row, alias: string | null, source: "ui" | "file", by: string): Promise<Result<true>> {
597 if (target.id === row.id) return fail("invalid", "A project cannot depend on itself.");
598 if (alias != null && !ALIAS.test(alias)) {
599 return fail("invalid", "The variable's name is capital letters, digits and underscores, such as API_URL.");
600 }
601 if (await this.reaches(target.id, row.id)) {
602 return fail("conflict", `${target.slug} already depends on ${row.slug}, directly or through others; that would be a cycle.`);
603 }
604 const count = await this.db
605 .prepare("SELECT COUNT(*) AS n FROM dependencies WHERE project_id = ?")
606 .bind(row.id)
607 .first<{ n: number }>();
608 if ((count?.n ?? 0) >= MAX_DEPENDENCIES) return fail("invalid", `A project can depend on at most ${MAX_DEPENDENCIES} others.`);
609 await this.db
610 .prepare(
611 `INSERT INTO dependencies (project_id, depends_on_id, alias, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)
612 ON CONFLICT (project_id, depends_on_id) DO UPDATE SET alias = excluded.alias, source = excluded.source`,
613 )
614 .bind(row.id, target.id, alias, source, by, now())
615 .run();
616 return ok(true);
617 }
618
619 async addDependency(a: { actor: User; workspace: string; slug: string; on: string; as: string | null }): Promise<Result<Dependencies>> {
620 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
621 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 look622 const allowed = this.changeable(row, a.actor, NEEDS.addDependency);
623 if (!allowed.ok) return allowed;
624 // A project the actor cannot read is not there for them to depend on.
625 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 know626 const existing = await this.db
627 .prepare("SELECT source FROM dependencies WHERE project_id = ? AND depends_on_id = ?")
628 .bind(row.id, target.id)
629 .first<{ source: string }>();
630 if (existing?.source === "file") return fail("conflict", "This dependency is declared in .g1t/project.yml; change it there.");
631 const alias = a.as?.trim() ? a.as.trim().toUpperCase() : null;
632 const done = await this.declare(row, target, alias, "ui", a.actor.username);
633 if (!done.ok) return done;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look634 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know635 }
636
637 async removeDependency(a: { actor: User; workspace: string; slug: string; on: string }): Promise<Result<Dependencies>> {
638 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 look639 if (!row) return fail("not_found", "There is no such project.");
640 const allowed = this.changeable(row, a.actor, NEEDS.removeDependency);
641 if (!allowed.ok) return allowed;
642 if (!target) return fail("not_found", "There is no such dependency.");
Project dependencies: addresses, preview stacks, Affects, and agents who know643 const removed = await this.db
644 .prepare("DELETE FROM dependencies WHERE project_id = ? AND depends_on_id = ? AND source = 'ui' RETURNING project_id")
645 .bind(row.id, target.id)
646 .first();
647 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 look648 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know649 }
650
651 async graph(a: { projectId: string }): Promise<ProjectGraph> {
652 const [out, into] = await Promise.all([
653 this.db
654 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look655 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
656 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL`,
Project dependencies: addresses, preview stacks, Affects, and agents who know657 )
658 .bind(a.projectId)
659 .all<NodeRow>(),
660 this.db
661 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look662 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.project_id
663 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL`,
Project dependencies: addresses, preview stacks, Affects, and agents who know664 )
665 .bind(a.projectId)
666 .all<NodeRow>(),
667 ]);
668 const node = (r: NodeRow) => ({ id: r.id, slug: r.slug, workspace: r.workspace, as: r.alias });
669 return { dependsOn: out.results.map(node), usedBy: into.results.map(node) };
670 }
671
672 /** For the runner: each project on a repository, with what it uses and what uses it. */
673 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 look674 const rows = await this.db
675 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
676 .bind(a.repoId)
677 .all<Row>();
Project dependencies: addresses, preview stacks, Affects, and agents who know678 return Promise.all(rows.results.map(async (row) => ({ slug: row.slug, name: row.name, dependencies: await this.links(row.id) })));
679 }
680
681 /**
682 * A project's `.g1t/project.yml` at a commit of its default branch:
683 *
684 * dependsOn:
685 * - project: api
686 * as: API_URL
687 *
688 * Its dependencies replace the ones the file declared before. Ones that
689 * cannot be kept (an unknown project, a cycle) are left out.
690 */
691 private async syncFile(row: Row, commit: string): Promise<void> {
692 const actor = await this.workspaceActor(row.workspace);
693 if (!actor) return;
694 const path = row.root_dir ? `${row.root_dir}/.g1t/project.yml` : ".g1t/project.yml";
695 const blob = await reposClient(this.env.REPOS).blob({ namespace: row.repo_namespace, name: row.repo_name }, actor, commit, path);
696 if (!blob.ok || blob.value.text == null) {
697 // No file (any more): what it declared goes with it.
698 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
699 return;
700 }
701 let declared: unknown[] = [];
702 try {
703 const parsed = parseYaml(blob.value.text) as { dependsOn?: unknown } | null;
704 if (Array.isArray(parsed?.dependsOn)) declared = parsed.dependsOn;
705 } catch (error) {
706 console.error("could not read", path, "of", row.slug, error);
707 return;
708 }
709 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
710 for (const entry of declared.slice(0, MAX_DEPENDENCIES)) {
711 const item = entry as { project?: unknown; as?: unknown } | string;
712 const on = typeof item === "string" ? item : typeof item?.project === "string" ? item.project : null;
713 if (!on) continue;
714 const target = await this.row(row.workspace, on);
715 if (!target) continue;
716 const alias = typeof item === "object" && typeof item.as === "string" ? item.as.trim().toUpperCase() : null;
717 await this.declare(row, target, alias, "file", "g1t");
718 }
719 }
720
Projects: what a workspace builds and runs, first on every page721 async onEvent(event: G1tEvent): Promise<void> {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains722 if (event.type === "workspace.renamed") {
723 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
724 const statements = renameStatements(staleSlugs(event.data, current), current);
725 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
726 return;
727 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look728 const move = repoMove(event);
729 if (move) {
730 const current = await currentMovedPath(this.env.REPOS, move);
731 const stale = staleMovedPaths(move, current);
732 if (stale.length === 0) return;
733 const statements = moveStatements(stale, current, move.repoId);
734 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
735 // A project whose slug the destination already had moves under a free one.
736 const query = strandedQuery(stale, current, move.repoId);
737 if (!query) return;
738 const workspace = current.split("/")[0]!;
739 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
740 for (const row of stranded.results) {
741 const slug = await this.freeSlug(workspace, row.slug);
742 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
743 await this.db
744 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
745 .bind(workspace, slug, workspace, now(), row.id)
746 .run();
747 }
748 return;
749 }
750 if (event.type === "repo.deleted") {
751 // Hidden, not gone: a restore brings its projects back as they were.
752 await this.db
753 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
754 .bind(now(), event.data.repoId)
755 .run();
756 return;
757 }
758 if (event.type === "repo.restored") {
759 await this.db
760 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
761 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
762 .run();
763 return;
764 }
765 if (event.type === "repo.purged") {
766 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
767 await this.db.batch([
768 this.db
769 .prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`)
770 .bind(event.data.repoId),
771 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
772 ]);
773 return;
774 }
775 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
776 // Who may see its projects follows who may see the repository.
777 await this.db
778 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
779 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
780 .run();
Fast pages, required checks on the branch, self-hosted runners, honest incidents781 if (event.type === "repo.updated") {
782 // Projects without a description of their own show the repository's.
783 // Asked of repos, so changes delivered out of order end the same.
784 const repo = await this.repoById(event.data.repoId);
785 if (repo) {
786 await this.db
787 .prepare("UPDATE projects SET repo_description = ? WHERE repo_id = ?")
788 .bind(repo.description ?? null, event.data.repoId)
789 .run();
790 }
791 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look792 return;
793 }
794 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
795 // Asked of repos, so changes delivered out of order end the same.
796 const repo = await this.repoById(event.data.repoId);
797 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
798 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
799 return;
800 }
801 if (event.type === "repo.default_branch_changed") {
802 // Asked of repos, so changes delivered out of order end the same.
803 const repo = await this.repoById(event.data.repoId);
804 await this.db
805 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
806 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
807 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production808 // Another branch has other files: they are read again from its head.
809 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
810 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
811 return;
812 }
813 if (
814 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
815 event.data.repoId
816 ) {
817 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
818 for (const row of rows.results) await this.detect(row, null, "packages");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look819 return;
820 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member821 if (event.type === "workspace.deleting") {
822 // Hidden, not gone: every project it shows, including any building
823 // from a repository it transferred away, until staff restore it or it
824 // is purged. Those already hidden stay as they were.
825 if (isProtectedWorkspace(event.data.slug)) return;
826 const at = now();
827 await this.db
828 .prepare(
829 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
830 )
831 .bind(at, event.data.slug.toLowerCase())
832 .run();
833 return;
834 }
835 if (event.type === "workspace.restored") {
836 await this.db
837 .prepare(
838 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
839 )
840 .bind(event.data.slug.toLowerCase())
841 .run();
842 return;
843 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look844 if (event.type === "workspace.deleted") {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member845 // Its own repositories' projects go with them (`repo.purged`); any
846 // building from a repository it transferred away goes now. It is not
847 // backfilled again.
848 const slug = event.data.slug.toLowerCase();
849 const ids = "SELECT id FROM projects WHERE workspace = ?1";
850 await this.db.batch([
851 this.db.prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`).bind(slug),
852 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
853 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
854 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look855 return;
856 }
Project dependencies: addresses, preview stacks, Affects, and agents who know857 if (event.type === "git.push" && event.data.defaultBranch) {
858 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production859 for (const row of rows.results) {
860 await this.syncFile(row, event.data.after);
861 await this.detect(row, event.data.after);
862 }
Project dependencies: addresses, preview stacks, Affects, and agents who know863 return;
864 }
Projects: what a workspace builds and runs, first on every page865 if (event.type !== "repo.created") return;
866 const actor = await this.workspaceActor(event.data.namespace);
867 if (!actor) return;
868 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
869 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
870 }
871}
872
Fast pages, required checks on the branch, self-hosted runners, honest incidents873/** One RPC method's answer. */
874async function answer(service: Projects, method: string, args: any): Promise<Response> {
875 switch (method) {
876 case "list":
877 return Response.json(await service.list(args));
878 case "get":
879 return Response.json(await service.get(args));
880 case "by_repo":
881 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 member882 case "count":
883 return Response.json(await service.count(args));
Fast pages, required checks on the branch, self-hosted runners, honest incidents884 case "create":
885 return Response.json(await service.create(args));
886 case "update":
887 return Response.json(await service.update(args));
A project is an app or a library: libraries show their package and how to ship a release, not production888 case "deployments_changed":
889 await service.deploymentsChanged(args);
890 return Response.json(null);
Fast pages, required checks on the branch, self-hosted runners, honest incidents891 case "dependencies":
892 return Response.json(await service.dependencies(args));
893 case "add_dependency":
894 return Response.json(await service.addDependency(args));
895 case "remove_dependency":
896 return Response.json(await service.removeDependency(args));
897 case "graph":
898 return Response.json(await service.graph(args));
899 case "context_for_repo":
900 return Response.json(await service.contextForRepo(args));
901 default:
902 return new Response("Unknown method\n", { status: 404 });
903 }
904}
905
Projects: what a workspace builds and runs, first on every page906export default {
A project is an app or a library: libraries show their package and how to ship a release, not production907 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Projects: what a workspace builds and runs, first on every page908 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
909 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents910 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
911 const opened = openD1(env.DB, request);
A project is an app or a library: libraries show their package and how to ship a release, not production912 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page913 const args = (await request.json().catch(() => ({}))) as any;
Fast pages, required checks on the branch, self-hosted runners, honest incidents914 return opened.finish(await answer(service, match[1], args));
Projects: what a workspace builds and runs, first on every page915 },
916
917 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
918 const service = new Projects(env);
919 for (const message of batch.messages) {
920 try {
921 await service.onEvent(message.body);
922 message.ack();
923 } catch (error) {
924 console.error("projects could not handle", message.body.type, error);
925 message.retry();
926 }
927 }
928 },
929} satisfies ExportedHandler<Env, G1tEvent>;