Skip to content

g1t/services/projects/src/index.ts

1,110 lines50,381 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";
Projects: when each was last pushed, and how active it is lately20import { ACTIVE, decayed, raised } from "./activity";
Projects: pinned and recent projects, per person per workspace21import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains22import { renameStatements } from "./rename";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23import { moveStatements, slugOf, strandedQuery } from "./transfer";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains24
Projects: what a workspace builds and runs, first on every page25import {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look26 can,
27 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains28 currentWorkspaceSlug,
Projects: what a workspace builds and runs, first on every page29 fail,
30 identityClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains31 staleSlugs,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look32 needs,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member33 isProtectedWorkspace,
Projects: what a workspace builds and runs, first on every page34 newId,
35 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents36 openD1,
A project is an app or a library: libraries show their package and how to ship a release, not production37 packagesClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look38 permission,
39 repoMove,
Projects: what a workspace builds and runs, first on every page40 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41 staleMovedPaths,
Project dependencies: addresses, preview stacks, Affects, and agents who know42 type Dependencies,
43 type DependencyLink,
A project is an app or a library: libraries show their package and how to ship a release, not production44 type DeploysSetting,
45 type Ecosystem,
Projects: what a workspace builds and runs, first on every page46 type G1tEvent,
47 type NewProject,
48 type Project,
A project is an app or a library: libraries show their package and how to ship a release, not production49 type ProjectEcosystem,
Project dependencies: addresses, preview stacks, Affects, and agents who know50 type ProjectGraph,
Projects: pinned and recent projects, per person per workspace51 type ProjectShortcuts,
Projects: what a workspace builds and runs, first on every page52 type Repo,
53 type Result,
54 type ServiceBinding,
55 type User,
56 type Viewer,
57} from "@g1t/contracts";
58
59type Env = {
60 DB: D1Database;
61 REPOS: ServiceBinding;
62 IDENTITY: ServiceBinding;
A project is an app or a library: libraries show their package and how to ship a release, not production63 PACKAGES: ServiceBinding;
64 DEPLOYMENTS: ServiceBinding;
Projects: what a workspace builds and runs, first on every page65};
66
67type Row = {
68 id: string;
69 workspace: string;
70 slug: string;
71 name: string;
Fast pages, required checks on the branch, self-hosted runners, honest incidents72 /** The project's own description; null while it follows its repository's. */
Projects: what a workspace builds and runs, first on every page73 description: string | null;
Fast pages, required checks on the branch, self-hosted runners, honest incidents74 /** Its repository's description, kept from repos. */
75 repo_description?: string | null;
Projects: what a workspace builds and runs, first on every page76 source_kind: string;
77 repo_id: string;
78 repo_namespace: string;
79 repo_name: string;
80 repo_private: number;
81 default_branch: string;
82 root_dir: string;
83 is_primary: number;
84 created_by: string;
85 created_at: string;
86 updated_at: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look87 /** Set while its repository is deleted; the project is hidden until it is restored. */
88 repo_deleted_at: string | null;
89 /** When its repository was archived; null while it is not. */
90 repo_archived_at?: string | null;
A project is an app or a library: libraries show their package and how to ship a release, not production91 /** auto, yes or no: whether it deploys (migrations/0007_deploys.sql). */
92 deploys?: string | null;
93 detected_kind?: string | null;
94 detected_detail?: string | null;
95 detected_ecosystem?: string | null;
96 /** The default branch's commit its files were read at; null until they have been. */
97 detected_commit?: string | null;
98 linked_package?: string | null;
99 deployments_on?: number | null;
Projects: when each was last pushed, and how active it is lately100 /** When its repository was last pushed to (migrations/0009_activity.sql). */
101 pushed_at?: string | null;
102 /** How active it is lately, as of `active_at` (src/activity.ts). */
103 activity?: number | null;
104 active_at?: string | null;
Projects: what a workspace builds and runs, first on every page105};
106
A project is an app or a library: libraries show their package and how to ship a release, not production107/** How a package its repository publishes is named in why a project is a library. */
108const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
109 container: "container image",
110 npm: "npm package",
111 composer: "Composer package",
112 cargo: "crate",
113 go: "Go module",
Projects: a library publishing a Maven, NuGet or RubyGems package says so114 maven: "Maven package",
115 nuget: "NuGet package",
116 rubygems: "gem",
A project is an app or a library: libraries show their package and how to ship a release, not production117};
118
119/** How many projects one listing reads the files of, in the background, before it has. */
120const DETECT_PER_LIST = 10;
121
Projects: what a workspace builds and runs, first on every page122const now = () => new Date().toISOString();
123
Project dependencies: addresses, preview stacks, Affects, and agents who know124/** A variable's name for a dependency's address, spelled as secrets' names are. */
125const ALIAS = /^[A-Z_][A-Z0-9_]{0,99}$/;
126/** How many dependencies a project may declare. */
127const MAX_DEPENDENCIES = 50;
128
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look129type 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 know130type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
131
Projects: what a workspace builds and runs, first on every page132function toProject(row: Row): Project {
Fast pages, required checks on the branch, self-hosted runners, honest incidents133 const { description, inherited } = effectiveDescription(row);
A project is an app or a library: libraries show their package and how to ship a release, not production134 const deploys = deploysSetting(row.deploys);
135 const facts: Omit<KindFacts, "deploys"> = {
136 deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
137 linkedPackage: row.linked_package ?? null,
138 detected:
139 row.detected_commit == null
140 ? null
141 : { kind: row.detected_kind === "app" || row.detected_kind === "library" ? row.detected_kind : null, detail: row.detected_detail ?? "" },
142 };
143 const { kind, reason } = resolveKind({ deploys, ...facts });
144 const auto = resolveKind({ deploys: "auto", ...facts });
Projects: what a workspace builds and runs, first on every page145 return {
146 id: row.id,
147 workspace: row.workspace,
148 slug: row.slug,
149 name: row.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents150 description,
151 descriptionInherited: inherited,
Projects: what a workspace builds and runs, first on every page152 source: {
153 kind: "hosted",
154 repoId: row.repo_id,
155 repo: { namespace: row.repo_namespace, name: row.repo_name },
156 rootDir: row.root_dir,
157 defaultBranch: row.default_branch,
158 },
159 private: !!row.repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look160 archived: !!row.repo_archived_at,
Projects: what a workspace builds and runs, first on every page161 primary: !!row.is_primary,
A project is an app or a library: libraries show their package and how to ship a release, not production162 deploys,
163 kind,
164 kindReason: reason,
165 detected: auto,
166 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
Projects: what a workspace builds and runs, first on every page167 createdBy: row.created_by,
168 createdAt: row.created_at,
169 updatedAt: row.updated_at,
Projects: when each was last pushed, and how active it is lately170 pushedAt: row.pushed_at ?? null,
171 activity: decayed(row.activity ?? 0, row.active_at ?? null, now()),
Projects: what a workspace builds and runs, first on every page172 };
173}
174
175function isMember(viewer: Viewer, workspace: string): boolean {
176 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
177}
178
179class Projects {
A project is an app or a library: libraries show their package and how to ship a release, not production180 /** `defer` runs work after the answer is sent: the request's waitUntil. */
181 constructor(
182 private readonly env: Env,
183 private readonly defer: (work: Promise<unknown>) => void = () => {},
184 ) {}
Projects: what a workspace builds and runs, first on every page185
186 private get db() {
187 return this.env.DB;
188 }
189
190 private async workspaceActor(slug: string): Promise<User | null> {
191 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
192 if (!workspace) return null;
193 return {
194 id: workspace.id,
195 username: workspace.slug,
196 kind: "workspace",
197 verified: true,
198 workspaces: [{ slug: workspace.slug, role: "member" }],
199 };
200 }
201
202 /** A free slug for `wanted` in the workspace. */
203 private async freeSlug(workspace: string, wanted: string): Promise<string> {
204 const base = slugOf(wanted) || "project";
205 for (let n = 1; n < 100; n++) {
206 const slug = n === 1 ? base : `${base}-${n}`;
207 const taken = await this.db
208 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
209 .bind(workspace, slug)
210 .first();
211 if (!taken) return slug;
212 }
213 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
214 }
215
216 /** Gives a repository its own project, unless it has one. */
217 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
218 if (repo.forkOf) return;
219 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
220 if (existing) {
221 await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look222 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents223 "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 look224 )
Fast pages, required checks on the branch, self-hosted runners, honest incidents225 .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 page226 .run();
227 return;
228 }
229 const at = now();
230 await this.db
231 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents232 // No description of its own: it shows the repository's, as that changes.
233 `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 look234 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 incidents235 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
Projects: what a workspace builds and runs, first on every page236 ON CONFLICT (workspace, slug) DO NOTHING`,
237 )
238 .bind(
239 newId("prj"),
240 repo.namespace.toLowerCase(),
241 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
242 repo.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents243 repo.description ?? null,
Projects: what a workspace builds and runs, first on every page244 repo.id,
245 repo.namespace,
246 repo.name,
247 repo.isPrivate ? 1 : 0,
248 repo.defaultBranch,
249 createdBy,
250 at,
251 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look252 repo.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page253 )
254 .run();
255 }
256
257 /** Projects for a workspace's repositories made before projects existed. Once. */
258 private async backfill(workspace: string): Promise<void> {
259 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
260 if (done) return;
261 const actor = await this.workspaceActor(workspace);
262 if (!actor) return;
263 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
264 for (const repo of list) {
265 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
266 }
267 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
268 }
269
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look270 /**
271 * Whether the viewer can read the project's repository. The workspace's
272 * own token sees all its projects, also one left building from a
273 * repository that moved to another workspace.
274 */
Projects: what a workspace builds and runs, first on every page275 private visible(row: Row, viewer: Viewer): boolean {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look276 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
277 return permission(viewer, repoRef(row)) != null;
278 }
279
280 /**
281 * Whether the actor may change the project: not found when they cannot
282 * read its repository, refused when their role there is too low.
283 */
284 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
285 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
286 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
287 return ok(true);
Projects: what a workspace builds and runs, first on every page288 }
289
290 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
291 const workspace = a.workspace.toLowerCase();
292 await this.backfill(workspace);
293 const rows = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 .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 page295 .bind(workspace)
296 .all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production297 // Projects whose files have not been read yet are read after this answer,
298 // a few at a time; until then they show as apps, as before.
299 const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
300 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 page301 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
302 }
303
304 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
305 const workspace = a.workspace.toLowerCase();
306 await this.backfill(workspace);
307 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look308 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page309 .bind(workspace, a.slug.toLowerCase())
310 .first<Row>();
311 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 production312 if (row.detected_commit == null) {
313 // Once per project: what its files say, read now so its first page is right.
314 const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
315 if (read) return ok(toProject(read));
316 }
Projects: what a workspace builds and runs, first on every page317 return ok(toProject(row));
318 }
319
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member320 /** How many projects a workspace shows: what deleting it would take with it. */
321 async count(a: { workspace: string }): Promise<number> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look322 const row = await this.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member323 .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 look324 .bind(a.workspace.toLowerCase())
325 .first<{ n: number }>();
326 return row?.n ?? 0;
327 }
328
329 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
330 private async repoById(repoId: string): Promise<Repo | null> {
Projects: what a workspace builds and runs, first on every page331 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
332 method: "POST",
333 headers: { "content-type": "application/json" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look334 body: JSON.stringify({ id: repoId }),
Projects: what a workspace builds and runs, first on every page335 });
336 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 look337 if (!repoPath) return null;
Projects: what a workspace builds and runs, first on every page338 const actor = await this.workspaceActor(repoPath.namespace);
339 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 look340 return repo?.ok ? repo.value : null;
341 }
342
343 async byRepo(a: { repoId: string }): Promise<Project[]> {
344 const rows = await this.db
345 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
346 .bind(a.repoId)
347 .all<Row>();
348 // A deleted repository's projects are hidden until it is restored.
349 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
350 // A repository from before projects: give it its own now.
351 const repo = await this.repoById(a.repoId);
352 if (!repo) return [];
353 await this.ensureFor(repo, "g1t");
354 const again = await this.db
355 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
356 .bind(a.repoId)
357 .all<Row>();
Projects: what a workspace builds and runs, first on every page358 return again.results.map(toProject);
359 }
360
361 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
362 const workspace = a.workspace.toLowerCase();
363 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
364 if (a.input.repo.namespace.toLowerCase() !== workspace) {
365 return fail("invalid", "A project builds from one of its own workspace's repositories.");
366 }
367 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
368 if (!repo.ok) return repo;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look369 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
Projects: what a workspace builds and runs, first on every page370 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
371 const name = a.input.name.trim();
372 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
373 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
374 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
375 const slug = slugOf(name);
376 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
377 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
378 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
379 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
380 const at = now();
381 const id = newId("prj");
382 await this.db
383 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents384 `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 look385 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 incidents386 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Projects: what a workspace builds and runs, first on every page387 )
388 .bind(
389 id,
390 workspace,
391 slug,
392 name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents393 ownDescription(null, a.input.description ?? null),
394 repo.value.description ?? null,
Projects: what a workspace builds and runs, first on every page395 repo.value.id,
396 repo.value.namespace,
397 repo.value.name,
398 repo.value.isPrivate ? 1 : 0,
399 repo.value.defaultBranch,
400 rootDir,
401 primary ? 1 : 0,
402 a.actor.username,
403 at,
404 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look405 repo.value.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page406 )
407 .run();
408 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
409 }
410
411 async update(a: {
412 actor: User;
413 workspace: string;
414 slug: string;
A project is an app or a library: libraries show their package and how to ship a release, not production415 changes: { name?: string; description?: string | null; rootDir?: string; deploys?: DeploysSetting };
Projects: what a workspace builds and runs, first on every page416 }): Promise<Result<Project>> {
417 const workspace = a.workspace.toLowerCase();
418 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look419 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page420 .bind(workspace, a.slug.toLowerCase())
421 .first<Row>();
422 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 look423 const allowed = this.changeable(row, a.actor, NEEDS.update);
424 if (!allowed.ok) return allowed;
Projects: what a workspace builds and runs, first on every page425 const name = a.changes.name?.trim() || row.name;
Fast pages, required checks on the branch, self-hosted runners, honest incidents426 // Blank or null goes back to following the repository's description.
427 const description = ownDescription(row.description, a.changes.description);
Projects: what a workspace builds and runs, first on every page428 const rootDir = a.changes.rootDir === undefined ? row.root_dir : a.changes.rootDir.trim().replace(/^\/+|\/+$/g, "");
429 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 production430 const deploys = a.changes.deploys === undefined ? deploysSetting(row.deploys) : deploysSetting(a.changes.deploys);
431 // Not deploying while Deployments run would leave apps up that no page
432 // offers: a person turns them off first, in Deployments settings.
433 const deploying = deploys === "no" && deploysSetting(row.deploys) !== "no" ? (row.deployments_on ?? (await this.deploymentsOn(row.id))) : false;
434 if (deploying) {
435 return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
436 }
Projects: what a workspace builds and runs, first on every page437 await this.db
A project is an app or a library: libraries show their package and how to ship a release, not production438 .prepare("UPDATE projects SET name = ?, description = ?, root_dir = ?, deploys = ?, updated_at = ? WHERE id = ?")
439 .bind(name.slice(0, 100), description, rootDir, deploys, now(), row.id)
Projects: what a workspace builds and runs, first on every page440 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production441 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
442 // Another root has other files: read them again.
443 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
444 return ok(toProject(saved));
Projects: what a workspace builds and runs, first on every page445 }
446
A project is an app or a library: libraries show their package and how to ship a release, not production447 async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
448 await this.db.prepare("UPDATE projects SET deployments_on = ? WHERE id = ?").bind(a.enabled ? 1 : 0, a.projectId).run();
449 }
450
Projects: pinned and recent projects, per person per workspace451 // ---- Pinned and recent ---------------------------------------------------
452
453 /** The person's pins in the workspace, in order, as rows: hidden projects left out. */
454 private async pinRows(userId: string, workspace: string): Promise<Row[]> {
455 const rows = await this.db
456 .prepare(
457 `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id
458 WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`,
459 )
460 .bind(userId, workspace.toLowerCase())
461 .all<Row>();
462 return rows.results;
463 }
464
465 /** Writes the person's pins in the workspace in this order, by project id. */
466 private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> {
467 const at = now();
468 await this.db.batch([
469 ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)),
470 ...order.map((id, position) =>
471 this.db
472 .prepare(
473 `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?)
474 ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`,
475 )
476 .bind(userId, id, position, at),
477 ),
478 ]);
479 }
480
481 /** The person behind a call about their own pins, or why there is none. */
482 private person(viewer: Viewer): Result<User> {
483 if (!viewer) return fail("unauthenticated", "Sign in to pin projects.");
484 if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token.");
485 return ok(viewer);
486 }
487
488 /** A project the person can see, for pinning. */
489 private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> {
490 const row = await this.row(workspace, slug);
491 if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project.");
492 return ok(row);
493 }
494
495 async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> {
496 const who = this.person(a.viewer);
497 if (!who.ok) return { pinned: [], recent: [] };
498 const workspace = a.workspace.toLowerCase();
499 const [pinned, visited] = await Promise.all([
500 this.pinRows(who.value.id, workspace),
501 this.db
502 .prepare(
503 `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id
504 WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`,
505 )
506 .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT)
507 .all<Row>(),
508 ]);
509 const shown = (row: Row) => this.visible(row, who.value);
510 const pins = pinned.filter(shown);
511 return {
512 pinned: pins.map(toProject),
513 recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject),
514 };
515 }
516
517 async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> {
518 const who = this.person(a.actor);
519 if (!who.ok) return who;
520 const found = await this.pinnable(who.value, a.workspace, a.slug);
521 if (!found.ok) return found;
522 const current = await this.pinRows(who.value.id, found.value.workspace);
523 const order = placePin(
524 current.map((row) => row.id),
525 found.value.id,
526 a.position,
527 );
528 if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`);
529 await this.writePins(who.value.id, order);
530 return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
531 }
532
533 async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> {
534 const who = this.person(a.actor);
535 if (!who.ok) return who;
536 const row = await this.row(a.workspace, a.slug);
537 if (!row) return fail("not_found", "There is no such project.");
538 const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id);
539 await this.writePins(who.value.id, rest, [row.id]);
540 return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject));
541 }
542
543 async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> {
544 const who = this.person(a.actor);
545 if (!who.ok) return who;
546 if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order.");
547 const workspace = a.workspace.toLowerCase();
548 const current = await this.pinRows(who.value.id, workspace);
549 const idOf = new Map(current.map((row) => [row.slug, row.id]));
550 const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug));
551 const order = reorderPins(
552 current.map((row) => row.id),
553 wanted,
554 );
555 if (!order.ok) return fail("invalid", order.message);
556 await this.writePins(who.value.id, order.order);
557 return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
558 }
559
560 async visited(a: { actor: User; projectId: string }): Promise<void> {
561 const who = this.person(a.actor);
562 if (!who.ok || !a.projectId) return;
563 await this.db.batch([
564 this.db
565 .prepare(
566 `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?)
567 ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`,
568 )
569 .bind(who.value.id, a.projectId, now()),
570 // Only their latest few are kept.
571 this.db
572 .prepare(
573 `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN
574 (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`,
575 )
576 .bind(who.value.id, MAX_VISITS),
577 ]);
578 }
579
A project is an app or a library: libraries show their package and how to ship a release, not production580 // ---- App or library --------------------------------------------------
581
582 /**
583 * Reads what decides whether the project is a library: the manifests at
584 * its root at `commit` (the default branch's head when null), a package
585 * its repository publishes, and, the first time, whether Deployments are
586 * on. Files are read again only for a commit they were not read at.
587 * `packages` reads only the packages. Returns the row as it is now.
588 */
589 private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
590 const actor = await this.workspaceActor(row.workspace);
591 if (!actor) return row;
592 const repo = { namespace: row.repo_namespace, name: row.repo_name };
593 const sets: string[] = [];
594 const values: unknown[] = [];
595 const set = (column: string, value: unknown) => {
596 sets.push(`${column} = ?`);
597 values.push(value);
598 };
599 const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
600 const [files, linked, deploying] = await Promise.all([
601 filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
602 this.linkedPackage(row, actor),
603 row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
604 ]);
605 if (files) {
606 const found = files.commit ? detectKind(files.root) : null;
607 set("detected_kind", found?.kind ?? null);
608 set("detected_detail", found?.detail ?? null);
609 set("detected_ecosystem", found?.ecosystem ?? null);
610 set("detected_commit", files.commit);
611 }
612 if (linked !== undefined) set("linked_package", linked);
613 if (deploying != null) set("deployments_on", deploying ? 1 : 0);
614 if (sets.length === 0) return row;
615 await this.db
616 .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
617 .bind(...values, row.id)
618 .run();
619 return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
620 }
621
622 /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
623 private async readRoot(
624 repo: { namespace: string; name: string },
625 actor: User,
626 commit: string | null,
627 rootDir: string,
628 ): Promise<{ commit: string; root: RootFiles } | null> {
629 const client = reposClient(this.env.REPOS);
630 const empty = { commit: "", root: { entries: [], text: {} } };
631 const tree = await client.tree(repo, actor, commit, rootDir);
632 if (!tree.ok) return null;
633 const head = commit ?? tree.value.head?.hash ?? "";
634 if (!head) return empty;
635 const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
636 const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
637 const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
638 const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
639 const [texts, src, pub] = await Promise.all([
640 Promise.all(
641 names.map(async (name) => {
642 const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
643 return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
644 }),
645 ),
646 entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
647 entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
648 ]);
649 const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
650 return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
651 }
652
653 /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
654 private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
655 const listed = await packagesClient(this.env.PACKAGES)
656 .list(row.workspace, actor, { repoId: row.repo_id })
657 .catch(() => null);
658 if (!listed?.ok) return undefined;
659 // An image is how an app ships too; it says nothing about being a library.
660 const pkg = listed.value.find((p) => p.ecosystem !== "container");
661 if (!pkg) return null;
662 return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
663 }
664
665 /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
666 private async deploymentsOn(projectId: string): Promise<boolean | null> {
667 const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
668 method: "POST",
669 headers: { "content-type": "application/json" },
670 body: JSON.stringify({ projectId }),
671 }).catch(() => null);
672 if (!answer?.ok) return null;
673 const value = (await answer.json().catch(() => null)) as unknown;
674 return typeof value === "boolean" ? value : null;
675 }
676
Project dependencies: addresses, preview stacks, Affects, and agents who know677 // ---- Dependencies ------------------------------------------------------
678
679 private async row(workspace: string, slug: string): Promise<Row | null> {
680 return this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look681 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Project dependencies: addresses, preview stacks, Affects, and agents who know682 .bind(workspace.toLowerCase(), slug.toLowerCase())
683 .first<Row>();
684 }
685
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look686 /** A project's dependencies; for a viewer, only the projects whose repositories they can read. */
687 private async links(projectId: string, viewer?: Viewer): Promise<Dependencies> {
Project dependencies: addresses, preview stacks, Affects, and agents who know688 const [out, into] = await Promise.all([
689 this.db
690 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look691 `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
692 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 know693 )
694 .bind(projectId)
695 .all<LinkRow>(),
696 this.db
697 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look698 `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
699 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 know700 )
701 .bind(projectId)
702 .all<LinkRow>(),
703 ]);
704 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 look705 const shown = (r: LinkRow) => viewer === undefined || permission(viewer, repoRef(r)) != null;
706 return { dependsOn: out.results.filter(shown).map(link), usedBy: into.results.filter(shown).map(link) };
Project dependencies: addresses, preview stacks, Affects, and agents who know707 }
708
709 /** Whether `from` already reaches `to` through dependencies. */
710 private async reaches(from: string, to: string): Promise<boolean> {
711 const seen = new Set<string>([from]);
712 let frontier = [from];
713 while (frontier.length > 0) {
714 const marks = frontier.map(() => "?").join(", ");
715 const next = await this.db
716 .prepare(`SELECT depends_on_id AS id FROM dependencies WHERE project_id IN (${marks})`)
717 .bind(...frontier)
718 .all<{ id: string }>();
719 frontier = [];
720 for (const { id } of next.results) {
721 if (id === to) return true;
722 if (!seen.has(id)) {
723 seen.add(id);
724 frontier.push(id);
725 }
726 }
727 }
728 return false;
729 }
730
731 async dependencies(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Dependencies>> {
732 const row = await this.row(a.workspace, a.slug);
733 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 look734 return ok(await this.links(row.id, a.viewer));
Project dependencies: addresses, preview stacks, Affects, and agents who know735 }
736
737 /** Records `row` using `target`, after the checks every way of declaring one shares. */
738 private async declare(row: Row, target: Row, alias: string | null, source: "ui" | "file", by: string): Promise<Result<true>> {
739 if (target.id === row.id) return fail("invalid", "A project cannot depend on itself.");
740 if (alias != null && !ALIAS.test(alias)) {
741 return fail("invalid", "The variable's name is capital letters, digits and underscores, such as API_URL.");
742 }
743 if (await this.reaches(target.id, row.id)) {
744 return fail("conflict", `${target.slug} already depends on ${row.slug}, directly or through others; that would be a cycle.`);
745 }
746 const count = await this.db
747 .prepare("SELECT COUNT(*) AS n FROM dependencies WHERE project_id = ?")
748 .bind(row.id)
749 .first<{ n: number }>();
750 if ((count?.n ?? 0) >= MAX_DEPENDENCIES) return fail("invalid", `A project can depend on at most ${MAX_DEPENDENCIES} others.`);
751 await this.db
752 .prepare(
753 `INSERT INTO dependencies (project_id, depends_on_id, alias, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)
754 ON CONFLICT (project_id, depends_on_id) DO UPDATE SET alias = excluded.alias, source = excluded.source`,
755 )
756 .bind(row.id, target.id, alias, source, by, now())
757 .run();
758 return ok(true);
759 }
760
761 async addDependency(a: { actor: User; workspace: string; slug: string; on: string; as: string | null }): Promise<Result<Dependencies>> {
762 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
763 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 look764 const allowed = this.changeable(row, a.actor, NEEDS.addDependency);
765 if (!allowed.ok) return allowed;
766 // A project the actor cannot read is not there for them to depend on.
767 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 know768 const existing = await this.db
769 .prepare("SELECT source FROM dependencies WHERE project_id = ? AND depends_on_id = ?")
770 .bind(row.id, target.id)
771 .first<{ source: string }>();
772 if (existing?.source === "file") return fail("conflict", "This dependency is declared in .g1t/project.yml; change it there.");
773 const alias = a.as?.trim() ? a.as.trim().toUpperCase() : null;
774 const done = await this.declare(row, target, alias, "ui", a.actor.username);
775 if (!done.ok) return done;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look776 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know777 }
778
779 async removeDependency(a: { actor: User; workspace: string; slug: string; on: string }): Promise<Result<Dependencies>> {
780 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 look781 if (!row) return fail("not_found", "There is no such project.");
782 const allowed = this.changeable(row, a.actor, NEEDS.removeDependency);
783 if (!allowed.ok) return allowed;
784 if (!target) return fail("not_found", "There is no such dependency.");
Project dependencies: addresses, preview stacks, Affects, and agents who know785 const removed = await this.db
786 .prepare("DELETE FROM dependencies WHERE project_id = ? AND depends_on_id = ? AND source = 'ui' RETURNING project_id")
787 .bind(row.id, target.id)
788 .first();
789 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 look790 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know791 }
792
793 async graph(a: { projectId: string }): Promise<ProjectGraph> {
794 const [out, into] = await Promise.all([
795 this.db
796 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look797 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
798 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL`,
Project dependencies: addresses, preview stacks, Affects, and agents who know799 )
800 .bind(a.projectId)
801 .all<NodeRow>(),
802 this.db
803 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look804 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.project_id
805 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL`,
Project dependencies: addresses, preview stacks, Affects, and agents who know806 )
807 .bind(a.projectId)
808 .all<NodeRow>(),
809 ]);
810 const node = (r: NodeRow) => ({ id: r.id, slug: r.slug, workspace: r.workspace, as: r.alias });
811 return { dependsOn: out.results.map(node), usedBy: into.results.map(node) };
812 }
813
814 /** For the runner: each project on a repository, with what it uses and what uses it. */
815 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 look816 const rows = await this.db
817 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
818 .bind(a.repoId)
819 .all<Row>();
Project dependencies: addresses, preview stacks, Affects, and agents who know820 return Promise.all(rows.results.map(async (row) => ({ slug: row.slug, name: row.name, dependencies: await this.links(row.id) })));
821 }
822
823 /**
824 * A project's `.g1t/project.yml` at a commit of its default branch:
825 *
826 * dependsOn:
827 * - project: api
828 * as: API_URL
829 *
830 * Its dependencies replace the ones the file declared before. Ones that
831 * cannot be kept (an unknown project, a cycle) are left out.
832 */
833 private async syncFile(row: Row, commit: string): Promise<void> {
834 const actor = await this.workspaceActor(row.workspace);
835 if (!actor) return;
836 const path = row.root_dir ? `${row.root_dir}/.g1t/project.yml` : ".g1t/project.yml";
837 const blob = await reposClient(this.env.REPOS).blob({ namespace: row.repo_namespace, name: row.repo_name }, actor, commit, path);
838 if (!blob.ok || blob.value.text == null) {
839 // No file (any more): what it declared goes with it.
840 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
841 return;
842 }
843 let declared: unknown[] = [];
844 try {
845 const parsed = parseYaml(blob.value.text) as { dependsOn?: unknown } | null;
846 if (Array.isArray(parsed?.dependsOn)) declared = parsed.dependsOn;
847 } catch (error) {
848 console.error("could not read", path, "of", row.slug, error);
849 return;
850 }
851 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
852 for (const entry of declared.slice(0, MAX_DEPENDENCIES)) {
853 const item = entry as { project?: unknown; as?: unknown } | string;
854 const on = typeof item === "string" ? item : typeof item?.project === "string" ? item.project : null;
855 if (!on) continue;
856 const target = await this.row(row.workspace, on);
857 if (!target) continue;
858 const alias = typeof item === "object" && typeof item.as === "string" ? item.as.trim().toUpperCase() : null;
859 await this.declare(row, target, alias, "file", "g1t");
860 }
861 }
862
Projects: when each was last pushed, and how active it is lately863 /** Something happened to a repository's code or work: its projects are that much more active. */
864 private async active(repoId: string, at: string, pushed: boolean): Promise<void> {
865 const rows = await this.db
866 .prepare("SELECT id, activity, active_at FROM projects WHERE repo_id = ?")
867 .bind(repoId)
868 .all<{ id: string; activity: number | null; active_at: string | null }>();
869 if (rows.results.length === 0) return;
870 await this.db.batch(
871 rows.results.map((row) => {
872 const next = raised(row.activity ?? 0, row.active_at, at);
873 return pushed
874 ? this.db
875 .prepare("UPDATE projects SET activity = ?, active_at = ?, pushed_at = MAX(COALESCE(pushed_at, ''), ?) WHERE id = ?")
876 .bind(next.score, next.at, at, row.id)
877 : this.db.prepare("UPDATE projects SET activity = ?, active_at = ? WHERE id = ?").bind(next.score, next.at, row.id);
878 }),
879 );
880 }
881
Projects: what a workspace builds and runs, first on every page882 async onEvent(event: G1tEvent): Promise<void> {
Projects: when each was last pushed, and how active it is lately883 if (ACTIVE.has(event.type)) {
884 const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
885 if (repoId) await this.active(repoId, event.time, event.type === "git.push");
886 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains887 if (event.type === "workspace.renamed") {
888 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
889 const statements = renameStatements(staleSlugs(event.data, current), current);
890 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
891 return;
892 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look893 const move = repoMove(event);
894 if (move) {
895 const current = await currentMovedPath(this.env.REPOS, move);
896 const stale = staleMovedPaths(move, current);
897 if (stale.length === 0) return;
898 const statements = moveStatements(stale, current, move.repoId);
899 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
900 // A project whose slug the destination already had moves under a free one.
901 const query = strandedQuery(stale, current, move.repoId);
902 if (!query) return;
903 const workspace = current.split("/")[0]!;
904 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
905 for (const row of stranded.results) {
906 const slug = await this.freeSlug(workspace, row.slug);
907 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
908 await this.db
909 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
910 .bind(workspace, slug, workspace, now(), row.id)
911 .run();
912 }
913 return;
914 }
915 if (event.type === "repo.deleted") {
916 // Hidden, not gone: a restore brings its projects back as they were.
917 await this.db
918 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
919 .bind(now(), event.data.repoId)
920 .run();
921 return;
922 }
923 if (event.type === "repo.restored") {
924 await this.db
925 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
926 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
927 .run();
928 return;
929 }
930 if (event.type === "repo.purged") {
931 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
932 await this.db.batch([
933 this.db
934 .prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`)
935 .bind(event.data.repoId),
Projects: pinned and recent projects, per person per workspace936 // Pins and visits of what is gone go with it.
937 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
938 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look939 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
940 ]);
941 return;
942 }
943 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
944 // Who may see its projects follows who may see the repository.
945 await this.db
946 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
947 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
948 .run();
Fast pages, required checks on the branch, self-hosted runners, honest incidents949 if (event.type === "repo.updated") {
950 // Projects without a description of their own show the repository's.
951 // Asked of repos, so changes delivered out of order end the same.
952 const repo = await this.repoById(event.data.repoId);
953 if (repo) {
954 await this.db
955 .prepare("UPDATE projects SET repo_description = ? WHERE repo_id = ?")
956 .bind(repo.description ?? null, event.data.repoId)
957 .run();
958 }
959 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look960 return;
961 }
962 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
963 // Asked of repos, so changes delivered out of order end the same.
964 const repo = await this.repoById(event.data.repoId);
965 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
966 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
967 return;
968 }
969 if (event.type === "repo.default_branch_changed") {
970 // Asked of repos, so changes delivered out of order end the same.
971 const repo = await this.repoById(event.data.repoId);
972 await this.db
973 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
974 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
975 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production976 // Another branch has other files: they are read again from its head.
977 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
978 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
979 return;
980 }
981 if (
982 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
983 event.data.repoId
984 ) {
985 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
986 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 look987 return;
988 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member989 if (event.type === "workspace.deleting") {
990 // Hidden, not gone: every project it shows, including any building
991 // from a repository it transferred away, until staff restore it or it
992 // is purged. Those already hidden stay as they were.
993 if (isProtectedWorkspace(event.data.slug)) return;
994 const at = now();
995 await this.db
996 .prepare(
997 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
998 )
999 .bind(at, event.data.slug.toLowerCase())
1000 .run();
1001 return;
1002 }
1003 if (event.type === "workspace.restored") {
1004 await this.db
1005 .prepare(
1006 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
1007 )
1008 .bind(event.data.slug.toLowerCase())
1009 .run();
1010 return;
1011 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1012 if (event.type === "workspace.deleted") {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1013 // Its own repositories' projects go with them (`repo.purged`); any
1014 // building from a repository it transferred away goes now. It is not
1015 // backfilled again.
1016 const slug = event.data.slug.toLowerCase();
1017 const ids = "SELECT id FROM projects WHERE workspace = ?1";
1018 await this.db.batch([
1019 this.db.prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`).bind(slug),
Projects: pinned and recent projects, per person per workspace1020 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
1021 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1022 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
1023 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
1024 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1025 return;
1026 }
Project dependencies: addresses, preview stacks, Affects, and agents who know1027 if (event.type === "git.push" && event.data.defaultBranch) {
1028 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 production1029 for (const row of rows.results) {
1030 await this.syncFile(row, event.data.after);
1031 await this.detect(row, event.data.after);
1032 }
Project dependencies: addresses, preview stacks, Affects, and agents who know1033 return;
1034 }
Projects: what a workspace builds and runs, first on every page1035 if (event.type !== "repo.created") return;
1036 const actor = await this.workspaceActor(event.data.namespace);
1037 if (!actor) return;
1038 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
1039 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
1040 }
1041}
1042
Fast pages, required checks on the branch, self-hosted runners, honest incidents1043/** One RPC method's answer. */
1044async function answer(service: Projects, method: string, args: any): Promise<Response> {
1045 switch (method) {
1046 case "list":
1047 return Response.json(await service.list(args));
1048 case "get":
1049 return Response.json(await service.get(args));
1050 case "by_repo":
1051 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 member1052 case "count":
1053 return Response.json(await service.count(args));
Fast pages, required checks on the branch, self-hosted runners, honest incidents1054 case "create":
1055 return Response.json(await service.create(args));
1056 case "update":
1057 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 production1058 case "deployments_changed":
1059 await service.deploymentsChanged(args);
1060 return Response.json(null);
Fast pages, required checks on the branch, self-hosted runners, honest incidents1061 case "dependencies":
1062 return Response.json(await service.dependencies(args));
1063 case "add_dependency":
1064 return Response.json(await service.addDependency(args));
1065 case "remove_dependency":
1066 return Response.json(await service.removeDependency(args));
1067 case "graph":
1068 return Response.json(await service.graph(args));
Projects: pinned and recent projects, per person per workspace1069 case "shortcuts":
1070 return Response.json(await service.shortcuts(args));
1071 case "pin":
1072 return Response.json(await service.pin(args));
1073 case "unpin":
1074 return Response.json(await service.unpin(args));
1075 case "reorder_pins":
1076 return Response.json(await service.reorderPins(args));
1077 case "visited":
1078 await service.visited(args);
1079 return Response.json(null);
Fast pages, required checks on the branch, self-hosted runners, honest incidents1080 case "context_for_repo":
1081 return Response.json(await service.contextForRepo(args));
1082 default:
1083 return new Response("Unknown method\n", { status: 404 });
1084 }
1085}
1086
Projects: what a workspace builds and runs, first on every page1087export default {
A project is an app or a library: libraries show their package and how to ship a release, not production1088 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Projects: what a workspace builds and runs, first on every page1089 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
1090 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents1091 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1092 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 production1093 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 page1094 const args = (await request.json().catch(() => ({}))) as any;
Fast pages, required checks on the branch, self-hosted runners, honest incidents1095 return opened.finish(await answer(service, match[1], args));
Projects: what a workspace builds and runs, first on every page1096 },
1097
1098 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1099 const service = new Projects(env);
1100 for (const message of batch.messages) {
1101 try {
1102 await service.onEvent(message.body);
1103 message.ack();
1104 } catch (error) {
1105 console.error("projects could not handle", message.body.type, error);
1106 message.retry();
1107 }
1108 }
1109 },
1110} satisfies ExportedHandler<Env, G1tEvent>;