import {assert} from '@augment-vir/assert'; import { camelCaseToKebabCase, collapseWhiteSpace, log, randomString, type PartialWithUndefined, } from '@augment-vir/common'; import {getNowInUtcTimezone} from 'date-vir'; import {existsSync} from 'node:fs'; import {mkdir, readdir, rm, writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {dirname, join} from 'node:path'; import {getDefaultMigrationsDirPath, getDefaultPrismaConfigPath} from '../util/default-paths.js'; import {resolvePrismaConfigPaths} from '../util/prisma-config.js'; import { diffMigrationSql, migrationLockFileName, readMigrationList, readSchemaContainers, } from './schema-engine.js'; /** * Params for {@link createPgliteMigration} * * @category Internal */ export type PgliteMigrationParams = PartialWithUndefined<{ /** * Path to a Prisma config file (`prisma.config.ts`). Prisma v7 reads the schema location, * datasource, and (if set) the `migrations.path` from this config. The migrations directory is * derived from it: the config's `migrations.path` if set, otherwise the `migrations` folder * next to the schema. * * @default join(process.cwd(), 'prisma.config.ts') */ prismaConfigPath: string; /** * Enable logging. * * @default false */ enableLogs: boolean; }> & { /** The name of the new migration, if one is needed. */ migrationName: string; }; /** * Output from {@link createPgliteMigration}. * * @category Internal */ export type PgliteMigration = { migrationName: string; migrationDirPath: string; }; /** * Contents of the migration lock file as required by this plugin (specifically, using postgres). * * @category Internal */ export const migrationLockFileContents = [ '# Please do not edit this file manually', '# It should be added in your version-control system (e.g., Git)', 'provider = "postgresql"', ].join('\n'); function migrationTimestamp(): string { const now = getNowInUtcTimezone(); return [ now.year, now.month, now.day, now.hour, now.minute, now.second, ] .map((entry) => String(entry).padStart(2, '0')) .join(''); } /** * Create a dev migration using Prisma with a PGlite database. This is analogous to running `prisma * migrate dev` with a plain Postgres database. * * The new migration is computed by replaying the existing migration history into a throwaway PGlite * database (the "shadow" database) and diffing it against your schema. No snapshot files are needed * and the Prisma CLI is not invoked. * * @category CLI * @returns {@link PgliteMigration} If the migration was created, otherwise `undefined`. */ export async function createPgliteMigration( params: Readonly, ): Promise { const prismaConfigPath = params.prismaConfigPath || getDefaultPrismaConfigPath(); const {schemaPath, migrationsDirPath: configMigrationsDirPath} = await resolvePrismaConfigPaths(prismaConfigPath); const migrationsDirPath = configMigrationsDirPath || getDefaultMigrationsDirPath(schemaPath); const migrationName = sanitizeMigrationName(params.migrationName); assert.isTrue(existsSync(schemaPath), `Prisma schema does not exist: '${schemaPath}'.`); const [ schemaContainers, existingMigrations, ] = await Promise.all([ readSchemaContainers(schemaPath), readMigrationList(migrationsDirPath), ]); /* node:coverage ignore next 1: dynamic imports are not a branch */ const {PGlite} = await import('@electric-sql/pglite'); const shadowDirPath = join(tmpdir(), `prisma-pglite-shadow-${randomString()}`); const shadowPglite = new PGlite(shadowDirPath); await shadowPglite.waitReady; process.exitCode = undefined; const migrationSql = await diffMigrationSql({ shadowPglite, existingMigrations, schemaContainers, schemaConfigDir: dirname(schemaPath), }).finally(async () => { await shadowPglite.close(); await rm(shadowDirPath, { force: true, recursive: true, }); process.exitCode = undefined; }); if (!migrationSql) { log.warning('No changes detected.'); return undefined; } const migrationDirName = [ migrationTimestamp(), migrationName, ].join('_'); const newMigrationDirPath = join(migrationsDirPath, migrationDirName); await mkdir(newMigrationDirPath, { recursive: true, }); await writeFile(join(newMigrationDirPath, 'migration.sql'), migrationSql); const migrationLockFilePath = join(migrationsDirPath, migrationLockFileName); if (!existsSync(migrationLockFilePath)) { await writeFile(migrationLockFilePath, migrationLockFileContents); } log.success(`Migration created: ${migrationDirName}`); return { migrationDirPath: newMigrationDirPath, migrationName: migrationDirName, }; } /** * Sanitize a user provided migration name so it fits Prisma's naming scheme conventions / * requirements. * * @category Internal */ export function sanitizeMigrationName(originalName: string): string { return camelCaseToKebabCase(collapseWhiteSpace(originalName).replaceAll(' ', '-')) .replaceAll('-', '_') .toLowerCase() .replaceAll(/_{2,}/g, '_') .replaceAll(/^_|_$/g, ''); } /** * Finds the latest Prisma migration folder within the given migrations folder. * * @category Internal */ export async function findLatestMigrationPath( migrationsDirPath: string, ): Promise { if (!existsSync(migrationsDirPath)) { return undefined; } const migrationDirs = ( await readdir(migrationsDirPath, { withFileTypes: true, }) ).filter((entry) => entry.isDirectory()); const latestMigrationDir = migrationDirs.toSorted((a, b) => b.name.localeCompare(a.name))[0]; if (!latestMigrationDir) { return undefined; } return join(migrationsDirPath, latestMigrationDir.name); } export {migrationLockFileName} from './schema-engine.js';