Skip to content
File

Blob: src/worker/api/private/projects/shared.ts

typescript102 lines
1import { GetProjectRunsQuery, ProjectId, type RunId, type RunSummary, UserId } from "@/contracts";
2import { expectTrusted, RunMetaState } from "@/worker/contracts";
3import type { AppContext } from "@/worker/hono";
4import type { RunPaginationCursor } from "@/worker/db/d1/repositories";
5import { HttpError } from "@/worker/http";
6import { serializeRunSummary } from "@/worker/presentation/serializers";
7import { createLogger, extractTimestampFromDurableEntityId } from "@/worker/services";
8import { decodeBase64Url, encodeBase64Url } from "@/worker/services/crypto";
9import { toCodecIssueDetails } from "@/lib/codec-errors";
10export {
11 requireWebhookProviderParam,
12 resolveWebhookConfigForUpsert,
13 normalizeWebhookConfigIfPresent,
14} from "@/worker/api/webhook-shared";
15 
16export const logger = createLogger("worker.projects");
17export const UNIQUE_PROJECT_SLUG_CONSTRAINT =
18 "UNIQUE constraint failed: project_index.owner_slug, project_index.project_slug";
19export const DEFAULT_PROJECT_RUN_LIMIT = 20;
20export const MAX_PROJECT_RUN_LIMIT = 100;
21const textDecoder = new TextDecoder();
22const textEncoder = new TextEncoder();
23 
24export const isConstraintError = (error: unknown, messageFragment: string): boolean =>
25 error instanceof Error && error.message.includes(messageFragment);
26 
27export const getProjectStub = (env: AppContext["env"], projectId: ProjectId) => env.PROJECT_DO.getByName(projectId);
28export const getRunStub = (env: AppContext["env"], runId: RunId) => env.RUN_DO.getByName(runId);
29 
30export const parseProjectRunsQuery = (c: AppContext): { limit: number; cursor?: string } => {
31 const limitValue = c.req.query("limit");
32 
33 let payload: { limit?: number; cursor?: string };
34 try {
35 payload = GetProjectRunsQuery.assertDecode({
36 limit: limitValue === undefined ? undefined : Number(limitValue),
37 cursor: c.req.query("cursor") ?? undefined,
38 });
39 } catch (error) {
40 throw new HttpError(400, "invalid_request", "Query string failed validation.", toCodecIssueDetails(error));
41 }
42 
43 return {
44 limit: Math.min(payload.limit ?? DEFAULT_PROJECT_RUN_LIMIT, MAX_PROJECT_RUN_LIMIT),
45 cursor: payload.cursor,
46 };
47};
48 
49export const decodeRunCursor = (cursor: string): RunPaginationCursor => {
50 let parsed: unknown;
51 
52 try {
53 parsed = JSON.parse(textDecoder.decode(decodeBase64Url(cursor))) as unknown;
54 } catch (error) {
55 throw new HttpError(400, "invalid_cursor", "Cursor is invalid.", error);
56 }
57 
58 if (
59 !parsed ||
60 typeof parsed !== "object" ||
61 Array.isArray(parsed) ||
62 !("queuedAt" in parsed) ||
63 !("runId" in parsed) ||
64 typeof parsed.queuedAt !== "number" ||
65 typeof parsed.runId !== "string"
66 ) {
67 throw new HttpError(400, "invalid_cursor", "Cursor is invalid.");
68 }
69 
70 return {
71 queuedAt: parsed.queuedAt,
72 runId: parsed.runId,
73 };
74};
75 
76export const encodeRunCursor = (cursor: RunPaginationCursor): string =>
77 encodeBase64Url(
78 textEncoder.encode(
79 JSON.stringify({
80 queuedAt: cursor.queuedAt,
81 runId: cursor.runId,
82 }),
83 ),
84 );
85 
86export const mergeRunSummaryWithMeta = (runId: string, meta: RunMetaState, base: RunSummary | null) =>
87 serializeRunSummary({
88 id: runId,
89 projectId: meta.projectId,
90 triggeredByUserId: base?.triggeredByUserId ?? null,
91 triggerType: meta.triggerType,
92 branch: meta.branch,
93 commitSha: meta.commitSha,
94 status: meta.status,
95 queuedAt: base === null ? (extractTimestampFromDurableEntityId(runId) ?? Date.now()) : Date.parse(base.queuedAt),
96 startedAt: meta.startedAt,
97 finishedAt: meta.finishedAt,
98 exitCode: meta.exitCode,
99 });
100 
101export const toTriggeredByUserId = (userId: string) => expectTrusted(UserId, userId, "UserId");