Skip to content

g1t/services/projects/src/index.ts

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