Skip to content
989 linesCodeBlameRaw
1/**
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
15
16import { NEEDS, repoRef } from "./access";
17import { effectiveDescription, ownDescription } from "./description";
18import {
19 type KindFacts,
20 type RootFiles,
21 MANIFESTS,
22 detectKind,
23 detectedKind,
24 goFilesToRead,
25 kindSetting,
26 neverRuns,
27 nextSetting,
28 resolveKind,
29 runsSetting,
30} from "./kind";
31import { cleanLinks, cleanUrl, projectLinks } from "./links";
32import { ACTIVE, decayed, raised } from "./activity";
33import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
34import { renameStatements } from "./rename";
35import { moveStatements, slugOf, strandedQuery } from "./transfer";
36
37import {
38 can,
39 currentMovedPath,
40 currentWorkspaceSlug,
41 fail,
42 identityClient,
43 staleSlugs,
44 needs,
45 isProtectedWorkspace,
46 newId,
47 ok,
48 openD1,
49 packagesClient,
50 permission,
51 repoMove,
52 reposClient,
53 staleMovedPaths,
54 type Ecosystem,
55 type G1tEvent,
56 type NewProject,
57 type Project,
58 type ProjectChanges,
59 type ProjectKind,
60 type ProjectEcosystem,
61 type ProjectShortcuts,
62 type Repo,
63 type Result,
64 type ServiceBinding,
65 type User,
66 type Viewer,
67} from "@g1t/contracts";
68
69type Env = {
70 DB: D1Database;
71 REPOS: ServiceBinding;
72 IDENTITY: ServiceBinding;
73 PACKAGES: ServiceBinding;
74 DEPLOYMENTS: ServiceBinding;
75};
76
77type Row = {
78 id: string;
79 workspace: string;
80 slug: string;
81 name: string;
82 /** The project's own description; null while it follows its repository's. */
83 description: string | null;
84 /** Its repository's description, kept from repos. */
85 repo_description?: string | null;
86 source_kind: string;
87 repo_id: string;
88 repo_namespace: string;
89 repo_name: string;
90 repo_private: number;
91 default_branch: string;
92 root_dir: string;
93 is_primary: number;
94 created_by: string;
95 created_at: string;
96 updated_at: string;
97 /** Set while its repository is deleted; the project is hidden until it is restored. */
98 repo_deleted_at: string | null;
99 /** When its repository was archived; null while it is not. */
100 repo_archived_at?: string | null;
101 /** What a person set it to be and where it runs; null left to detection (migrations/0010_kind_and_links.sql). */
102 kind_setting?: string | null;
103 runs_setting?: string | null;
104 production_url?: string | null;
105 /** Its own homepage; null follows `repo_website`. */
106 homepage?: string | null;
107 repo_website?: string | null;
108 docs_url?: string | null;
109 /** Its other links, as JSON (src/links.ts). */
110 links?: string | null;
111 detected_kind?: string | null;
112 detected_detail?: string | null;
113 detected_ecosystem?: string | null;
114 /** The default branch's commit its files were read at; null until they have been. */
115 detected_commit?: string | null;
116 linked_package?: string | null;
117 deployments_on?: number | null;
118 /** When its repository was last pushed to (migrations/0009_activity.sql). */
119 pushed_at?: string | null;
120 /** How active it is lately, as of `active_at` (src/activity.ts). */
121 activity?: number | null;
122 active_at?: string | null;
123};
124
125/** How a package its repository publishes is named in why a project is a library. */
126const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
127 container: "container image",
128 npm: "npm package",
129 composer: "Composer package",
130 cargo: "crate",
131 go: "Go module",
132 maven: "Maven package",
133 nuget: "NuGet package",
134 rubygems: "gem",
135};
136
137/** How many projects one listing reads the files of, in the background, before it has. */
138const DETECT_PER_LIST = 10;
139
140const now = () => new Date().toISOString();
141
142function toProject(row: Row): Project {
143 const { description, inherited } = effectiveDescription(row);
144 const setting = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
145 const facts: Omit<KindFacts, "setting"> = {
146 deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
147 linkedPackage: row.linked_package ?? null,
148 detected: row.detected_commit == null ? null : { kind: kindSetting(row.detected_kind), detail: row.detected_detail ?? "" },
149 };
150 const { kind, reason, runs } = resolveKind({ setting, ...facts });
151 return {
152 id: row.id,
153 workspace: row.workspace,
154 slug: row.slug,
155 name: row.name,
156 description,
157 descriptionInherited: inherited,
158 source: {
159 kind: "hosted",
160 repoId: row.repo_id,
161 repo: { namespace: row.repo_namespace, name: row.repo_name },
162 rootDir: row.root_dir,
163 defaultBranch: row.default_branch,
164 },
165 private: !!row.repo_private,
166 archived: !!row.repo_archived_at,
167 primary: !!row.is_primary,
168 setting,
169 kind,
170 kindReason: reason,
171 runs,
172 productionUrl: row.production_url ?? null,
173 detected: detectedKind(facts),
174 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
175 links: projectLinks(row),
176 createdBy: row.created_by,
177 createdAt: row.created_at,
178 updatedAt: row.updated_at,
179 pushedAt: row.pushed_at ?? null,
180 activity: decayed(row.activity ?? 0, row.active_at ?? null, now()),
181 };
182}
183
184function isMember(viewer: Viewer, workspace: string): boolean {
185 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
186}
187
188class Projects {
189 /** `defer` runs work after the answer is sent: the request's waitUntil. */
190 constructor(
191 private readonly env: Env,
192 private readonly defer: (work: Promise<unknown>) => void = () => {},
193 ) {}
194
195 private get db() {
196 return this.env.DB;
197 }
198
199 private async workspaceActor(slug: string): Promise<User | null> {
200 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
201 if (!workspace) return null;
202 return {
203 id: workspace.id,
204 username: workspace.slug,
205 kind: "workspace",
206 verified: true,
207 workspaces: [{ slug: workspace.slug, role: "member" }],
208 };
209 }
210
211 /** A free slug for `wanted` in the workspace. */
212 private async freeSlug(workspace: string, wanted: string): Promise<string> {
213 const base = slugOf(wanted) || "project";
214 for (let n = 1; n < 100; n++) {
215 const slug = n === 1 ? base : `${base}-${n}`;
216 const taken = await this.db
217 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
218 .bind(workspace, slug)
219 .first();
220 if (!taken) return slug;
221 }
222 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
223 }
224
225 /** Gives a repository its own project, unless it has one. */
226 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
227 if (repo.forkOf) return;
228 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
229 if (existing) {
230 await this.db
231 .prepare(
232 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ?, repo_description = ?, repo_website = ? WHERE repo_id = ?",
233 )
234 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.description ?? null, repo.website ?? null, repo.id)
235 .run();
236 return;
237 }
238 const at = now();
239 await this.db
240 .prepare(
241 // No description of its own: it shows the repository's, as that changes.
242 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_website, repo_id, repo_namespace, repo_name, repo_private,
243 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
244 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
245 ON CONFLICT (workspace, slug) DO NOTHING`,
246 )
247 .bind(
248 newId("prj"),
249 repo.namespace.toLowerCase(),
250 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
251 repo.name,
252 repo.description ?? null,
253 repo.website ?? null,
254 repo.id,
255 repo.namespace,
256 repo.name,
257 repo.isPrivate ? 1 : 0,
258 repo.defaultBranch,
259 createdBy,
260 at,
261 at,
262 repo.archivedAt ?? null,
263 )
264 .run();
265 }
266
267 /** Projects for a workspace's repositories made before projects existed. Once. */
268 private async backfill(workspace: string): Promise<void> {
269 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
270 if (done) return;
271 const actor = await this.workspaceActor(workspace);
272 if (!actor) return;
273 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
274 for (const repo of list) {
275 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
276 }
277 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
278 }
279
280 /**
281 * Whether the viewer can read the project's repository. The workspace's
282 * own token sees all its projects, also one left building from a
283 * repository that moved to another workspace.
284 */
285 private visible(row: Row, viewer: Viewer): boolean {
286 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
287 return permission(viewer, repoRef(row)) != null;
288 }
289
290 /**
291 * Whether the actor may change the project: not found when they cannot
292 * read its repository, refused when their role there is too low.
293 */
294 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
295 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
296 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
297 return ok(true);
298 }
299
300 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
301 const workspace = a.workspace.toLowerCase();
302 await this.backfill(workspace);
303 const rows = await this.db
304 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
305 .bind(workspace)
306 .all<Row>();
307 // Projects whose files have not been read yet are read after this answer,
308 // a few at a time; until then they show as apps, as before.
309 const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
310 if (unread.length) this.defer(Promise.all(unread.map((row) => this.detect(row).catch((error) => console.warn("detect", row.slug, error)))));
311 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
312 }
313
314 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
315 const workspace = a.workspace.toLowerCase();
316 await this.backfill(workspace);
317 const row = await this.db
318 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
319 .bind(workspace, a.slug.toLowerCase())
320 .first<Row>();
321 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
322 if (row.detected_commit == null) {
323 // Once per project: what its files say, read now so its first page is right.
324 const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
325 if (read) return ok(toProject(read));
326 }
327 return ok(toProject(row));
328 }
329
330 /** How many projects a workspace shows: what deleting it would take with it. */
331 async count(a: { workspace: string }): Promise<number> {
332 const row = await this.db
333 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL")
334 .bind(a.workspace.toLowerCase())
335 .first<{ n: number }>();
336 return row?.n ?? 0;
337 }
338
339 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
340 private async repoById(repoId: string): Promise<Repo | null> {
341 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
342 method: "POST",
343 headers: { "content-type": "application/json" },
344 body: JSON.stringify({ id: repoId }),
345 });
346 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
347 if (!repoPath) return null;
348 const actor = await this.workspaceActor(repoPath.namespace);
349 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
350 return repo?.ok ? repo.value : null;
351 }
352
353 async byRepo(a: { repoId: string }): Promise<Project[]> {
354 const rows = await this.db
355 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
356 .bind(a.repoId)
357 .all<Row>();
358 // A deleted repository's projects are hidden until it is restored.
359 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
360 // A repository from before projects: give it its own now.
361 const repo = await this.repoById(a.repoId);
362 if (!repo) return [];
363 await this.ensureFor(repo, "g1t");
364 const again = await this.db
365 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
366 .bind(a.repoId)
367 .all<Row>();
368 return again.results.map(toProject);
369 }
370
371 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
372 const workspace = a.workspace.toLowerCase();
373 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
374 if (a.input.repo.namespace.toLowerCase() !== workspace) {
375 return fail("invalid", "A project builds from one of its own workspace's repositories.");
376 }
377 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
378 if (!repo.ok) return repo;
379 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
380 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
381 const name = a.input.name.trim();
382 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
383 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
384 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
385 const slug = slugOf(name);
386 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
387 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
388 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
389 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
390 const at = now();
391 const id = newId("prj");
392 await this.db
393 .prepare(
394 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
395 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
396 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
397 )
398 .bind(
399 id,
400 workspace,
401 slug,
402 name,
403 ownDescription(null, a.input.description ?? null),
404 repo.value.description ?? null,
405 repo.value.id,
406 repo.value.namespace,
407 repo.value.name,
408 repo.value.isPrivate ? 1 : 0,
409 repo.value.defaultBranch,
410 rootDir,
411 primary ? 1 : 0,
412 a.actor.username,
413 at,
414 at,
415 repo.value.archivedAt ?? null,
416 )
417 .run();
418 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
419 }
420
421 async update(a: {
422 actor: User;
423 workspace: string;
424 slug: string;
425 changes: ProjectChanges;
426 }): Promise<Result<Project>> {
427 const changes = a.changes ?? {};
428 const workspace = a.workspace.toLowerCase();
429 const row = await this.db
430 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
431 .bind(workspace, a.slug.toLowerCase())
432 .first<Row>();
433 if (!row) return fail("not_found", "There is no such project.");
434 const allowed = this.changeable(row, a.actor, NEEDS.update);
435 if (!allowed.ok) return allowed;
436 const name = changes.name?.trim() || row.name;
437 // Blank or null goes back to following the repository's description.
438 const description = ownDescription(row.description, changes.description);
439 const rootDir = changes.rootDir === undefined ? row.root_dir : String(changes.rootDir).trim().replace(/^\/+|\/+$/g, "");
440 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
441 const known = (value: unknown, allowed: readonly string[], what: string) =>
442 value === undefined || allowed.includes(value as string) ? null : fail("invalid", `${what} is one of ${allowed.join(", ")}.`);
443 const badKind = known(changes.kind, ["auto", "app", "library", "tool", "docs", "other"], "kind");
444 if (badKind) return badKind;
445 const badRuns = known(changes.runs, ["auto", "g1t", "elsewhere"], "runs");
446 if (badRuns) return badRuns;
447 const before = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
448 const setting = nextSetting(before, { kind: changes.kind as ProjectKind | "auto" | undefined, runs: changes.runs });
449 // Something that never runs, while Deployments run, would leave apps up
450 // that no page offers: a person turns them off first.
451 if (neverRuns(setting) && !neverRuns(before) && (row.deployments_on ?? (await this.deploymentsOn(row.id)))) {
452 return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
453 }
454 const url = (value: string | null | undefined, current: string | null | undefined, what: string) =>
455 value === undefined ? { ok: true as const, value: current ?? null } : cleanUrl(value, what);
456 const production = url(changes.productionUrl, row.production_url, "The production address");
457 if (!production.ok) return fail("invalid", production.message);
458 const homepage = url(changes.homepage, row.homepage, "The homepage");
459 if (!homepage.ok) return fail("invalid", homepage.message);
460 const docs = url(changes.docsUrl, row.docs_url, "The docs address");
461 if (!docs.ok) return fail("invalid", docs.message);
462 let linksJson = row.links ?? null;
463 if (changes.links !== undefined) {
464 const links = cleanLinks(changes.links);
465 if (!links.ok) return fail("invalid", links.message);
466 linksJson = links.value.length ? JSON.stringify(links.value) : null;
467 }
468 await this.db
469 .prepare(
470 `UPDATE projects SET name = ?, description = ?, root_dir = ?, kind_setting = ?, runs_setting = ?, production_url = ?,
471 homepage = ?, docs_url = ?, links = ?, updated_at = ? WHERE id = ?`,
472 )
473 .bind(
474 name.slice(0, 100),
475 description,
476 rootDir,
477 setting.kind,
478 setting.runs,
479 production.value,
480 homepage.value,
481 docs.value,
482 linksJson,
483 now(),
484 row.id,
485 )
486 .run();
487 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
488 // Another root has other files: read them again.
489 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
490 return ok(toProject(saved));
491 }
492
493 async publicLinks(a: { repos: { namespace: string; name: string }[] }): Promise<Record<string, string>> {
494 const keys = [...new Set((Array.isArray(a.repos) ? a.repos : []).slice(0, 100).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))];
495 if (keys.length === 0) return {};
496 const rows = await this.db
497 .prepare(
498 `SELECT * FROM projects WHERE is_primary = 1 AND repo_private = 0 AND repo_deleted_at IS NULL
499 AND lower(repo_namespace || '/' || repo_name) IN (${keys.map(() => "?").join(", ")})`,
500 )
501 .bind(...keys)
502 .all<Row>();
503 const out: Record<string, string> = {};
504 for (const row of rows.results) {
505 const project = toProject(row);
506 const link = (project.runs === "elsewhere" ? project.productionUrl : null) ?? project.links.homepage ?? project.links.docs;
507 if (link) out[`${row.repo_namespace}/${row.repo_name}`.toLowerCase()] = link;
508 }
509 return out;
510 }
511
512 async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
513 // Turned on, it runs on g1t: one said to run elsewhere moved here.
514 await this.db
515 .prepare(
516 "UPDATE projects SET deployments_on = ?1, runs_setting = CASE WHEN ?1 = 1 AND runs_setting = 'elsewhere' THEN 'g1t' ELSE runs_setting END WHERE id = ?2",
517 )
518 .bind(a.enabled ? 1 : 0, a.projectId)
519 .run();
520 }
521
522 // ---- Pinned and recent ---------------------------------------------------
523
524 /** The person's pins in the workspace, in order, as rows: hidden projects left out. */
525 private async pinRows(userId: string, workspace: string): Promise<Row[]> {
526 const rows = await this.db
527 .prepare(
528 `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id
529 WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`,
530 )
531 .bind(userId, workspace.toLowerCase())
532 .all<Row>();
533 return rows.results;
534 }
535
536 /** Writes the person's pins in the workspace in this order, by project id. */
537 private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> {
538 const at = now();
539 await this.db.batch([
540 ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)),
541 ...order.map((id, position) =>
542 this.db
543 .prepare(
544 `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?)
545 ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`,
546 )
547 .bind(userId, id, position, at),
548 ),
549 ]);
550 }
551
552 /** The person behind a call about their own pins, or why there is none. */
553 private person(viewer: Viewer): Result<User> {
554 if (!viewer) return fail("unauthenticated", "Sign in to pin projects.");
555 if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token.");
556 return ok(viewer);
557 }
558
559 /** A project the person can see, for pinning. */
560 private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> {
561 const row = await this.row(workspace, slug);
562 if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project.");
563 return ok(row);
564 }
565
566 async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> {
567 const who = this.person(a.viewer);
568 if (!who.ok) return { pinned: [], recent: [] };
569 const workspace = a.workspace.toLowerCase();
570 const [pinned, visited] = await Promise.all([
571 this.pinRows(who.value.id, workspace),
572 this.db
573 .prepare(
574 `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id
575 WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`,
576 )
577 .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT)
578 .all<Row>(),
579 ]);
580 const shown = (row: Row) => this.visible(row, who.value);
581 const pins = pinned.filter(shown);
582 return {
583 pinned: pins.map(toProject),
584 recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject),
585 };
586 }
587
588 async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> {
589 const who = this.person(a.actor);
590 if (!who.ok) return who;
591 const found = await this.pinnable(who.value, a.workspace, a.slug);
592 if (!found.ok) return found;
593 const current = await this.pinRows(who.value.id, found.value.workspace);
594 const order = placePin(
595 current.map((row) => row.id),
596 found.value.id,
597 a.position,
598 );
599 if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`);
600 await this.writePins(who.value.id, order);
601 return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
602 }
603
604 async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> {
605 const who = this.person(a.actor);
606 if (!who.ok) return who;
607 const row = await this.row(a.workspace, a.slug);
608 if (!row) return fail("not_found", "There is no such project.");
609 const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id);
610 await this.writePins(who.value.id, rest, [row.id]);
611 return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject));
612 }
613
614 async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> {
615 const who = this.person(a.actor);
616 if (!who.ok) return who;
617 if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order.");
618 const workspace = a.workspace.toLowerCase();
619 const current = await this.pinRows(who.value.id, workspace);
620 const idOf = new Map(current.map((row) => [row.slug, row.id]));
621 const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug));
622 const order = reorderPins(
623 current.map((row) => row.id),
624 wanted,
625 );
626 if (!order.ok) return fail("invalid", order.message);
627 await this.writePins(who.value.id, order.order);
628 return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
629 }
630
631 async visited(a: { actor: User; projectId: string }): Promise<void> {
632 const who = this.person(a.actor);
633 if (!who.ok || !a.projectId) return;
634 await this.db.batch([
635 this.db
636 .prepare(
637 `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?)
638 ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`,
639 )
640 .bind(who.value.id, a.projectId, now()),
641 // Only their latest few are kept.
642 this.db
643 .prepare(
644 `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN
645 (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`,
646 )
647 .bind(who.value.id, MAX_VISITS),
648 ]);
649 }
650
651 // ---- App or library --------------------------------------------------
652
653 /**
654 * Reads what decides whether the project is a library: the manifests at
655 * its root at `commit` (the default branch's head when null), a package
656 * its repository publishes, and, the first time, whether Deployments are
657 * on. Files are read again only for a commit they were not read at.
658 * `packages` reads only the packages. Returns the row as it is now.
659 */
660 private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
661 const actor = await this.workspaceActor(row.workspace);
662 if (!actor) return row;
663 const repo = { namespace: row.repo_namespace, name: row.repo_name };
664 const sets: string[] = [];
665 const values: unknown[] = [];
666 const set = (column: string, value: unknown) => {
667 sets.push(`${column} = ?`);
668 values.push(value);
669 };
670 const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
671 const [files, linked, deploying] = await Promise.all([
672 filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
673 this.linkedPackage(row, actor),
674 row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
675 ]);
676 if (files) {
677 const found = files.commit ? detectKind(files.root) : null;
678 set("detected_kind", found?.kind ?? null);
679 set("detected_detail", found?.detail ?? null);
680 set("detected_ecosystem", found?.ecosystem ?? null);
681 set("detected_commit", files.commit);
682 }
683 if (linked !== undefined) set("linked_package", linked);
684 if (deploying != null) set("deployments_on", deploying ? 1 : 0);
685 if (sets.length === 0) return row;
686 await this.db
687 .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
688 .bind(...values, row.id)
689 .run();
690 return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
691 }
692
693 /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
694 private async readRoot(
695 repo: { namespace: string; name: string },
696 actor: User,
697 commit: string | null,
698 rootDir: string,
699 ): Promise<{ commit: string; root: RootFiles } | null> {
700 const client = reposClient(this.env.REPOS);
701 const empty = { commit: "", root: { entries: [], text: {} } };
702 const tree = await client.tree(repo, actor, commit, rootDir);
703 if (!tree.ok) return null;
704 const head = commit ?? tree.value.head?.hash ?? "";
705 if (!head) return empty;
706 const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
707 const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
708 const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
709 const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
710 const [texts, src, pub] = await Promise.all([
711 Promise.all(
712 names.map(async (name) => {
713 const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
714 return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
715 }),
716 ),
717 entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
718 entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
719 ]);
720 const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
721 return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
722 }
723
724 /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
725 private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
726 const listed = await packagesClient(this.env.PACKAGES)
727 .list(row.workspace, actor, { repoId: row.repo_id })
728 .catch(() => null);
729 if (!listed?.ok) return undefined;
730 // An image is how an app ships too; it says nothing about being a library.
731 const pkg = listed.value.find((p) => p.ecosystem !== "container");
732 if (!pkg) return null;
733 return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
734 }
735
736 /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
737 private async deploymentsOn(projectId: string): Promise<boolean | null> {
738 const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
739 method: "POST",
740 headers: { "content-type": "application/json" },
741 body: JSON.stringify({ projectId }),
742 }).catch(() => null);
743 if (!answer?.ok) return null;
744 const value = (await answer.json().catch(() => null)) as unknown;
745 return typeof value === "boolean" ? value : null;
746 }
747
748 private async row(workspace: string, slug: string): Promise<Row | null> {
749 return this.db
750 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
751 .bind(workspace.toLowerCase(), slug.toLowerCase())
752 .first<Row>();
753 }
754
755 /** Something happened to a repository's code or work: its projects are that much more active. */
756 private async active(repoId: string, at: string, pushed: boolean): Promise<void> {
757 const rows = await this.db
758 .prepare("SELECT id, activity, active_at FROM projects WHERE repo_id = ?")
759 .bind(repoId)
760 .all<{ id: string; activity: number | null; active_at: string | null }>();
761 if (rows.results.length === 0) return;
762 await this.db.batch(
763 rows.results.map((row) => {
764 const next = raised(row.activity ?? 0, row.active_at, at);
765 return pushed
766 ? this.db
767 .prepare("UPDATE projects SET activity = ?, active_at = ?, pushed_at = MAX(COALESCE(pushed_at, ''), ?) WHERE id = ?")
768 .bind(next.score, next.at, at, row.id)
769 : this.db.prepare("UPDATE projects SET activity = ?, active_at = ? WHERE id = ?").bind(next.score, next.at, row.id);
770 }),
771 );
772 }
773
774 async onEvent(event: G1tEvent): Promise<void> {
775 if (ACTIVE.has(event.type)) {
776 const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
777 if (repoId) await this.active(repoId, event.time, event.type === "git.push");
778 }
779 if (event.type === "workspace.renamed") {
780 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
781 const statements = renameStatements(staleSlugs(event.data, current), current);
782 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
783 return;
784 }
785 const move = repoMove(event);
786 if (move) {
787 const current = await currentMovedPath(this.env.REPOS, move);
788 const stale = staleMovedPaths(move, current);
789 if (stale.length === 0) return;
790 const statements = moveStatements(stale, current, move.repoId);
791 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
792 // A project whose slug the destination already had moves under a free one.
793 const query = strandedQuery(stale, current, move.repoId);
794 if (!query) return;
795 const workspace = current.split("/")[0]!;
796 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
797 for (const row of stranded.results) {
798 const slug = await this.freeSlug(workspace, row.slug);
799 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
800 await this.db
801 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
802 .bind(workspace, slug, workspace, now(), row.id)
803 .run();
804 }
805 return;
806 }
807 if (event.type === "repo.deleted") {
808 // Hidden, not gone: a restore brings its projects back as they were.
809 await this.db
810 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
811 .bind(now(), event.data.repoId)
812 .run();
813 return;
814 }
815 if (event.type === "repo.restored") {
816 await this.db
817 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
818 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
819 .run();
820 return;
821 }
822 if (event.type === "repo.purged") {
823 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
824 await this.db.batch([
825 // Pins and visits of what is gone go with it.
826 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
827 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
828 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
829 ]);
830 return;
831 }
832 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
833 // Who may see its projects follows who may see the repository.
834 await this.db
835 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
836 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
837 .run();
838 if (event.type === "repo.updated") {
839 // Projects without a description of their own show the repository's.
840 // Asked of repos, so changes delivered out of order end the same.
841 const repo = await this.repoById(event.data.repoId);
842 if (repo) {
843 await this.db
844 .prepare("UPDATE projects SET repo_description = ?, repo_website = ? WHERE repo_id = ?")
845 .bind(repo.description ?? null, repo.website ?? null, event.data.repoId)
846 .run();
847 }
848 }
849 return;
850 }
851 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
852 // Asked of repos, so changes delivered out of order end the same.
853 const repo = await this.repoById(event.data.repoId);
854 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
855 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
856 return;
857 }
858 if (event.type === "repo.default_branch_changed") {
859 // Asked of repos, so changes delivered out of order end the same.
860 const repo = await this.repoById(event.data.repoId);
861 await this.db
862 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
863 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
864 .run();
865 // Another branch has other files: they are read again from its head.
866 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
867 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
868 return;
869 }
870 if (
871 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
872 event.data.repoId
873 ) {
874 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
875 for (const row of rows.results) await this.detect(row, null, "packages");
876 return;
877 }
878 if (event.type === "workspace.deleting") {
879 // Hidden, not gone: every project it shows, including any building
880 // from a repository it transferred away, until staff restore it or it
881 // is purged. Those already hidden stay as they were.
882 if (isProtectedWorkspace(event.data.slug)) return;
883 const at = now();
884 await this.db
885 .prepare(
886 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
887 )
888 .bind(at, event.data.slug.toLowerCase())
889 .run();
890 return;
891 }
892 if (event.type === "workspace.restored") {
893 await this.db
894 .prepare(
895 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
896 )
897 .bind(event.data.slug.toLowerCase())
898 .run();
899 return;
900 }
901 if (event.type === "workspace.deleted") {
902 // Its own repositories' projects go with them (`repo.purged`); any
903 // building from a repository it transferred away goes now. It is not
904 // backfilled again.
905 const slug = event.data.slug.toLowerCase();
906 const ids = "SELECT id FROM projects WHERE workspace = ?1";
907 await this.db.batch([
908 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
909 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug),
910 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
911 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
912 ]);
913 return;
914 }
915 if (event.type === "git.push" && event.data.defaultBranch) {
916 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
917 for (const row of rows.results) {
918 await this.detect(row, event.data.after);
919 }
920 return;
921 }
922 if (event.type !== "repo.created") return;
923 const actor = await this.workspaceActor(event.data.namespace);
924 if (!actor) return;
925 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
926 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
927 }
928}
929
930/** One RPC method's answer. */
931async function answer(service: Projects, method: string, args: any): Promise<Response> {
932 switch (method) {
933 case "list":
934 return Response.json(await service.list(args));
935 case "get":
936 return Response.json(await service.get(args));
937 case "by_repo":
938 return Response.json(await service.byRepo(args));
939 case "count":
940 return Response.json(await service.count(args));
941 case "create":
942 return Response.json(await service.create(args));
943 case "update":
944 return Response.json(await service.update(args));
945 case "deployments_changed":
946 await service.deploymentsChanged(args);
947 return Response.json(null);
948 case "shortcuts":
949 return Response.json(await service.shortcuts(args));
950 case "pin":
951 return Response.json(await service.pin(args));
952 case "unpin":
953 return Response.json(await service.unpin(args));
954 case "reorder_pins":
955 return Response.json(await service.reorderPins(args));
956 case "visited":
957 await service.visited(args);
958 return Response.json(null);
959 case "public_links":
960 return Response.json(await service.publicLinks(args));
961 default:
962 return new Response("Unknown method\n", { status: 404 });
963 }
964}
965
966export default {
967 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
968 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
969 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
970 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
971 const opened = openD1(env.DB, request);
972 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
973 const args = (await request.json().catch(() => ({}))) as any;
974 return opened.finish(await answer(service, match[1], args));
975 },
976
977 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
978 const service = new Projects(env);
979 for (const message of batch.messages) {
980 try {
981 await service.onEvent(message.body);
982 message.ack();
983 } catch (error) {
984 console.error("projects could not handle", message.body.type, error);
985 message.retry();
986 }
987 }
988 },
989} satisfies ExportedHandler<Env, G1tEvent>;