1import type { Contract } from "@prisma/orm-postgres/contract/types";
2import type { SqlStorage } from "@prisma/orm-postgres/family-contract/types";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import { emitSchemas, SchemaError } from "./Schema.ts";
9import {
10 effectGeneratorKey,
11 type EffectGeneratorOptions,
12} from "./generator.ts";
13import {
14 CliError,
15 rewriteEmittedTypes,
16 runPrismaCli,
17 type EmitResult,
18} from "./internal.ts";
19
20interface GeneratorConfig {
21 readonly orm?: {
22 readonly [effectGeneratorKey]?: EffectGeneratorOptions;
23 readonly contract?: {
24 readonly output?: string;
25 readonly source?: { readonly inputs?: readonly string[] };
26 };
27 };
28}
29
30/** Load generation settings without running emission or migrations. */
31export const loadGeneratorConfig = Effect.fn(function* (configFile: string) {
32 const path = yield* Path.Path;
33 const absolute = path.resolve(configFile);
34 const url = yield* path.toFileUrl(absolute);
35 const module = yield* Effect.tryPromise({
36 try: () => import(url.href),
37 catch: (cause) =>
38 new CliError({ message: `Cannot load ${absolute}: ${String(cause)}` }),
39 });
40 const config = module.default as GeneratorConfig | undefined;
41 const options = config?.orm?.[effectGeneratorKey];
42 if (!options || !config?.orm?.contract?.output) {
43 return yield* Effect.fail(
44 new CliError({
45 message: `Wrap the orm configuration in ${absolute} with withEffect(ormConfig(...), options) and specify output.`,
46 }),
47 );
48 }
49 const directory = path.dirname(absolute);
50 return {
51 absolute,
52 directory,
53 options,
54 output: path.resolve(directory, config.orm.contract.output),
55 inputs: [
56 absolute,
57 ...(config.orm.contract.source?.inputs ?? []).map((input) =>
58 path.resolve(directory, input),
59 ),
60 ],
61 };
62});
63
64const header = "// Generated by alchemy prisma generate.\n";
65
66/** Emit Prisma contracts and optional Effect bindings; never connect or migrate. */
67export const generate = Effect.fn(function* (configFile: string) {
68 const fs = yield* FileSystem.FileSystem;
69 const path = yield* Path.Path;
70 const config = yield* loadGeneratorConfig(configFile);
71 const temporary = yield* fs.makeTempDirectoryScoped({
72 prefix: "alchemy-prisma-generate-",
73 });
74 const emitted = yield* runPrismaCli<EmitResult>(
75 [
76 "contract",
77 "emit",
78 "--config",
79 config.absolute,
80 "--output-path",
81 path.join(temporary, path.basename(config.output)),
82 ],
83 { cwd: config.directory },
84 ).pipe(
85 Effect.catchTag("Prisma.CliError", (error) => {
86 const diagnostics = Schema.decodeUnknownOption(
87 Schema.Struct({
88 diagnostics: Schema.Array(Schema.Struct({ message: Schema.String })),
89 }),
90 )(error.meta);
91 return Effect.fail(
92 Option.isSome(diagnostics)
93 ? new CliError({
94 ...error,
95 message: [
96 error.message,
97 ...diagnostics.value.diagnostics.map(
98 (diagnostic) => diagnostic.message,
99 ),
100 ].join("\n"),
101 })
102 : error,
103 );
104 }),
105 );
106 const json = path.resolve(config.directory, emitted.files.json);
107 const dts = path.resolve(config.directory, emitted.files.dts);
108 yield* rewriteEmittedTypes(dts);
109 const text = yield* fs.readFileString(json);
110 const contract = yield* Effect.try({
111 try: () => JSON.parse(text) as Contract<SqlStorage>,
112 catch: (cause) =>
113 new CliError({ message: `Invalid emitted contract: ${String(cause)}` }),
114 });
115 const directory = path.dirname(config.output);
116 const jsonName = `./${path.basename(json)}`;
117 const typeName = `./${path.basename(dts)}`;
118 const files: Record<string, string> = {};
119 if (config.options.client !== false) {
120 files["runtime.ts"] =
121 `${header}import contractJson from ${JSON.stringify(jsonName)} with { type: "json" };\nexport { contractJson };\n`;
122 files["client.ts"] =
123 `${header}import type { Contract } from ${JSON.stringify(typeName)};\nimport type * as Effect from "effect/Effect";\nimport type * as Redacted from "effect/Redacted";\nimport { Postgres, type PostgresConfig } from "alchemy/Prisma/ORM/Postgres";\nimport { contractJson } from "./runtime.ts";\nexport const makeDatabase = <E = never, R = never>(\n connectionString: Effect.Effect<Redacted.Redacted<string>, E, R>,\n options: Omit<PostgresConfig<Contract>, "contract" | "contractJson"> = {},\n) => Postgres<Contract, E, R>(connectionString, { ...options, contractJson });\n`;
124 }
125 if (config.options.schemas !== false) {
126 files["schemas.ts"] = yield* Effect.try({
127 try: () => emitSchemas(contract, config.options),
128 catch: (cause) =>
129 cause instanceof SchemaError
130 ? cause
131 : new CliError({ message: `Cannot emit schemas: ${String(cause)}` }),
132 });
133 }
134 files["index.ts"] =
135 header +
136 [
137 ...(config.options.client !== false
138 ? ['export { makeDatabase } from "./client.ts";']
139 : []),
140 ...(config.options.schemas !== false
141 ? ['export { schemas } from "./schemas.ts";']
142 : []),
143 ].join("\n") +
144 "\n";
145 const owned = ["runtime.ts", "client.ts", "schemas.ts", "index.ts"];
146 for (const name of owned) {
147 const target = path.join(directory, name);
148 if (yield* fs.exists(target)) {
149 const previous = yield* fs.readFileString(target);
150 if (!previous.startsWith(header)) {
151 return yield* Effect.fail(
152 new CliError({
153 message: `Refusing to overwrite non-generated file ${target}`,
154 }),
155 );
156 }
157 }
158 }
159 files[path.basename(json)] = text;
160 files[path.basename(dts)] = yield* fs.readFileString(dts);
161 yield* fs.makeDirectory(directory, { recursive: true });
162 for (const name of new Set([...owned, ...Object.keys(files)])) {
163 const target = path.join(directory, name);
164 const exists = yield* fs.exists(target);
165 const previous = exists ? yield* fs.readFileString(target) : undefined;
166 const content = files[name];
167 if (content === undefined) {
168 if (exists) yield* fs.remove(target);
169 } else if (content !== previous) {
170 yield* fs.writeFileString(target, content);
171 }
172 }
173 return {
174 directory,
175 files: Object.keys(files).map((name) => path.join(directory, name)),
176 };
177}, Effect.scoped);