1import { AlchemyContext } from "@/AlchemyContext";
2import { ArtifactStore, createArtifactStore } from "@/Artifacts";
3import { AuthProviders } from "@/Auth/AuthProvider";
4import * as CliKit from "@/Cli/CliKit";
5import * as Provider from "@/Provider";
6import * as Prisma from "@/Prisma";
7import { PrismaLogStreamError } from "@/Prisma/PrismaLogs";
8import { PlatformServices } from "@/Util/PlatformServices";
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import { describe, expect, it } from "alchemy-test";
11import * as ConfigProvider from "effect/ConfigProvider";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Redacted from "effect/Redacted";
15import * as Result from "effect/Result";
16import * as Stream from "effect/Stream";
17import { v4 as uuidv4 } from "uuid";
18import { testStackContext } from "./fixtures/StackContext.ts";
19
20const devAlchemyContext = Layer.succeed(AlchemyContext, {
21 dotAlchemy: ".alchemy-test",
22 dev: true,
23 adopt: false,
24});
25
26// What `Stack.make` supplies to a stack's `providers` layer.
27const stackProvidedServices = Layer.mergeAll(
28 testStackContext,
29 PlatformServices,
30 Layer.effect(ArtifactStore, Effect.sync(createArtifactStore)),
31);
32
33const providePrismaDev = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
34 effect.pipe(
35 Effect.provide(
36 Prisma.providers().pipe(
37 // The dual registration always carries the (lazy) management-api
38 // layer; it registers auth at build without resolving credentials.
39 Layer.provideMerge(Layer.succeed(AuthProviders, {})),
40 Layer.provide(stackProvidedServices),
41 ),
42 ),
43 Effect.provide(devAlchemyContext),
44 Effect.provide(CliKit.layer({ input: false })),
45 );
46
47const reconcileInput = (id: string, news: unknown, output?: unknown) =>
48 ({
49 id,
50 instanceId: "00000000000000000000000000000000",
51 news,
52 olds: undefined,
53 output,
54 session: undefined as never,
55 bindings: [],
56 }) as never;
57
58describe(
59 "Prisma providers",
60 { tags: ["unit", "provider:prisma", "local"] },
61 () => {
62 it.effect(
63 "registers Prisma auth without resolving credentials",
64 () =>
65 Effect.gen(function* () {
66 const authProviders: AuthProviders["Service"] = {};
67
68 yield* Layer.build(
69 Prisma.providers().pipe(
70 Layer.provideMerge(Layer.succeed(AuthProviders, authProviders)),
71 Layer.provide(stackProvidedServices),
72 ),
73 );
74
75 expect(authProviders.Prisma?.name).toBe("Prisma");
76 }).pipe(
77 Effect.provide(
78 ConfigProvider.layer(
79 ConfigProvider.fromUnknown({
80 CI: true,
81 }),
82 ),
83 ),
84 Effect.provide(
85 Layer.succeed(AlchemyContext, {
86 dotAlchemy: ".alchemy-test",
87 dev: false,
88 adopt: false,
89 }),
90 ),
91 Effect.scoped,
92 ),
93 { timeout: 30_000 },
94 );
95
96 it.effect(
97 "registers every Prisma resource provider",
98 () =>
99 Effect.gen(function* () {
100 const resourceTypes = [
101 Prisma.Project.Type,
102 Prisma.Database.Type,
103 Prisma.Connection.Type,
104 Prisma.Branch.Type,
105 Prisma.Bucket.Type,
106 Prisma.BucketAccessKey.Type,
107 Prisma.Compute.Type,
108 Prisma.App.Type,
109 Prisma.Deployment.Type,
110 Prisma.CustomDomain.Type,
111 Prisma.EnvironmentVariable.Type,
112 Prisma.SourceRepository.Type,
113 ];
114 const expectedStables = new Map([
115 [Prisma.Project.Type, ["projectId"]],
116 [Prisma.Database.Type, ["databaseId"]],
117 [Prisma.Connection.Type, ["connectionId"]],
118 [Prisma.Branch.Type, ["branchId"]],
119 [Prisma.Bucket.Type, ["bucketId"]],
120 [
121 Prisma.BucketAccessKey.Type,
122 [
123 "bucketAccessKeyId",
124 "bucketId",
125 "accessKeyId",
126 "secretAccessKey",
127 "endpoint",
128 "bucketName",
129 ],
130 ],
131 [Prisma.Compute.Type, ["appId"]],
132 [Prisma.App.Type, ["appId"]],
133 [Prisma.Deployment.Type, ["deploymentId"]],
134 [Prisma.CustomDomain.Type, ["customDomainId"]],
135 [Prisma.EnvironmentVariable.Type, ["environmentVariableId"]],
136 [Prisma.SourceRepository.Type, ["sourceRepositoryId"]],
137 ]);
138
139 const providers = yield* Effect.all(
140 resourceTypes.map((type) =>
141 Provider.findProviderByType(type as any),
142 ),
143 { concurrency: "unbounded" },
144 );
145
146 expect(providers).toHaveLength(resourceTypes.length);
147 for (const provider of providers) {
148 expect(typeof provider.reconcile).toBe("function");
149 expect(typeof provider.delete).toBe("function");
150 // Lookup resolves the concrete local variant in dev.
151 expect(provider.mode).toBe("local");
152 }
153 for (let i = 0; i < resourceTypes.length; i += 1) {
154 const provider = providers[i]!;
155 expect(provider.stables).toEqual(
156 expectedStables.get(resourceTypes[i]),
157 );
158 }
159 }).pipe(providePrismaDev),
160 {
161 tags: [
162 "provider:prisma:app",
163 "provider:prisma:branch",
164 "provider:prisma:bucket",
165 "provider:prisma:bucketaccesskey",
166 "provider:prisma:compute",
167 "provider:prisma:connection",
168 "provider:prisma:customdomain",
169 "provider:prisma:database",
170 "provider:prisma:deployment",
171 "provider:prisma:environmentvariable",
172 "provider:prisma:project",
173 "provider:prisma:sourcerepository",
174 ],
175 },
176 );
177
178 it.effect(
179 "uses tokenless dev providers from Prisma.providers()",
180 () =>
181 Effect.gen(function* () {
182 const projectProvider = yield* Provider.findProviderByType(
183 Prisma.Project.Type as any,
184 );
185 const appProvider = yield* Provider.findProviderByType(
186 Prisma.App.Type as any,
187 );
188 const envProvider = yield* Provider.findProviderByType(
189 Prisma.EnvironmentVariable.Type as any,
190 );
191 const branchProvider = yield* Provider.findProviderByType(
192 Prisma.Branch.Type as any,
193 );
194 const bucketProvider = yield* Provider.findProviderByType(
195 Prisma.Bucket.Type as any,
196 );
197 const bucketKeyProvider = yield* Provider.findProviderByType(
198 Prisma.BucketAccessKey.Type as any,
199 );
200
201 const project = (yield* projectProvider.reconcile(
202 reconcileInput("Project", {
203 name: "local-project",
204 createDatabase: false,
205 }),
206 )) as Prisma.Project["Attributes"];
207 const app = (yield* appProvider.reconcile(
208 reconcileInput("App", {
209 project,
210 displayName: "api",
211 regionId: "us-east-1",
212 }),
213 )) as Prisma.App["Attributes"];
214 const env = (yield* envProvider.reconcile(
215 reconcileInput("Environment", {
216 project,
217 branchId: "branch-preview",
218 class: "preview",
219 key: "TOKEN",
220 value: Redacted.make("secret"),
221 }),
222 )) as Prisma.EnvironmentVariable["Attributes"];
223 const branch = (yield* branchProvider.reconcile(
224 reconcileInput("Branch", {
225 project,
226 gitName: "main",
227 isDefault: true,
228 }),
229 )) as Prisma.Branch["Attributes"];
230
231 const bucket = (yield* bucketProvider.reconcile(
232 reconcileInput("Bucket", { project, name: "uploads" }),
233 )) as Prisma.Bucket["Attributes"];
234 const bucketKey = (yield* bucketKeyProvider.reconcile(
235 reconcileInput("BucketAccessKey", { bucket, role: "read_write" }),
236 )) as Prisma.BucketAccessKey["Attributes"];
237
238 expect(project.projectId).toBe("dev:project:Project");
239 expect(app.projectId).toBe(project.projectId);
240 expect(app.appId).toBe("dev:app:App");
241 expect(env.projectId).toBe(project.projectId);
242 expect(env.branchId).toBe("branch-preview");
243 expect(Redacted.value(env.value)).toBe("secret");
244 expect(branch.role).toBe("production");
245 expect(bucket.bucketId).toBe("dev:bucket:Bucket");
246 expect(bucket.name).toBe("uploads");
247 expect(bucket.projectId).toBe(project.projectId);
248 expect(bucketKey.bucketAccessKeyId).toBe(
249 "dev:bucket-access-key:BucketAccessKey",
250 );
251 expect(bucketKey.bucketId).toBe(bucket.bucketId);
252 expect(Redacted.isRedacted(bucketKey.secretAccessKey)).toBe(true);
253 }).pipe(providePrismaDev),
254 {
255 tags: [
256 "provider:prisma:app",
257 "provider:prisma:branch",
258 "provider:prisma:bucket",
259 "provider:prisma:bucketaccesskey",
260 "provider:prisma:environmentvariable",
261 "provider:prisma:project",
262 ],
263 },
264 );
265
266 it.effect("managementApi rejects an unknown explicit profile", () =>
267 Effect.gen(function* () {
268 const result = yield* Effect.result(
269 Effect.sandbox(
270 Effect.gen(function* () {
271 yield* Prisma.PrismaClient;
272 }).pipe(
273 Effect.provide(Prisma.managementApi()),
274 Effect.provide(
275 ConfigProvider.layer(
276 ConfigProvider.fromUnknown({
277 ALCHEMY_PROFILE: `non-existent-${uuidv4()}`,
278 }),
279 ),
280 ),
281 Effect.provide(NodeServices.layer),
282 Effect.provide(CliKit.layer({ input: false })),
283 ),
284 ),
285 );
286 expect(Result.isFailure(result)).toBe(true);
287 if (Result.isFailure(result)) {
288 expect(String(result.failure)).toContain("does not exist");
289 expect(String(result.failure)).toContain("alchemy profile create");
290 }
291 }),
292 );
293
294 it.effect(
295 "tails deployment logs from a providers()-shaped stack context",
296 () =>
297 Effect.gen(function* () {
298 const provider = yield* Provider.findProviderByType(
299 Prisma.Deployment.Type as any,
300 );
301
302 // The tail stream must resolve everything it needs from the context
303 // `providers()` produces. Point it at a closed loopback port so the
304 // WebSocket fails fast: a typed PrismaLogStreamError proves the
305 // context was complete, while a missing service surfaces as a defect.
306 const error = yield* Stream.runDrain(
307 provider.tail!({
308 output: { deploymentId: "deployment-1" },
309 } as never),
310 ).pipe(Effect.flip);
311
312 expect(error).toBeInstanceOf(PrismaLogStreamError);
313 }).pipe(
314 Effect.provide(
315 Prisma.providers().pipe(
316 Layer.provideMerge(Layer.succeed(AuthProviders, {})),
317 Layer.provide(stackProvidedServices),
318 ),
319 ),
320 Effect.provide(
321 Layer.succeed(AlchemyContext, {
322 dotAlchemy: ".alchemy-test",
323 dev: false,
324 adopt: false,
325 }),
326 ),
327 Effect.provide(
328 ConfigProvider.layer(
329 ConfigProvider.fromUnknown({
330 CI: true,
331 PRISMA_SERVICE_TOKEN: "test-token",
332 PRISMA_API_URL: "http://127.0.0.1:1",
333 }),
334 ),
335 ),
336 ),
337 { tags: ["provider:prisma:deployment"], timeout: 30_000 },
338 );
339 },
340);