1import type { ConfigError } from "@distilled.cloud/core/errors";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { PrismaApiError } from "../Client.ts";
5import { Credentials } from "../Credentials.ts";
6import type {
7 BuildLogsQuery,
8 BuildLogsRequest,
9 DeploymentLogsQuery,
10 DeploymentLogsRequest,
11} from "../Types.ts";
12
13/**
14 * Hand-written request builders for the Management API's log streams. The
15 * deployment stream is a WebSocket and the build stream is NDJSON-over-HTTP;
16 * neither fits distilled's request/response operations, so these stay in
17 * alchemy.
18 *
19 * They read the token and base URL from the distilled {@link Credentials}
20 * service — the same service every migrated operation authenticates with —
21 * rather than from `PrismaEnvironment`. `Credentials` holds an *effect*, so a
22 * stack layer can carry it without resolving credentials at build time;
23 * `PrismaEnvironment` is a resolved value and therefore cannot appear in
24 * `providers()`' output (see the lazy-auth note on `stackManagementApiLayer`).
25 */
26
27const INVALID_PATH_SEGMENT = "__alchemy_invalid_prisma_path_segment__";
28
29const pathSegment = (value: string): string => {
30 if (
31 value.length === 0 ||
32 value === "." ||
33 value === ".." ||
34 value === INVALID_PATH_SEGMENT ||
35 /[\\/?#%]/.test(value) ||
36 value.includes("\0")
37 ) {
38 return INVALID_PATH_SEGMENT;
39 }
40 try {
41 return encodeURIComponent(value);
42 } catch {
43 return INVALID_PATH_SEGMENT;
44 }
45};
46
47const isValidApiPath = (baseUrl: string, path: string): boolean => {
48 if (
49 (path !== "/v1" && !path.startsWith("/v1/")) ||
50 path.startsWith("//") ||
51 path.includes("\\") ||
52 path.includes("?") ||
53 path.includes("#") ||
54 path.includes(INVALID_PATH_SEGMENT)
55 ) {
56 return false;
57 }
58 try {
59 const base = new URL(baseUrl);
60 const resolved = new URL(path, base);
61 return (
62 resolved.origin === base.origin &&
63 (resolved.pathname === "/v1" || resolved.pathname.startsWith("/v1/"))
64 );
65 } catch {
66 return false;
67 }
68};
69
70const buildUrl = (
71 baseUrl: string,
72 path: string,
73 query?: object,
74): Effect.Effect<string, PrismaApiError> => {
75 if (!isValidApiPath(baseUrl, path)) {
76 return Effect.fail(
77 new PrismaApiError({
78 method: "GET",
79 path,
80 status: 0,
81 message: "Refused an invalid Prisma Management API path parameter",
82 }),
83 );
84 }
85 return Effect.sync(() => {
86 const url = new URL(path, baseUrl);
87 for (const [key, value] of Object.entries(query ?? {})) {
88 if (value === undefined || value === null) continue;
89 if (Array.isArray(value)) {
90 for (const item of value) {
91 url.searchParams.append(key, String(item));
92 }
93 } else {
94 url.searchParams.set(key, String(value));
95 }
96 }
97 return url.toString();
98 });
99};
100
101const buildWebSocketUrl = (baseUrl: string, path: string, query?: object) =>
102 buildUrl(baseUrl, path, query).pipe(
103 Effect.map((value) => {
104 const url = new URL(value);
105 if (url.protocol === "https:") url.protocol = "wss:";
106 if (url.protocol === "http:") url.protocol = "ws:";
107 return url.toString();
108 }),
109 );
110
111const logsQuery = (
112 query: DeploymentLogsQuery | undefined,
113): Record<string, unknown> | undefined => {
114 if (!query) return undefined;
115 const { fromStart, ...rest } = query;
116 return {
117 ...rest,
118 from_start: fromStart === undefined ? undefined : String(fromStart),
119 };
120};
121
122export const getDeploymentLogsRequest = (
123 deploymentId: string,
124 query?: DeploymentLogsQuery,
125): Effect.Effect<
126 DeploymentLogsRequest,
127 PrismaApiError | ConfigError,
128 Credentials
129> =>
130 Effect.gen(function* () {
131 const credentials = yield* Credentials;
132 const { apiToken, apiBaseUrl } = yield* credentials;
133 const url = yield* buildWebSocketUrl(
134 apiBaseUrl,
135 `/v1/deployments/${pathSegment(deploymentId)}/logs`,
136 logsQuery(query),
137 );
138 return {
139 url,
140 headers: {
141 Authorization: Redacted.make(`Bearer ${Redacted.value(apiToken)}`),
142 },
143 };
144 });
145
146export const getBuildLogsRequest = (
147 buildId: string,
148 query?: BuildLogsQuery,
149): Effect.Effect<BuildLogsRequest, PrismaApiError | ConfigError, Credentials> =>
150 Effect.gen(function* () {
151 const credentials = yield* Credentials;
152 const { apiToken, apiBaseUrl } = yield* credentials;
153 const url = yield* buildUrl(
154 apiBaseUrl,
155 `/v1/builds/${pathSegment(buildId)}/logs`,
156 query,
157 );
158 return {
159 url,
160 headers: {
161 Authorization: Redacted.make(`Bearer ${Redacted.value(apiToken)}`),
162 Accept: "application/x-ndjson",
163 },
164 };
165 });