File
Blob: src/worker/api/private/projects/shared.ts
| 1 | import { GetProjectRunsQuery, ProjectId, type RunId, type RunSummary, UserId } from "@/contracts"; |
| 2 | import { expectTrusted, RunMetaState } from "@/worker/contracts"; |
| 3 | import type { AppContext } from "@/worker/hono"; |
| 4 | import type { RunPaginationCursor } from "@/worker/db/d1/repositories"; |
| 5 | import { HttpError } from "@/worker/http"; |
| 6 | import { serializeRunSummary } from "@/worker/presentation/serializers"; |
| 7 | import { createLogger, extractTimestampFromDurableEntityId } from "@/worker/services"; |
| 8 | import { decodeBase64Url, encodeBase64Url } from "@/worker/services/crypto"; |
| 9 | import { toCodecIssueDetails } from "@/lib/codec-errors"; |
| 10 | export { |
| 11 | requireWebhookProviderParam, |
| 12 | resolveWebhookConfigForUpsert, |
| 13 | normalizeWebhookConfigIfPresent, |
| 14 | } from "@/worker/api/webhook-shared"; |
| 15 | |
| 16 | export const logger = createLogger("worker.projects"); |
| 17 | export const UNIQUE_PROJECT_SLUG_CONSTRAINT = |
| 18 | "UNIQUE constraint failed: project_index.owner_slug, project_index.project_slug"; |
| 19 | export const DEFAULT_PROJECT_RUN_LIMIT = 20; |
| 20 | export const MAX_PROJECT_RUN_LIMIT = 100; |
| 21 | const textDecoder = new TextDecoder(); |
| 22 | const textEncoder = new TextEncoder(); |
| 23 | |
| 24 | export const isConstraintError = (error: unknown, messageFragment: string): boolean => |
| 25 | error instanceof Error && error.message.includes(messageFragment); |
| 26 | |
| 27 | export const getProjectStub = (env: AppContext["env"], projectId: ProjectId) => env.PROJECT_DO.getByName(projectId); |
| 28 | export const getRunStub = (env: AppContext["env"], runId: RunId) => env.RUN_DO.getByName(runId); |
| 29 | |
| 30 | export 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 | |
| 49 | export 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 | |
| 76 | export 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 | |
| 86 | export 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 | |
| 101 | export const toTriggeredByUserId = (userId: string) => expectTrusted(UserId, userId, "UserId"); |