1import * as Effect from "effect/Effect";
2import { Unowned } from "../AdoptPolicy.ts";
3import { isResolved } from "../Diff.ts";
4import * as Redacted from "effect/Redacted";
5import * as Provider from "../Provider.ts";
6import {
7 DEV_TIMESTAMP,
8 attrOrString,
9 devId,
10 devProvider,
11} from "./Internal/DevStub.ts";
12import * as ProviderLayer from "../Local/ProviderLayer.ts";
13import { Resource } from "../Resource.ts";
14import {
15 type GetEnvironmentVariablesResponse,
16 deleteEnvironmentVariable,
17 getEnvironmentVariables,
18 getEnvironmentVariable,
19 updateEnvironmentVariable,
20 createEnvironmentVariable,
21} from "@distilled.cloud/prisma/management";
22import { Retry } from "@distilled.cloud/prisma";
23import type { Project } from "./Project.ts";
24import type { Providers } from "./Providers.ts";
25import {
26 concreteIdsChanged,
27 isInputObject,
28 isPrismaDevId,
29 resolveProjectId,
30 unresolvedProjectIdOf,
31} from "./Refs.ts";
32import type { ObservedEnvironmentVariable } from "./Internal/Observed.ts";
33import { PrismaPaginationError } from "./Internal/Pagination.ts";
34
35export interface EnvironmentVariableProps {
36 /**
37 * Project ID or `project.projectId` output that owns this variable.
38 */
39 project: string | Project;
40 /**
41 * Preview branch ID for a branch override. Omit for project-level templates.
42 */
43 branchId?: string;
44 /**
45 * Environment variable class.
46 */
47 class: "production" | "preview";
48 /**
49 * Environment variable key.
50 */
51 key: string;
52 /**
53 * Secret value. Must be wrapped with `Redacted.make(...)` so the engine
54 * redacts it before resource props are persisted to state.
55 */
56 value: Redacted.Redacted<string>;
57}
58
59export interface EnvironmentVariable extends Resource<
60 "Prisma.EnvironmentVariable",
61 EnvironmentVariableProps,
62 {
63 /**
64 * Prisma environment variable ID.
65 */
66 environmentVariableId: string;
67 /**
68 * Project ID that owns the variable.
69 */
70 projectId: string;
71 /**
72 * Branch ID for branch overrides, or null for project templates.
73 */
74 branchId: string | null;
75 /**
76 * Environment variable class.
77 */
78 class: "production" | "preview";
79 /**
80 * Environment variable key.
81 */
82 key: string;
83 /**
84 * Secret value, redacted in state.
85 */
86 value: Redacted.Redacted<string>;
87 /**
88 * Key identifier for the encrypted stored value.
89 */
90 valueKid: string;
91 /**
92 * Whether Prisma manages this variable internally.
93 */
94 isManagedBySystem: boolean;
95 /**
96 * ISO timestamp when the variable was created.
97 */
98 createdAt: string;
99 /**
100 * ISO timestamp when the variable was last updated.
101 */
102 updatedAt: string;
103 },
104 never,
105 Providers
106> {}
107
108/**
109 * A Prisma compute environment variable.
110 *
111 * Values are write-only in Prisma. Alchemy stores them as `Redacted` values
112 * and reapplies the desired value to repair drift.
113 *
114 * ### Creating a Variable
115 * **Example:** Project-level production variable
116 * ```typescript
117 * yield* Prisma.EnvironmentVariable("api-url", {
118 * project: project.projectId,
119 * // No branchId: this is a project-level template.
120 * class: "production",
121 * key: "API_URL",
122 * value: Redacted.make("https://api.example.com"),
123 * });
124 * ```
125 *
126 * **Example:** Preview branch override
127 * ```typescript
128 * yield* Prisma.EnvironmentVariable("preview-api-url", {
129 * project,
130 * branchId: preview.branchId,
131 * // Branch overrides always use the preview class.
132 * class: "preview",
133 * key: "API_URL",
134 * value: Redacted.make("https://preview.example.com"),
135 * });
136 * ```
137 *
138 * @resource
139 * @product Compute
140 */
141export const EnvironmentVariable = Resource<EnvironmentVariable>(
142 "Prisma.EnvironmentVariable",
143);
144
145const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
146const ENV_VALUE_MAX_BYTES = 8 * 1024;
147
148const validateEnvironmentVariableKey = (key: string) =>
149 Effect.gen(function* () {
150 if (key.length < 1 || key.length > 256 || !ENV_KEY_PATTERN.test(key)) {
151 return yield* Effect.fail(
152 new Error(
153 `Prisma environment variable key '${key}' must match POSIX env-var key shape: [A-Z_][A-Z0-9_]* and be at most 256 characters.`,
154 ),
155 );
156 }
157 });
158
159const validateEnvironmentVariableWrite = (
160 key: string,
161 value: Redacted.Redacted<string>,
162) =>
163 Effect.gen(function* () {
164 yield* validateEnvironmentVariableKey(key);
165 const raw = Redacted.value(value);
166 if (raw.length === 0) {
167 return yield* Effect.fail(
168 new Error(
169 `Prisma environment variable '${key}' value must be non-empty.`,
170 ),
171 );
172 }
173 const byteLength = yield* Effect.sync(
174 () => new TextEncoder().encode(raw).byteLength,
175 );
176 if (byteLength > ENV_VALUE_MAX_BYTES) {
177 return yield* Effect.fail(
178 new Error(
179 `Prisma environment variable '${key}' value exceeds ${ENV_VALUE_MAX_BYTES} bytes.`,
180 ),
181 );
182 }
183 });
184
185// Distilled emits the cursor-paginated list operations as plain ops, so
186// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
187const listVariables = (
188 query: {
189 readonly projectId?: string;
190 readonly class?: "production" | "preview";
191 readonly key?: string;
192 readonly branchId?: string;
193 readonly limit?: number;
194 } = {},
195) =>
196 Effect.gen(function* () {
197 const variables: GetEnvironmentVariablesResponse["data"][number][] = [];
198 let cursor: string | undefined;
199 while (true) {
200 const page = yield* getEnvironmentVariables(
201 cursor === undefined ? query : { ...query, cursor },
202 );
203 variables.push(...page.data);
204 const nextCursor = page.pagination.nextCursor;
205 if (!page.pagination.hasMore) break;
206 if (nextCursor === null) {
207 return yield* Effect.fail(
208 new PrismaPaginationError({
209 message:
210 "Invalid Prisma Management API pagination response from getEnvironmentVariables: hasMore was true without a non-empty nextCursor",
211 }),
212 );
213 }
214 cursor = nextCursor;
215 }
216 return variables;
217 });
218
219const findVariable = (
220 projectId: string,
221 cls: "production" | "preview",
222 key: string,
223 branchId?: string | null,
224) =>
225 listVariables({
226 projectId,
227 class: cls,
228 key,
229 ...(branchId ? { branchId } : {}),
230 limit: 100,
231 }).pipe(
232 Effect.flatMap((variables) => {
233 const matches = variables.filter(
234 (variable) => variable.branchId === (branchId ?? null),
235 );
236 return matches.length > 1
237 ? Effect.fail(
238 new Error(
239 `Multiple Prisma environment variables match '${key}' in the requested scope; refusing to select one arbitrarily.`,
240 ),
241 )
242 : Effect.succeed(matches[0]);
243 }),
244 );
245
246const attrsFrom = (
247 variable: ObservedEnvironmentVariable,
248 value: Redacted.Redacted<string>,
249): EnvironmentVariable["Attributes"] => ({
250 environmentVariableId: variable.id,
251 projectId: variable.projectId,
252 branchId: variable.branchId,
253 class: variable.class,
254 key: variable.key,
255 value,
256 valueKid: variable.valueKid,
257 isManagedBySystem: variable.isManagedBySystem,
258 createdAt: variable.createdAt,
259 updatedAt: variable.updatedAt,
260});
261
262const systemManagedVariableError = (key: string) =>
263 new Error(
264 `Prisma environment variable '${key}' is managed by Prisma and cannot be managed by Alchemy.`,
265 );
266
267const ensureUserManagedVariable = (variable: ObservedEnvironmentVariable) =>
268 Effect.gen(function* () {
269 if (variable.isManagedBySystem) {
270 return yield* Effect.fail(systemManagedVariableError(variable.key));
271 }
272 });
273
274const ensureVariableIdentity = (
275 variable: ObservedEnvironmentVariable,
276 expected: {
277 projectId: string;
278 branchId: string | null;
279 class: "production" | "preview";
280 key: string;
281 },
282) =>
283 variable.projectId === expected.projectId &&
284 variable.branchId === expected.branchId &&
285 variable.class === expected.class &&
286 variable.key === expected.key
287 ? Effect.void
288 : Effect.fail(
289 new Error(
290 `Prisma environment variable '${variable.id}' has immutable identity project '${variable.projectId}', branch '${variable.branchId ?? "null"}', class '${variable.class}', key '${variable.key}', but this resource requires project '${expected.projectId}', branch '${expected.branchId ?? "null"}', class '${expected.class}', key '${expected.key}'. Refusing to update or delete a mismatched secret.`,
291 ),
292 );
293
294const ProviderLive = () =>
295 Provider.effect(
296 EnvironmentVariable,
297 Effect.gen(function* () {
298 return {
299 stables: ["environmentVariableId"],
300 list: () =>
301 listVariables().pipe(
302 Effect.map((variables) =>
303 variables
304 // System-managed variables are project-owned and cannot be
305 // deleted through this API.
306 .filter((variable) => !variable.isManagedBySystem)
307 .map((variable) =>
308 // The API deliberately never returns plaintext. `delete`
309 // needs only identity/metadata, so nuke uses an impossible
310 // empty placeholder rather than pretending to know a secret.
311 attrsFrom(variable, Redacted.make("")),
312 ),
313 ),
314 ),
315 diff: Effect.fn(function* ({ olds, news, output }) {
316 if (!isInputObject(news)) return undefined;
317 if (isPrismaDevId(output?.environmentVariableId)) {
318 return { action: "update" } as const;
319 }
320 const oldProjectId =
321 output?.projectId ?? unresolvedProjectIdOf(olds.project);
322 const newProjectId = isResolved(news.project)
323 ? unresolvedProjectIdOf(news.project)
324 : undefined;
325 if (
326 concreteIdsChanged(oldProjectId, newProjectId) ||
327 (isResolved(news.branchId) && news.branchId !== olds.branchId) ||
328 (isResolved(news.class) && news.class !== olds.class) ||
329 (isResolved(news.key) && news.key !== olds.key)
330 ) {
331 return { action: "replace" } as const;
332 }
333 // Values are write-only: the Management API never returns
334 // plaintext, so equality with `olds` cannot prove the live secret
335 // is still correct. Re-apply every resolved desired value to heal
336 // out-of-band secret drift on ordinary deploys.
337 if (isResolved(news.value)) {
338 return { action: "update" } as const;
339 }
340 return undefined;
341 }),
342 read: Effect.fn(function* ({ output, olds }) {
343 const variableId = isPrismaDevId(output?.environmentVariableId)
344 ? undefined
345 : output?.environmentVariableId;
346 const variable = variableId
347 ? yield* getEnvironmentVariable({
348 envVarId: variableId,
349 }).pipe(
350 Effect.map((response) => response.data),
351 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
352 )
353 : yield* Effect.gen(function* () {
354 const projectId = unresolvedProjectIdOf(olds.project);
355 return projectId
356 ? yield* findVariable(
357 projectId,
358 olds.class,
359 olds.key,
360 olds.branchId,
361 )
362 : undefined;
363 });
364 if (!variable) return undefined;
365 const attrs = attrsFrom(
366 variable,
367 // A cold read cannot observe the secret. Never copy the desired
368 // value into output: doing so would make the forced adoption
369 // reconcile falsely conclude the cloud already contains it.
370 output?.value ?? Redacted.make(""),
371 );
372 return variableId === undefined ? Unowned(attrs) : attrs;
373 }),
374 reconcile: Effect.fn(function* ({ news, output }) {
375 if (news.branchId !== undefined && news.class !== "preview") {
376 return yield* Effect.fail(
377 new Error(
378 'Prisma branch-scoped environment variables must use class: "preview".',
379 ),
380 );
381 }
382 yield* validateEnvironmentVariableWrite(news.key, news.value);
383 const projectId = yield* resolveProjectId(news.project);
384 const variableId = isPrismaDevId(output?.environmentVariableId)
385 ? undefined
386 : output?.environmentVariableId;
387 let variable = variableId
388 ? yield* getEnvironmentVariable({
389 envVarId: variableId,
390 }).pipe(
391 Effect.map((response) => response.data),
392 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
393 )
394 : undefined;
395 const value = news.value;
396 let created = false;
397 if (!variable) {
398 const result = yield* createEnvironmentVariable({
399 projectId,
400 ...(news.branchId ? { branchId: news.branchId } : {}),
401 class: news.class,
402 key: news.key,
403 value: Redacted.value(value),
404 }).pipe(
405 // A replayed create would mint a second variable; the retry
406 // policy cannot see the request, so opt out explicitly.
407 Retry.none,
408 Effect.map((response) => ({
409 variable: response.data,
410 created: true,
411 })),
412 Effect.catchTag("Conflict", () =>
413 Effect.fail(
414 new Error(
415 `Prisma environment variable '${news.key}' appeared after the adoption check. Refusing to overwrite its secret; rerun with adoption enabled if it is the intended variable.`,
416 ),
417 ),
418 ),
419 );
420 variable = result.variable;
421 created = result.created;
422 }
423 yield* ensureVariableIdentity(variable, {
424 projectId,
425 branchId: news.branchId ?? null,
426 class: news.class,
427 key: news.key,
428 });
429 yield* ensureUserManagedVariable(variable);
430 if (!created) {
431 variable = (yield* updateEnvironmentVariable({
432 envVarId: variable.id,
433 value: Redacted.value(value),
434 })).data;
435 }
436 return attrsFrom(variable, value);
437 }),
438 delete: Effect.fn(function* ({ output, session }) {
439 if (isPrismaDevId(output.environmentVariableId)) return;
440 const variable = yield* getEnvironmentVariable({
441 envVarId: output.environmentVariableId,
442 }).pipe(
443 Effect.map((response) => response.data),
444 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
445 );
446 if (!variable) return;
447 yield* ensureVariableIdentity(variable, {
448 projectId: output.projectId,
449 branchId: output.branchId,
450 class: output.class,
451 key: output.key,
452 });
453 if (variable.isManagedBySystem) {
454 // Prisma-owned environment variables are provider-managed system
455 // state and are not deletable through this resource lifecycle.
456 if (session !== undefined) {
457 yield* session.note(
458 `Skipping direct delete for system-managed Prisma environment variable '${variable.key}'.`,
459 );
460 }
461 return;
462 }
463 yield* deleteEnvironmentVariable({
464 envVarId: variable.id,
465 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
466 }),
467 };
468 }),
469 );
470
471const ProviderLocal = () =>
472 devProvider(
473 EnvironmentVariable,
474 ["environmentVariableId"],
475 ({ id, news }) => ({
476 environmentVariableId: devId("environment-variable", id),
477 projectId: attrOrString(news.project, "projectId"),
478 branchId: news.branchId ?? null,
479 class: news.class,
480 key: news.key,
481 value: news.value,
482 valueKid: devId("value-kid", id),
483 isManagedBySystem: false,
484 createdAt: DEV_TIMESTAMP,
485 updatedAt: DEV_TIMESTAMP,
486 }),
487 );
488
489export const EnvironmentVariableProvider = () =>
490 ProviderLayer.dual(EnvironmentVariable, {
491 local: () => ProviderLocal(),
492 live: () => ProviderLive(),
493 });