1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "alchemy-test";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as ts from "typescript-api/unstable/ast";
7import { API } from "typescript-api/unstable/async";
8
9const forbiddenPatterns = [
10 {
11 name: "raw filesystem/path/os imports",
12 pattern:
13 /\bfrom\s+["'](?:node:fs|node:fs\/promises|node:path|node:os|pathe)["']/,
14 },
15 {
16 name: "async/await",
17 pattern: /(?:^|[^\w.])(?:async|await)\b/,
18 },
19 {
20 name: "Effect.promise",
21 pattern: /\bEffect\.promise\b/,
22 },
23 {
24 name: "raw Promise constructor",
25 pattern: /\bnew\s+Promise\b/,
26 },
27 {
28 name: "raw fetch",
29 pattern: /\bfetch\s*\(/,
30 },
31 {
32 name: "Effect.orDie",
33 pattern: /\bEffect\.orDie\b/,
34 },
35 {
36 name: "legacy create/update lifecycle handlers",
37 pattern: /\b(?:create|update)\s*:\s*Effect\.fn\b/,
38 },
39 {
40 name: "explicit output undefined create/update branch",
41 pattern:
42 /\b(?:output\s*(?:===|!==)\s*undefined|undefined\s*(?:===|!==)\s*output)\b/,
43 },
44 {
45 name: "bare process.cwd()",
46 pattern: /\bprocess\.cwd\(\)/,
47 },
48 {
49 name: "explicit any",
50 pattern: /\bany\b/,
51 },
52 {
53 name: "double unknown cast",
54 pattern: /\bas\s+unknown\s+as\b/,
55 },
56] as const;
57
58const documentedResources = [
59 "Branch",
60 "Bucket",
61 "BucketAccessKey",
62 "Compute",
63 "App",
64 "Deployment",
65 "Connection",
66 "CustomDomain",
67 "Database",
68 "EnvironmentVariable",
69 "Project",
70 "SourceRepository",
71] as const;
72
73const resourceConfigInterfaces = {
74 Compute: ["ComputeBuild", "ComputeDev", "ComputeProps", "ComputeStaticBuild"],
75 Connection: ["ConnectionEnvOptions"],
76 Database: ["DatabaseDev"],
77} as const;
78
79// These are interop boundaries, not lifecycle implementation shortcuts. Keep
80// exceptions scoped to both a file and a rule so other conventions still apply.
81const boundaryPatterns: Record<string, ReadonlyArray<string>> = {
82 "Internal/ArchivePlatform.ts": [
83 "raw filesystem/path/os imports",
84 "async/await",
85 ],
86 "Internal/ArtifactFile.ts": ["raw filesystem/path/os imports", "async/await"],
87 // Resolve user paths against the cwd at invocation time, not module import.
88 "ComputeArchive.ts": ["bare process.cwd()"],
89 "ORM/Contract.ts": ["bare process.cwd()"],
90 "ORM/Migrate.ts": ["bare process.cwd()"],
91 // Prisma's native authoring function is retyped to preserve its generic API.
92 "ORM/ContractBuilder.ts": ["double unknown cast"],
93 // The fluent runtime proxy erases intermediate native query-builder types.
94 "ORM/OrmClient.ts": ["explicit any"],
95 // Native Promise transactions and close/rollback finalizers cross into Effect.
96 "ORM/Postgres.ts": ["async/await", "Effect.promise"],
97};
98
99const stripStringsAndComments = (source: string) =>
100 source
101 .replaceAll(/`(?:\\.|[^`])*`/gs, "``")
102 .replaceAll(/"(?:\\.|[^"])*"/g, '""')
103 .replaceAll(/'(?:\\.|[^'])*'/g, "''")
104 .replaceAll(/\/\*[\s\S]*?\*\//g, "")
105 .replaceAll(/\/\/.*$/gm, "");
106
107const prismaSourceProject = Effect.gen(function* () {
108 const path = yield* Path.Path;
109 const sourceRoot = path.resolve(import.meta.dirname, "../../src/Prisma");
110 const configPath = path.join(sourceRoot, "tsconfig.source-conventions.json");
111 const config = JSON.stringify({
112 files: documentedResources.map((resource) =>
113 path.join(sourceRoot, `${resource}.ts`),
114 ),
115 compilerOptions: { noResolve: true, noLib: true, types: [] },
116 });
117 const api = yield* Effect.acquireRelease(
118 Effect.sync(
119 () =>
120 new API({
121 fs: {
122 readFile: (file) => (file === configPath ? config : undefined),
123 },
124 }),
125 ),
126 (api) => Effect.promise(() => api.close()),
127 );
128 const snapshot = yield* Effect.tryPromise(() =>
129 api.updateSnapshot({ openProjects: [configPath] }),
130 );
131 const project = snapshot.getProject(configPath);
132 if (!project) throw new Error(`Missing project ${configPath}`);
133 return project.program;
134});
135
136describe(
137 "Prisma source conventions",
138 { tags: ["unit", "provider:prisma", "local"] },
139 () => {
140 it.effect(
141 "keeps provider source in Effect-style lifecycle conventions",
142 () =>
143 Effect.gen(function* () {
144 const fs = yield* FileSystem.FileSystem;
145 const path = yield* Path.Path;
146 const sourceRoot = path.resolve(
147 import.meta.dirname,
148 "../../src/Prisma",
149 );
150 const files = (yield* fs.readDirectory(sourceRoot, {
151 recursive: true,
152 }))
153 .filter((file) => file.endsWith(".ts"))
154 .sort();
155
156 const violations: string[] = [];
157 for (const file of files) {
158 const fullPath = path.join(sourceRoot, file);
159 const source = yield* fs.readFileString(fullPath);
160 for (const { name, pattern } of forbiddenPatterns) {
161 if (boundaryPatterns[file]?.includes(name)) continue;
162 const scannedSource =
163 name === "raw filesystem/path/os imports"
164 ? source
165 : stripStringsAndComments(source);
166 const match = pattern.exec(scannedSource);
167 if (match) {
168 violations.push(`${file}: ${name}: ${match[0]}`);
169 }
170 }
171 }
172
173 expect(violations).toEqual([]);
174 }).pipe(Effect.provide(NodeServices.layer)),
175 );
176
177 it.effect("keeps Prisma resources ready for generated API docs", () =>
178 Effect.gen(function* () {
179 const fs = yield* FileSystem.FileSystem;
180 const path = yield* Path.Path;
181 const sourceRoot = path.resolve(
182 import.meta.dirname,
183 "../../src/Prisma",
184 );
185
186 for (const resource of documentedResources) {
187 const source = yield* fs.readFileString(
188 path.join(sourceRoot, `${resource}.ts`),
189 );
190 expect(source).toContain(`export interface ${resource}Props`);
191 const constructorPattern =
192 resource === "Compute"
193 ? `export const ${resource}: Platform<`
194 : `export const ${resource} = Resource<`;
195 expect(source).toContain(constructorPattern);
196
197 const resourceDoc = source.match(
198 new RegExp(
199 resource === "Compute"
200 ? `/\\*\\*[\\s\\S]*?\\*/\\s*export const ${resource}: Platform<`
201 : `/\\*\\*[\\s\\S]*?\\*/\\s*export const ${resource} = Resource<`,
202 ),
203 )?.[0];
204 if (resourceDoc === undefined) {
205 throw new Error(`${resource} is missing resource JSDoc`);
206 }
207 // `docs:fix-jsdoc` rewrites `@section` / `@example` into markdown
208 // headings (`###` / `**Example:**`). Source of truth is the
209 // rewritten form — see scripts/fix-api-jsdocs.ts.
210 expect(resourceDoc).toContain("### ");
211 expect(resourceDoc).toContain("**Example:**");
212 }
213 }).pipe(Effect.provide(NodeServices.layer)),
214 );
215
216 it.effect("documents public Prisma resource props and attributes", () =>
217 Effect.gen(function* () {
218 const path = yield* Path.Path;
219 const sourceRoot = path.resolve(
220 import.meta.dirname,
221 "../../src/Prisma",
222 );
223 const program = yield* prismaSourceProject;
224 const missingDocs: string[] = [];
225
226 for (const resource of documentedResources) {
227 const fileName = `${resource}.ts`;
228 const sourceFile = yield* Effect.tryPromise(() =>
229 program.getSourceFile(path.join(sourceRoot, fileName)),
230 );
231 if (!sourceFile) throw new Error(`Missing source file ${fileName}`);
232 const configInterfaces = [
233 `${resource}Props`,
234 ...(resourceConfigInterfaces[
235 resource as keyof typeof resourceConfigInterfaces
236 ] ?? []),
237 ];
238
239 for (const interfaceName of configInterfaces) {
240 const declaration = sourceFile.statements
241 .filter(ts.isInterfaceDeclaration)
242 .find((node) => node.name.text === interfaceName);
243 if (declaration === undefined) continue;
244 for (const property of declaration.members.filter(
245 ts.isPropertySignatureDeclaration,
246 )) {
247 if (!property.jsDoc?.length) {
248 missingDocs.push(
249 `${fileName}:${interfaceName}.${property.name.getText()}`,
250 );
251 }
252 }
253 }
254
255 const resourceDeclaration = sourceFile.statements
256 .filter(ts.isInterfaceDeclaration)
257 .find((node) => node.name.text === resource);
258 const attrs = resourceDeclaration?.heritageClauses?.find(
259 (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
260 )?.types[0]?.typeArguments?.[2];
261 const attributes =
262 attrs && ts.isTypeLiteralNode(attrs) ? attrs : undefined;
263 if (attributes === undefined) continue;
264 for (const property of attributes.members.filter(
265 ts.isPropertySignatureDeclaration,
266 )) {
267 if (!property.jsDoc?.length) {
268 missingDocs.push(
269 `${fileName}:${resource}.Attributes.${property.name.getText()}`,
270 );
271 }
272 }
273 }
274
275 expect(missingDocs).toEqual([]);
276 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
277 );
278
279 it.effect("keeps raw artifact bytes out of persisted resource props", () =>
280 Effect.gen(function* () {
281 const path = yield* Path.Path;
282 const sourceRoot = path.resolve(
283 import.meta.dirname,
284 "../../src/Prisma",
285 );
286 const program = yield* prismaSourceProject;
287
288 for (const resource of ["Compute", "Deployment"] as const) {
289 const sourceFile = yield* Effect.tryPromise(() =>
290 program.getSourceFile(path.join(sourceRoot, `${resource}.ts`)),
291 );
292 if (!sourceFile)
293 throw new Error(`Missing source file ${resource}.ts`);
294 const props = sourceFile.statements
295 .filter(ts.isInterfaceDeclaration)
296 .find((node) => node.name.text === `${resource}Props`);
297 const properties = props?.members
298 .filter(ts.isPropertySignatureDeclaration)
299 .map((node) => node.name.getText());
300 expect(properties).not.toContain("artifact");
301 expect(properties).toContain("artifactPath");
302 }
303 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
304 );
305
306 it.effect("models deployment environment values as redaction markers", () =>
307 Effect.gen(function* () {
308 const fs = yield* FileSystem.FileSystem;
309 const path = yield* Path.Path;
310 const source = yield* fs.readFileString(
311 path.resolve(import.meta.dirname, "../../src/Prisma/Types.ts"),
312 );
313
314 expect(source).toContain(
315 'export type RedactedDeploymentEnvironmentValue = "[redacted]"',
316 );
317 expect(source).toContain(
318 "envVars?: Record<string, RedactedDeploymentEnvironmentValue>",
319 );
320 }).pipe(Effect.provide(NodeServices.layer)),
321 );
322 },
323);