@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,439 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { posix } from 'node:path'
4
+ import { compileFunction } from 'node:vm'
5
+
6
+ import { readJson } from '@exodus/stasis-core/bundle-util'
7
+ import { hasNodeModulesSegment } from '@exodus/stasis-core/util'
8
+ import { createVfs } from '@preventive/vfs'
9
+ import { isDir, isFile, loadTsconfigCompilerOptions } from '../resolve-typescript.js'
10
+ import { getParser, literalSpec, syntaxErrors } from '../scan.js'
11
+ import { holding, nearest, projectView } from './tree.js'
12
+
13
+ // buildVfsBundle's `generate: ['prisma']`: the client each project's `prisma generate` would write
14
+ // with its installed Prisma, without running anything of the repo: schema, config, tsconfig.json
15
+ // and package.json are read from the tree as data. The optional peer GENERATOR builds the client in
16
+ // memory; an older version's is 7.10.0's rewritten (DOWN), tested byte for byte against its own.
17
+
18
+ const GENERATOR = '@prisma/client-generator-ts'
19
+ const GENERATOR_VERSION = '7.10.0'
20
+ const PROVIDER = 'prisma-client'
21
+ const require = createRequire(import.meta.url)
22
+
23
+ // The engine hash each Prisma stamps its client with, newest first.
24
+ const ENGINES = {
25
+ '7.10.0': '0edf323efd1d98336f3f0a68684b56f689b900d3',
26
+ '7.9.1': 'e922089b7d7502aff4249d5da3420f6fa55fc6ad',
27
+ '7.9.0': 'e922089b7d7502aff4249d5da3420f6fa55fc6ad',
28
+ '7.8.0': '3c6e192761c0362d496ed980de936e2f3cebcd3a',
29
+ '7.7.0': '75cbdc1eb7150937890ad5465d861175c6624711',
30
+ '7.6.0': '75cbdc1eb7150937890ad5465d861175c6624711',
31
+ '7.5.0': '280c870be64f457428992c43c1f6d557fab6e29e',
32
+ '7.4.2': '94a226be1cf2967af2541cca5529f0f7ba866919',
33
+ '7.4.1': '55ae170b1ced7fc6ed07a15f110549408c501bb3',
34
+ '7.4.0': 'ab56fe763f921d033a6c195e7ddeb3e255bdbb57',
35
+ }
36
+ export const PRISMA_VERSIONS = Object.keys(ENGINES)
37
+ const newerThan = (version, than) => PRISMA_VERSIONS.indexOf(version) < PRISMA_VERSIONS.indexOf(than)
38
+
39
+ // --- the generator ---
40
+
41
+ // The bundle's readSourceFile, for the client `at` describes: an edge runtime's client copies the
42
+ // query compiler from `prisma generate`'s own build, which here is the tree's `prisma`.
43
+ const readQueryCompiler = (at) => (name) => {
44
+ const file = posix.join(at.prismaDir, 'build', name)
45
+ if (!isFile(file, at.host)) throw new Error(`prisma: ${file}, which a ${at.target} client carries, is not in the tree`)
46
+ return Buffer.from(at.host.readFile(file))
47
+ }
48
+
49
+ // The bundle's inferModuleFormatFromNearestPackageJson, for node16 and nodenext; ESM before 7.10.0.
50
+ const packageFormat = ({ host, version }) => (dir) => {
51
+ if (!newerThan(version, '7.9.1')) return 'esm'
52
+ const file = nearestFile(host, dir, 'package.json')
53
+ try { return file !== null && JSON.parse(host.readFile(file).toString('utf8')).type === 'module' ? 'esm' : 'cjs' } catch { return 'cjs' }
54
+ }
55
+
56
+ // The esbuild bundle at `main` as a function of `parameters` returning `roots`, kept to the top-level
57
+ // statements they reach by name (any word of a statement: more is kept than used, never less), and
58
+ // refused where it then requires anything `given` doesn't hold, or by no literal name.
59
+ function compiled(main, roots, parameters, given) {
60
+ const source = readFileSync(main, 'utf8')
61
+ const { body } = getParser().parseSync(main, source, { sourceType: 'script' }).program
62
+ const declaring = new Map(body.flatMap((node) => (node.declarations ?? [node]).flatMap(({ id }) => (id?.name ? [[id.name, node]] : []))))
63
+ const kept = new Set()
64
+ const keep = (name) => {
65
+ const node = declaring.get(name)
66
+ if (node === undefined || parameters.includes(name) || kept.has(node)) return
67
+ kept.add(node)
68
+ for (const [word] of source.slice(node.start, node.end).matchAll(/[\w$]+/gu)) keep(word)
69
+ }
70
+ roots.forEach(keep)
71
+ const reached = body.filter((node) => node.directive || kept.has(node)).map((node) => source.slice(node.start, node.end)).join('\n')
72
+ const denied = [...reached.matchAll(/\brequire\b(?:\("([^"]*)"\))?/gu)].filter(([, id]) => !Object.hasOwn(given, id)).map(([call]) => call)
73
+ if (denied.length > 0) throw new Error(`--generate=prisma: ${main}, stripped, holds ${[...new Set(denied)].join(', ')}, beyond what stasis lets it require`)
74
+ return compileFunction(`${reached}\nreturn { ${roots.join(', ')} }`, ['require', ...parameters], { filename: main }).bind(null, (id) => given[id])
75
+ }
76
+
77
+ // CommonJS files from `main` on, compiled requiring nothing outside their package but what `given`
78
+ // holds (none reads __filename or __dirname).
79
+ function commonjs(main, given) {
80
+ const modules = new Map()
81
+ const load = (file) => {
82
+ if (modules.has(file)) return modules.get(file).exports
83
+ const module = { exports: {} }
84
+ modules.set(file, module)
85
+ const requiring = (id) => {
86
+ if (id.startsWith('.')) return load(createRequire(file).resolve(id))
87
+ if (!Object.hasOwn(given, id)) throw new Error(`--generate=prisma: ${file} requires ${id}, which stasis doesn't let it`)
88
+ return given[id]
89
+ }
90
+ compileFunction(readFileSync(file, 'utf8'), ['exports', 'require', 'module'], { filename: file })(module.exports, requiring, module)
91
+ return module.exports
92
+ }
93
+ return load(main)
94
+ }
95
+
96
+ // The schema engine's refusals are JSON with a `message`; its panics aren't.
97
+ const engineMessage = (message) => { try { return JSON.parse(message).message ?? message } catch { return message } }
98
+
99
+ // What the compiled generator returns, is given, and requires as Node loads it (none of it the
100
+ // disk's); given, as readSourceFile and inferModuleFormatFromNearestPackageJson read the disk,
101
+ // `debug` would require @prisma/debug, and buildTypedSql is undefined: stasis passes no typedSql.
102
+ const EXPORTS = ['buildClient', 'validateDmmfAgainstDenylists', 'parseRuntimeTargetFromUnknown', 'parseGeneratedFileExtension', 'parseImportFileExtension', 'inferImportFileExtension', 'parseModuleFormatFromUnknown', 'inferModuleFormat', 'parseCompilerBuildFromUnknown']
103
+ const PARAMETERS = ['readSourceFile', 'inferModuleFormatFromNearestPackageJson', 'debug', 'buildTypedSql']
104
+ const REQUIRES = ['@prisma/client-common', '@prisma/dmmf', '@prisma/param-graph-builder', 'indent-string', 'klona', 'pluralize', 'ts-pattern']
105
+ let loaded
106
+
107
+ // The peer exports neither buildClient (generateClient's in-memory half), the reserved-names check
108
+ // generateClient runs, nor its generate()'s option parsing and inference, so its bundle is compiled
109
+ // to return them, stripped (cleanup, not a sandbox) of what they never reach. @prisma/internals is
110
+ // never loaded: loading it reads the host's npm and yarn config and platform, and patches fs and
111
+ // process.cwd. Its users get a stand-in: its parsing the schema engine's and @prisma/get-dmmf's,
112
+ // which it wraps, and its isValidJsIdentifier its own file's, whose Unicode tables aren't Node's.
113
+ function loadGenerator() {
114
+ if (loaded) return loaded
115
+ let main
116
+ try { main = require.resolve(GENERATOR) } catch (cause) { throw new Error(`--generate=prisma needs the optional '${GENERATOR}' ${GENERATOR_VERSION} dependency; install it (e.g. \`npm i ${GENERATOR}@${GENERATOR_VERSION}\`)`, { cause }) }
117
+ const own = createRequire(main)
118
+ const { version } = own('../package.json')
119
+ if (version !== GENERATOR_VERSION) throw new Error(`--generate=prisma needs ${GENERATOR} ${GENERATOR_VERSION}, not ${version}`)
120
+ // Prisma's packages beside the @prisma/internals the peer was built with.
121
+ const near = createRequire(own.resolve('@prisma/internals'))
122
+ const engine = near('@prisma/prisma-schema-wasm')
123
+ const getDmmf = near('@prisma/get-dmmf')
124
+ const internals = {
125
+ externalToInternalDmmf: getDmmf.externalToInternalDmmf,
126
+ hasOwnProperty: Object.hasOwn,
127
+ assertNever: (value, message) => { throw new Error(message) },
128
+ isValidJsIdentifier: commonjs(own.resolve('@prisma/internals/dist/utils/isValidJsIdentifier.js'), {}).isValidJsIdentifier,
129
+ // But for resolving binaryTargets, which the generator never reads.
130
+ getConfig: ({ datamodel }) => {
131
+ const { config, errors } = JSON.parse(engine.get_config(JSON.stringify({ prismaSchema: datamodel })))
132
+ if (errors.length > 0) throw new Error(errors.map((error) => error.message).join('\n'))
133
+ return config
134
+ },
135
+ getDMMF: (options) => {
136
+ const result = getDmmf.getDMMF(options)
137
+ if ('error' in result) throw new Error(engineMessage(result.error.message), { cause: result.error })
138
+ return result
139
+ },
140
+ mergeSchemas: ({ schemas }) => engine.merge_schemas(JSON.stringify({ schema: schemas })),
141
+ }
142
+ const given = { ...Object.fromEntries(REQUIRES.map((id) => [id, own(id)])), '@prisma/internals': internals, '@prisma/ts-builders': commonjs(own.resolve('@prisma/ts-builders'), { '@prisma/internals': internals }) }
143
+ const run = compiled(main, EXPORTS, PARAMETERS, given)
144
+ const { loadSchemaFiles } = compiled(near.resolve('@prisma/schema-files-loader'), ['loadSchemaFiles'], ['realFsResolver'], { 'node:path': posix })()
145
+ // `peer` is evaluated for each client (a millisecond), its functions given what they read.
146
+ loaded = { peer: (at) => run(readQueryCompiler(at), packageFormat(at), () => {}), internals, loadSchemaFiles }
147
+ return loaded
148
+ }
149
+
150
+ // --- what `prisma generate` reads ---
151
+
152
+ const nearestFile = (host, dir, name) => {
153
+ const at = nearest(dir, holding(host, name))
154
+ return at === null ? null : posix.join(at, name)
155
+ }
156
+
157
+ // The `prisma` Node resolves from `dir`, or where that is unsupported (Prisma 8, say), the one
158
+ // `@prisma/prisma7` runs as `prisma7` beside it.
159
+ function installedPrisma(host, dir) {
160
+ const installed = (from) => {
161
+ const file = nearestFile(host, from, 'node_modules/prisma/package.json')
162
+ return file === null ? null : { file, version: readJson(file, host)?.version }
163
+ }
164
+ const prisma = installed(dir)
165
+ const prisma7 = PRISMA_VERSIONS.includes(prisma?.version) ? null : nearestFile(host, dir, 'node_modules/@prisma/prisma7/package.json')
166
+ const beside = prisma7 && installed(host.realpath(posix.dirname(prisma7)))
167
+ return PRISMA_VERSIONS.includes(beside?.version) ? beside : prisma
168
+ }
169
+
170
+ // @prisma/config's search order; only 7.10.0 reads the first two (prisma7.config.*).
171
+ const CONFIG_BASES = ['prisma7.config', '.config/prisma7', ...['prisma.config', '.config/prisma', '.config/prisma.config'].flatMap((base) => [base, `${base}/index`])]
172
+
173
+ // The modules a config's top-level imports and requires may bind, as `name.export` (`name.*`: the module).
174
+ const MODULES = { path: 'path', 'node:path': 'path', 'prisma/config': 'config', '@prisma/config': 'config' }
175
+
176
+ // The `schema` a Prisma config names, read from its syntax tree: the config is never run, nor
177
+ // anything built from it. A string literal or path.join of them is taken, through defineConfig,
178
+ // `as`, `satisfies`, parentheses and top-level consts; anything else is refused.
179
+ function configSchema(file, text) {
180
+ const parsed = getParser().parseSync(file, text, { sourceType: 'unambiguous' })
181
+ const [error] = syntaxErrors(parsed)
182
+ if (error) throw new Error(`${file}: ${error.message}`)
183
+ const refuse = (what) => { throw new Error(`${file}: ${what}, which stasis reads without running the config`) }
184
+ const consts = new Map()
185
+ const bound = new Map()
186
+ const bind = (from, name, local) => bound.set(local, `${MODULES[from]}.${name}`)
187
+ let config
188
+ for (const node of parsed.program.body) {
189
+ if (node.type === 'ExportDefaultDeclaration') config = node.declaration
190
+ else if (node.type === 'ImportDeclaration') {
191
+ for (const { type, imported, local } of node.specifiers) bind(node.source.value, type === 'ImportSpecifier' ? imported.name ?? imported.value : '*', local.name)
192
+ } else if (node.type === 'VariableDeclaration' && node.kind === 'const') {
193
+ for (const { id, init } of node.declarations) {
194
+ const from = init?.type === 'CallExpression' && init.callee.name === 'require' && init.arguments.length === 1 ? literalSpec(init.arguments[0]) : null
195
+ if (id.type === 'Identifier' && init) consts.set(id.name, init)
196
+ if (from !== null && id.type === 'Identifier') bind(from, '*', id.name)
197
+ if (from !== null && id.type === 'ObjectPattern') for (const { type, computed, key, value } of id.properties) if (type === 'Property' && !computed && value.type === 'Identifier') bind(from, key.name, value.name)
198
+ }
199
+ } else if (node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression') {
200
+ const { left, right } = node.expression
201
+ if (left.type === 'MemberExpression' && left.object.name === 'module' && left.property.name === 'exports') config = right
202
+ }
203
+ }
204
+ if (config === undefined) refuse('no default export')
205
+ // Each const once, against cycles.
206
+ const peel = (node, seen = new Set()) => {
207
+ if (['TSAsExpression', 'TSSatisfiesExpression', 'ParenthesizedExpression'].includes(node.type)) return peel(node.expression, seen)
208
+ return node.type === 'Identifier' && consts.has(node.name) && !seen.has(node.name) ? peel(consts.get(node.name), seen.add(node.name)) : node
209
+ }
210
+ const called = ({ type, callee }) => type === 'CallExpression' && (callee.type === 'MemberExpression' && !callee.computed && bound.get(callee.object.name) === 'path.*' ? `path.${callee.property.name}` : bound.get(callee.name))
211
+ config = peel(config)
212
+ if (called(config) === 'config.defineConfig' && config.arguments.length === 1) config = peel(config.arguments[0])
213
+ if (config.type !== 'ObjectExpression') refuse('its default export is no object literal')
214
+ const spelled = (node) => {
215
+ node = peel(node)
216
+ return literalSpec(node) ?? (called(node) === 'path.join' ? posix.join(...node.arguments.map(spelled)) : refuse('its `schema` is no string literal, nor path.join of them'))
217
+ }
218
+ let schema
219
+ for (const property of config.properties) {
220
+ // A spread or a computed key may name a schema stasis can't see.
221
+ if (property.type !== 'Property' || property.computed) refuse('its config spreads or computes a key')
222
+ if ((property.key.name ?? property.key.value) === 'schema') schema = spelled(property.value)
223
+ }
224
+ return schema
225
+ }
226
+
227
+ // A schema directory is read by Prisma's own loader, over the tree.
228
+ function schemaFilesAt(host, path) {
229
+ if (isFile(path, host)) return [[path, host.readFile(path).toString('utf8')]]
230
+ const view = projectView(host, '/')
231
+ return loadGenerator().loadSchemaFiles(path, {
232
+ listDirContents: async (dir) => view.readdir(dir),
233
+ getEntryType: async (at) => ({ kind: view.lstat(at).type, realPath: host.realpath(at) }),
234
+ getFileContents: async (file) => host.readFile(file).toString('utf8'),
235
+ })
236
+ }
237
+
238
+ // The schema path `prisma generate` takes at `dir`: the config's `schema` (relative to the config),
239
+ // else ./schema.prisma or ./prisma/schema.prisma; null if none.
240
+ function projectSchema(host, dir, version) {
241
+ const first = (names) => names.map((name) => posix.join(dir, name)).find((file) => isFile(file, host))
242
+ const config = first(CONFIG_BASES.slice(newerThan(version, '7.9.1') ? 0 : 2).flatMap((base) => ['.js', '.ts', '.mjs', '.cjs', '.mts', '.cts'].map((ext) => `${base}${ext}`)))
243
+ const named = config && configSchema(config, host.readFile(config).toString('utf8'))
244
+ const path = named === undefined ? first(['schema.prisma', 'prisma/schema.prisma']) : posix.resolve(posix.dirname(config), named)
245
+ if (path === undefined) return null
246
+ if (!isFile(path, host) && !isDir(path, host)) throw new Error(`${config}: its schema ${named} is no file or directory`)
247
+ return path
248
+ }
249
+
250
+ // --- the generator's options, as its generate() parses and infers them, with its own functions ---
251
+
252
+ // The inference reads the nearest tsconfig.json as get-tsconfig 4.10 gives it, defaults included: an
253
+ // ES2015+ target's module, else preserve's bundler resolution.
254
+ const ES_TARGETS = new Set(['es6', 'es2015', 'es2016', 'es2017', 'es2018', 'es2019', 'es2020', 'es2021', 'es2022', 'es2023', 'es2024', 'esnext'])
255
+ function generatorOptions(peer, host, config, outputDir) {
256
+ const file = nearestFile(host, outputDir, 'tsconfig.json')
257
+ const o = file === null ? undefined : loadTsconfigCompilerOptions(file, host)
258
+ if (o?.target && ES_TARGETS.has(o.target.toLowerCase())) o.module ??= 'es6'
259
+ else if (o?.module && o.module.toLowerCase() === 'preserve') o.moduleResolution ??= 'bundler'
260
+ const tsconfig = o && { compilerOptions: o }
261
+ const target = config.runtime === undefined ? 'nodejs' : peer.parseRuntimeTargetFromUnknown(config.runtime)
262
+ const generatedFileExtension = config.generatedFileExtension === undefined ? 'ts' : peer.parseGeneratedFileExtension(config.generatedFileExtension)
263
+ const importFileExtension = config.importFileExtension === undefined ? peer.inferImportFileExtension({ tsconfig, generatedFileExtension, target }) : peer.parseImportFileExtension(config.importFileExtension)
264
+ const moduleFormat = config.moduleFormat === undefined ? peer.inferModuleFormat({ tsconfig, generatedFileExtension, importFileExtension, outputDir }) : peer.parseModuleFormatFromUnknown(config.moduleFormat)
265
+ const compilerBuild = peer.parseCompilerBuildFromUnknown(config.compilerBuild, target)
266
+ return { target, generatedFileExtension, importFileExtension, moduleFormat, compilerBuild }
267
+ }
268
+
269
+ // --- 7.10.0's client as each older version writes it ---
270
+
271
+ // A missing `from` means the client isn't the one these rewrites were written against: refused unless `optional` (null).
272
+ function swap(text, from, to, { path, all, optional }) {
273
+ if (typeof from === 'string' ? text.includes(from) : from.test(text)) return all ? text.replaceAll(from, () => to) : text.replace(from, () => to)
274
+ if (optional) return null
275
+ throw new Error(`prisma: ${path} of the ${GENERATOR_VERSION} client holds no ${JSON.stringify(String(from).slice(0, 80))}`)
276
+ }
277
+
278
+ // File stems, without the generated extension (MODELS: each of models/).
279
+ const CLASS = 'internal/class'
280
+ const NAMESPACE = 'internal/prismaNamespace'
281
+ const MODELS = 'models/*'
282
+
283
+ // 7.10.0 adds the `schema` of each model to the runtime data model.
284
+ function dropModelSchemas(text, path) {
285
+ const match = /^config\.runtimeDataModel = JSON\.parse\((".*")\)$/mu.exec(text)
286
+ if (match === null) throw new Error(`prisma: ${path} of the ${GENERATOR_VERSION} client holds no runtimeDataModel`)
287
+ const model = JSON.parse(JSON.parse(match[1]))
288
+ // Must round-trip, or the rewrite wouldn't match the generator's own serialization.
289
+ if (JSON.stringify(JSON.stringify(model)) !== match[1]) throw new Error(`prisma: ${path}: the runtimeDataModel does not serialize back as written`)
290
+ for (const each of Object.values(model.models)) delete each.schema
291
+ return text.replace(match[0], () => `config.runtimeDataModel = JSON.parse(${JSON.stringify(JSON.stringify(model))})`)
292
+ }
293
+
294
+ // 7.8.0's PrismaClientOptions, which 7.9.0 splits into interfaces.
295
+ const UNION_OPTIONS = `export type PrismaClientOptions = ({
296
+ /**
297
+ * Instance of a Driver Adapter, e.g., like one provided by \`@prisma/adapter-pg\`.
298
+ */
299
+ adapter: runtime.SqlDriverAdapterFactory
300
+ accelerateUrl?: never
301
+ } | {
302
+ /**
303
+ * Prisma Accelerate URL allowing the client to connect through Accelerate instead of a direct database.
304
+ */
305
+ accelerateUrl: string
306
+ adapter?: never
307
+ }) & {`
308
+
309
+ // The constructor example in the docs: with an adapter from 7.4.2, without before.
310
+ const example = (indent) => [`${indent}* const prisma = new PrismaClient({\n${indent}* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })\n${indent}* })\n`, `${indent}* const prisma = new PrismaClient()\n`]
311
+ // Each version's rewrites of the next newer one's client, by file stem; none where only the stamps (STAMPS) differ.
312
+ const DOWN = {
313
+ '7.9.1': { [CLASS]: dropModelSchemas },
314
+ '7.8.0': {
315
+ [CLASS]: (text, path) => {
316
+ text = swap(text, '>(options: Prisma.PrismaClientConstructorArgs<Options>): PrismaClient<', '>(options: Prisma.Subset<Options, Prisma.PrismaClientOptions> ): PrismaClient<', { path })
317
+ return swap(text, " in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = Prisma.PrismaClientOptions['omit'],", " in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = undefined,", { path })
318
+ },
319
+ // 7.9.0's constructor argument type, its PrismaClientOptions split into interfaces, and its XOR.
320
+ [NAMESPACE]: (text, path) => {
321
+ text = swap(text, /\/\*\*\n \* Resolved type of the argument passed to the `PrismaClient` constructor\.\n[^]*?, PrismaClientOptions>;\n\n/u, '', { path })
322
+ text = swap(text, ' ((Without<T, U> & U) | (Without<U, T> & T)) & object\n', ' (Without<T, U> & U) | (Without<U, T> & T)\n', { path })
323
+ text = swap(text, /\/\*\*\n \* Options common to all variants of `PrismaClientOptions`[^]*?\nexport interface PrismaClientBaseOptions \{/u, UNION_OPTIONS, { path })
324
+ return swap(text, /\n\n\/\*\*\n \* `PrismaClient` options for connecting [^]*?\nexport type PrismaClientOptions = PrismaClientOptionsWithAccelerateUrl \| PrismaClientOptionsWithAdapter/u, '', { path })
325
+ },
326
+ },
327
+ '7.7.0': {
328
+ // MongoDB's transactions take no isolation level.
329
+ [CLASS]: (text, path) => swap(text, '[...P], options?: { maxWait?: number, timeout?: number, ', '[...P], options?: { ', { path, optional: true })
330
+ ?? swap(text, '[...P], options?: { maxWait?: number, timeout?: number })', '[...P])', { path }),
331
+ // 7.8.0's query plan cache option.
332
+ [NAMESPACE]: (text, path) => swap(text, / \/\*\*\n \* Optional maximum size for the query plan cache\.[^]*?\n queryPlanCacheMaxSize\?: number\n/u, '', { path }),
333
+ },
334
+ '7.5.0': { [MODELS]: (text) => text.replace(/^export (type Get(\w+)GroupByPayload<T extends \2GroupByArgs> = )/mu, '$1') },
335
+ '7.4.2': {
336
+ // 7.5.0's distinct doc on each model's FindManyArgs, which 7.4.2 has on FindFirst(OrThrow)Args alone.
337
+ [MODELS]: (text) => text.replace(/^(export type \w+FindManyArgs<.* = \{\n(?:(?!\}\n).*\n)*?) \/\*\*\n \* \{@link https:\/\/www\.prisma\.io\/docs\/concepts\/components\/prisma-client\/distinct Distinct Docs\}\n \* \n \* Filter by unique combinations of .*\n \*\/\n(?= distinct\?: )/gmu, '$1'),
338
+ // From 7.5.0, MongoDB's interactive transactions take no nested $transaction.
339
+ [CLASS]: (text) => text.replace('ITXClientDenyList | "$transaction">', 'ITXClientDenyList>'), [NAMESPACE]: (text) => text.replace("ITXClientDenyList | '$transaction'>", 'ITXClientDenyList>'),
340
+ },
341
+ '7.4.1': { client: (text, path) => swap(text, ...example(' '), { path, all: true }), [CLASS]: (text, path) => swap(swap(text, ...example(' '), { path, all: true }), ...example(' '), { path, all: true }) },
342
+ '7.4.0': { [CLASS]: (text, path) => swap(text, 'https://www.prisma.io/docs/orm/prisma-client/queries/transactions', 'https://www.prisma.io/docs/concepts/components/prisma-client/transactions', { path }) },
343
+ }
344
+
345
+ // Where each client stamps its version and engine.
346
+ const STAMPS = [
347
+ [CLASS, (version) => `"clientVersion": "${version}",\n "engineVersion": "${ENGINES[version]}",`],
348
+ [NAMESPACE, (version) => ` * Prisma Client JS version: ${version}\n * Query Engine version: ${ENGINES[version]}\n`],
349
+ [NAMESPACE, (version) => ` client: "${version}",\n engine: "${ENGINES[version]}"\n`],
350
+ ]
351
+
352
+ // A 7.10.0 client file as `version` writes it.
353
+ function asVersion(text, path, version, extension) {
354
+ if (typeof text !== 'string') return text
355
+ const key = path.startsWith('models/') ? MODELS : path.slice(0, -extension.length - 1)
356
+ for (const each of PRISMA_VERSIONS.slice(1, PRISMA_VERSIONS.indexOf(version) + 1)) text = DOWN[each]?.[key]?.(text, path) ?? text
357
+ for (const [where, stamp] of STAMPS) if (key === where) text = swap(text, stamp(GENERATOR_VERSION), stamp(version), { path })
358
+ return text
359
+ }
360
+
361
+ // --- generating ---
362
+
363
+ // One generator's client: path in the output -> text (bytes for the query compiler's .wasm).
364
+ function generateClientFor({ schemaPath, datamodel, generator, dmmf, datasources, outputDir, prisma, host }) {
365
+ const client = { host, version: prisma.version, prismaDir: posix.dirname(prisma.file) }
366
+ const peer = loadGenerator().peer(client)
367
+ const options = generatorOptions(peer, host, generator.config, outputDir)
368
+ client.target = options.target
369
+ const built = peer.buildClient({
370
+ datamodel, schemaPath, runtimeBase: '@prisma/client/runtime', outputDir, generator, dmmf, datasources, binaryPaths: {},
371
+ engineVersion: ENGINES[GENERATOR_VERSION], clientVersion: GENERATOR_VERSION, activeProvider: datasources[0]?.activeProvider, tsNoCheckPreamble: true, ...options,
372
+ })
373
+ const denied = peer.validateDmmfAgainstDenylists(built.prismaClientDmmf)
374
+ if (denied) throw new Error(`prisma: ${schemaPath} contains reserved keywords, to rename: ${denied.map((error) => error.message).join(', ')}`)
375
+ // fileMap nests directories as objects.
376
+ const flat = (map, at) => Object.entries(map).flatMap(([name, content]) => (typeof content === 'string' || Buffer.isBuffer(content) ? [[at + name, content]] : flat(content, `${at}${name}/`)))
377
+ return new Map(flat(built.fileMap, '').map(([path, content]) => [path, asVersion(content, path, prisma.version, options.generatedFileExtension)]))
378
+ }
379
+
380
+ const readingSchema = (path, what) => Promise.try(what).catch((cause) => { throw new Error(`prisma: ${path}: ${cause.message}`, { cause }) })
381
+
382
+ // Each `{ dir, files }` output. Projects without a schema or a `prisma-client` generator are skipped
383
+ // quietly; ones stasis can't generate for with a warning, the scan then reporting what is missing.
384
+ /* eslint-disable no-await-in-loop -- one project at a time, in the order its warnings read */
385
+ export async function generatePrismaClients({ host, root, projects }) {
386
+ const outputs = []
387
+ const skip = (what, why) => { console.warn(`[stasis] prisma: not generating ${what}: ${why}`) }
388
+ for (const project of [...projects].toSorted()) {
389
+ const dir = posix.join(root, project)
390
+ const prisma = installedPrisma(host, dir)
391
+ const supported = PRISMA_VERSIONS.includes(prisma?.version)
392
+ const path = await Promise.try(projectSchema, host, dir, supported ? prisma.version : GENERATOR_VERSION).catch((error) => skip(`for ${dir}`, error.message))
393
+ // Checked before parsing: another Prisma's schema needn't satisfy 7.10.0.
394
+ if (path && !supported) skip(`for ${path}`, `stasis generates as Prisma ${PRISMA_VERSIONS.at(-1)} to ${PRISMA_VERSIONS[0]} do, and ${prisma === null ? 'none is installed' : `${prisma.file} is ${prisma.version}`}`)
395
+ if (!path || !supported) continue
396
+ const files = await readingSchema(path, () => schemaFilesAt(host, path))
397
+ const { internals } = loadGenerator()
398
+ const config = await readingSchema(path, () => internals.getConfig({ datamodel: files }))
399
+ const generators = config.generators.filter((generator) => generator.provider.value === PROVIDER && generator.provider.fromEnvVar === null)
400
+ for (const other of config.generators.filter((generator) => !generators.includes(generator))) skip(`${path}'s generator ${other.name}`, `stasis generates the ${JSON.stringify(PROVIDER)} provider's alone`)
401
+ if (generators.length === 0) continue
402
+ for (const warning of config.warnings) console.warn(`[stasis] prisma: ${warning}`)
403
+ if (config.datasources.length === 0) throw new Error(`prisma: ${path} defines no datasource`)
404
+ const dmmf = await readingSchema(path, () => internals.getDMMF({ datamodel: files }))
405
+ const datamodel = internals.mergeSchemas({ schemas: files })
406
+ for (const generator of generators) {
407
+ if (generator.output === null) throw new Error(`prisma: ${path}: generator ${generator.name} names no output`)
408
+ if (generator.output.fromEnvVar !== null) {
409
+ skip(`${path}'s generator ${generator.name}`, `its output is env("${generator.output.fromEnvVar}"), which stasis doesn't read`)
410
+ continue
411
+ }
412
+ const outputDir = posix.resolve(posix.dirname(generator.sourceFilePath ?? path), generator.output.value)
413
+ if (hasNodeModulesSegment(outputDir)) throw new Error(`prisma: ${path}: generator ${generator.name}'s output ${outputDir} is in node_modules, which is laid out from the lockfile alone`)
414
+ if (outputs.some((output) => output.dir === outputDir)) throw new Error(`prisma: ${outputDir} is the output of more than one generator`)
415
+ outputs.push({ dir: outputDir, files: generateClientFor({ schemaPath: path, datamodel, generator, dmmf, datasources: config.datasources, outputDir, prisma, host }) })
416
+ }
417
+ }
418
+ return outputs
419
+ }
420
+
421
+ // What deleteOutputDir clears before writing (its globs skip dot paths), in an output that must
422
+ // already look like a client.
423
+ const isStale = (path) => /(?:\.(?:js|ts|mts|cts|wasm|prisma)$|^package\.json$|^[^/]*\.node$|^(?:query|schema)-engine-[^/]*$)/u.test(path) && !path.split('/').some((name) => name.startsWith('.'))
424
+ const CLIENT_FILES = ['client.ts', 'client.mts', 'client.cts', 'client.d.ts']
425
+
426
+ // A copy of `vfs` with the outputs written over any earlier client, as `prisma generate` does.
427
+ export function withPrismaClients(vfs, outputs) {
428
+ const out = createVfs()
429
+ out.mount(vfs, '/')
430
+ for (const { dir, files } of outputs) {
431
+ if (out.isDirectory(dir)) {
432
+ const names = out.readdir(dir)
433
+ if (names.length > 0 && !CLIENT_FILES.some((name) => names.includes(name))) throw new Error(`prisma: ${dir} exists and is not empty but doesn't look like a generated Prisma Client`)
434
+ for (const { type, path } of out.walk(dir).toArray()) if (type === 'file' && isStale(posix.relative(dir, path))) out.unlink(path)
435
+ }
436
+ out.mount(createVfs(Object.fromEntries([...files].map(([path, text]) => [posix.join(dir, path), text]))), '/', { clash: 'replace' })
437
+ }
438
+ return out
439
+ }
@@ -0,0 +1,125 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { isTomlTable, readToml } from '../loaders/toml.js'
4
+
5
+ // What `soldeer install` (0.12.0) writes beside the dependencies folder after installing it
6
+ // (soldeer-core's update_config_libs and edit_remappings with RemappingsAction::Update): the
7
+ // remappings.txt it would write is written into the tree, which then serves it in place of the
8
+ // project's; a foundry.toml it would edit is refused, as is what this can't tell it would leave as
9
+ // it is. `view` is the project's root by paths from `/`, `root` its real path, for messages.
10
+
11
+ const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u
12
+ // A requirement Soldeer takes as exact: a bare version (its `^` made `=`), or one written `=`.
13
+ const EXACT = /^=?\s*(\S+)$/u
14
+ const withoutBuild = (version) => version.replace(/\+.*$/u, '')
15
+
16
+ // A file's text as Soldeer reads it (a byte order mark kept), or undefined.
17
+ function textOf(view, path) {
18
+ try {
19
+ return view.stat(path).type === 'file' ? view.readFile(path).toString('utf8') : undefined
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ // Rust's `Path::components` of a remapping's target, `.` kept only at the start.
26
+ function components(path) {
27
+ const out = path.startsWith('/') ? ['/'] : []
28
+ for (const [i, part] of path.split('/').entries()) {
29
+ if (part !== '' && (part !== '.' || i === 0)) out.push(part)
30
+ }
31
+ return out
32
+ }
33
+
34
+ // Whether the folder `name` of a remapping's target (its dependencies folder and the one in it) is
35
+ // `dep`'s, as soldeer's path_matches tells, or null where only Rust's semver could tell.
36
+ function folderMatches(dep, name) {
37
+ if (!name.startsWith(`${dep.name}-`)) return false
38
+ const version = name.slice(dep.name.length + 1)
39
+ if (version === dep.version) return true
40
+ if (!SEMVER.test(version)) return version === dep.req
41
+ const exact = EXACT.exec(dep.req.trim())?.[1]
42
+ if (exact !== undefined && SEMVER.test(exact)) return withoutBuild(exact) === withoutBuild(version)
43
+ return null
44
+ }
45
+
46
+ // The remappings Soldeer's update leaves, of `existing` (`[name, target]` pairs) and the config's
47
+ // dependencies, sorted as it sorts them (by their UTF-8 bytes).
48
+ function updatedRemappings(existing, deps, { regenerate, where }) {
49
+ const items = deps.map((dep) => ({ dep, line: `${dep.remapped}=dependencies/${dep.folder}/` }))
50
+ let lines
51
+ if (regenerate || existing.length === 0) {
52
+ lines = items.map(({ line }) => line)
53
+ } else {
54
+ lines = []
55
+ let rest = existing
56
+ for (const { dep, line } of items) {
57
+ let found = false
58
+ rest = rest.filter(([name, target]) => {
59
+ const path = components(target).slice(0, 2)
60
+ const folder = path.at(-1)
61
+ const matches = folder === undefined || ['/', '.', '..'].includes(folder) ? false : folderMatches(dep, folder)
62
+ if (matches === null) throw new Error(`${where}: "${name}=${target}" names ${folder}, which \`soldeer install\` may rewrite to dependencies/${dep.folder}`)
63
+ if (!matches) return true
64
+ lines.push(`${name}=${target.replaceAll(path.join('/').replace(/^\/\//u, '/'), `dependencies/${dep.folder}`)}`)
65
+ found = true
66
+ return false
67
+ })
68
+ if (!found) lines.push(line)
69
+ }
70
+ lines.push(...rest.map(([name, target]) => `${name}=${target}`))
71
+ }
72
+ return lines.toSorted((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
73
+ }
74
+
75
+ // `[name, target]` of each remapping in `lines`, as Soldeer reads them: split at the first `=`, any
76
+ // line without one passed over.
77
+ const pairs = (lines) => lines.flatMap((line) => {
78
+ const at = line.indexOf('=')
79
+ return at === -1 ? [] : [[line.slice(0, at), line.slice(at + 1)]]
80
+ })
81
+
82
+ export function settleSoldeer(view, vfs, root) {
83
+ const foundryText = textOf(view, '/foundry.toml')
84
+ const foundry = foundryText === undefined ? undefined : readToml(foundryText)
85
+ const configFile = isTomlTable(foundry?.dependencies) ? 'foundry.toml' : 'soldeer.toml'
86
+ const config = configFile === 'foundry.toml' ? foundry : readToml(textOf(view, '/soldeer.toml') ?? '')
87
+ const where = (file) => join(root, file)
88
+
89
+ if (configFile === 'foundry.toml') {
90
+ const libs = foundry.profile?.default?.libs
91
+ if (!Array.isArray(libs) || !libs.includes('dependencies')) {
92
+ throw new Error(`${where('foundry.toml')}: [profile.default] libs holds no "dependencies", which \`soldeer install\` adds`)
93
+ }
94
+ }
95
+
96
+ const settings = { remappings_generate: true, remappings_regenerate: false, remappings_version: true, remappings_prefix: '', remappings_location: 'txt', ...config.soldeer }
97
+ if (!settings.remappings_generate) return
98
+ const locked = new Map(readToml(textOf(view, '/soldeer.lock') ?? '').dependencies?.map((entry) => [entry.name, entry.version]) ?? [])
99
+ const deps = Object.entries(config.dependencies ?? {}).map(([name, value]) => {
100
+ const req = typeof value === 'string' ? value : value.version
101
+ const version = locked.get(name)
102
+ const remapped = `${settings.remappings_prefix}${name}${settings.remappings_version ? `-${req.replaceAll('=', '')}` : ''}/`
103
+ return { name, req, version, folder: `${name}-${version}`, remapped }
104
+ })
105
+
106
+ const remappingsTxt = textOf(view, '/remappings.txt')
107
+ if (configFile === 'foundry.toml' && settings.remappings_location === 'config') {
108
+ if (remappingsTxt !== undefined) throw new Error(`${where('remappings.txt')}: \`soldeer install\` removes it, as remappings_location is "config"`)
109
+ for (const [name, profile] of Object.entries(isTomlTable(foundry.profile) ? foundry.profile : {})) {
110
+ const current = Array.isArray(profile?.remappings) ? profile.remappings : undefined
111
+ if (current === undefined && name !== 'default') continue
112
+ const updated = updatedRemappings(pairs((current ?? []).filter((r) => typeof r === 'string')), deps, { regenerate: settings.remappings_regenerate, where: where('foundry.toml') })
113
+ if (current === undefined || updated.length !== current.length || updated.some((r, i) => r !== current[i])) {
114
+ throw new Error(`${where('foundry.toml')}: [profile.${name}] remappings are not as \`soldeer install\` leaves them`)
115
+ }
116
+ }
117
+ return
118
+ }
119
+
120
+ const lines = (settings.remappings_regenerate ? '' : remappingsTxt ?? '').split('\n')
121
+ if (lines.at(-1) === '') lines.pop()
122
+ const existing = pairs(lines.map((line) => line.replace(/\r$/u, '')))
123
+ const text = updatedRemappings(existing, deps, { regenerate: settings.remappings_regenerate, where: where('remappings.txt') }).map((line) => `${line}\n`).join('')
124
+ if (text !== remappingsTxt) vfs.writeFile('/remappings.txt', text, { mode: remappingsTxt === undefined ? 0o644 : view.lstat('/remappings.txt').mode })
125
+ }