| 1 | import type { Contract } from "@prisma/orm-postgres/contract/types"; |
| 2 | import type { SqlStorage } from "@prisma/orm-postgres/family-contract/types"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import { emitSchemas, SchemaError } from "./Schema.ts"; |
| 9 | import { |
| 10 | effectGeneratorKey, |
| 11 | type EffectGeneratorOptions, |
| 12 | } from "./generator.ts"; |
| 13 | import { |
| 14 | CliError, |
| 15 | rewriteEmittedTypes, |
| 16 | runPrismaCli, |
| 17 | type EmitResult, |
| 18 | } from "./internal.ts"; |
| 19 | |
| 20 | interface 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. */ |
| 31 | export 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 | |
| 64 | const header = "// Generated by alchemy prisma generate.\n"; |
| 65 | |
| 66 | /** Emit Prisma contracts and optional Effect bindings; never connect or migrate. */ |
| 67 | export 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); |