Skip to content

Commit

Permalink
Updated keystone-next dev so that it interactively prompts for creati…
Browse files Browse the repository at this point in the history
…ng and applying a migration (#5135)
  • Loading branch information
emmatown authored Mar 17, 2021
1 parent 3a9d20c commit cdd889d
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 10 deletions.
6 changes: 6 additions & 0 deletions .changeset/odd-eagles-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@keystone-next/adapter-prisma-legacy': major
'@keystone-next/keystone': major
---

Updated `keystone-next dev` with the Prisma adapter so that it interactively prompts for creating and applying a migration
5 changes: 5 additions & 0 deletions .changeset/tame-ducks-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/adapter-prisma-legacy': major
---

Changed default migrationMode from `dev` to `prototype`
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid",
"overrides": [{
"files": "docs-next/**",
"options": {
"embeddedLanguageFormatting": "off"
"overrides": [
{
"files": "docs-next/**",
"options": {
"embeddedLanguageFormatting": "off"
}
}
}]
]
},
"remarkConfig": {
"settings": {
Expand Down
6 changes: 5 additions & 1 deletion packages/adapter-prisma/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
"@prisma/client": "2.18.0",
"@prisma/migrate": "2.18.0",
"@prisma/sdk": "2.18.0",
"@sindresorhus/slugify": "^1.1.0",
"@types/prompts": "^2.0.9",
"chalk": "^4.1.0",
"cuid": "^2.1.8",
"prisma": "2.18.0"
"prisma": "2.18.0",
"prompts": "^2.4.0"
},
"repository": "https://github.com/keystonejs/keystone/tree/master/packages/adapter-prisma"
}
6 changes: 3 additions & 3 deletions packages/adapter-prisma/src/adapter-prisma.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@keystone-next/keystone-legacy';
import { defaultObj, mapKeys, identity, flatten } from '@keystone-next/utils-legacy';
// eslint-disable-next-line import/no-unresolved
import { runPrototypeMigrations } from './migrations';
import { runPrototypeMigrations, devMigrations } from './migrations';

class PrismaAdapter extends BaseKeystoneAdapter {
constructor(config = {}) {
Expand All @@ -20,7 +20,7 @@ class PrismaAdapter extends BaseKeystoneAdapter {
this.listAdapterClass = PrismaListAdapter;
this.name = 'prisma';
this.provider = this.config.provider || 'postgresql';
this.migrationMode = this.config.migrationMode || 'dev';
this.migrationMode = this.config.migrationMode || 'prototype';

this.getPrismaPath = this.config.getPrismaPath || (() => '.prisma');
this.getDbSchemaName = this.config.getDbSchemaName || (() => 'public');
Expand Down Expand Up @@ -112,7 +112,7 @@ class PrismaAdapter extends BaseKeystoneAdapter {
this._runPrismaCmd(`migrate dev --create-only --name keystone-${cuid()} --preview-feature`);
} else if (this.migrationMode === 'dev') {
// Generate and apply a migration if required.
this._runPrismaCmd(`migrate dev --name keystone-${cuid()} --preview-feature`);
await devMigrations(this._url(), prismaSchema, path.resolve(this.schemaPath));
} else if (this.migrationMode === 'none') {
// Explicitly disable running any migrations
} else {
Expand Down
144 changes: 144 additions & 0 deletions packages/adapter-prisma/src/migrations.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import path from 'path';
import { createDatabase, uriToCredentials, DatabaseCredentials } from '@prisma/sdk';
import { Migrate } from '@prisma/migrate';
import chalk from 'chalk';
import slugify from '@sindresorhus/slugify';
import { confirmPrompt, textPrompt } from './prompts';

// we don't want to pollute process.env.DATABASE_URL so we're
// setting the env variable _just_ long enough for Migrate to
// read it and then we reset it immediately after.
// Migrate reads the env variables a single time when it starts the child process that it talks to

// note that we could only run this once per Migrate instance but we're going to do it consistently for all migrate calls
// so that calls can moved around freely without implictly relying on some other migrate command being called before it
function runMigrateWithDbUrl<T>(dbUrl: string, cb: () => T): T {
let prevDBURLFromEnv = process.env.DATABASE_URL;
try {
Expand Down Expand Up @@ -41,6 +47,144 @@ export async function runPrototypeMigrations(dbUrl: string, schema: string, sche
}
}

// TODO: don't have process.exit calls here
export async function devMigrations(dbUrl: string, prismaSchema: string, schemaPath: string) {
await ensureDatabaseExists(dbUrl, path.dirname(schemaPath));
let migrate = new Migrate(schemaPath);
try {
// see if we need to reset the database
// note that the other action devDiagnostic can return is createMigration
// that doesn't necessarily mean that we need to create a migration
// it only means that we don't need to reset the database
const devDiagnostic = await runMigrateWithDbUrl(dbUrl, () => migrate.devDiagnostic());
// when the action is reset, the database is somehow inconsistent with the migrations so we need to reset it
// (not just some migrations need to be applied but there's some inconsistency)
if (devDiagnostic.action.tag === 'reset') {
const credentials = uriToCredentials(dbUrl);
console.log(`${devDiagnostic.action.reason}
We need to reset the ${credentials.type} database "${credentials.database}" at ${getDbLocation(
credentials
)}.`);
const confirmedReset = await confirmPrompt(
`Do you want to continue? ${chalk.red('All data will be lost')}.`
);
console.info(); // empty line

if (!confirmedReset) {
console.info('Reset cancelled.');
process.exit(0);
}

// Do the reset
await migrate.reset();
}

let { appliedMigrationNames } = await runMigrateWithDbUrl(dbUrl, () =>
migrate.applyMigrations()
);
// Inform user about applied migrations now
if (appliedMigrationNames.length) {
console.info(
`✨ The following migration(s) have been applied:\n\n${printFilesFromMigrationIds(
appliedMigrationNames
)}`
);
}
// evaluateDataLoss basically means "try to create a migration but don't write it"
// so we can tell the user whether it can be executed and if there will be data loss
const evaluateDataLossResult = await runMigrateWithDbUrl(dbUrl, () =>
migrate.evaluateDataLoss()
);
// if there are no steps, there was no change to the prisma schema so we don't need to create a migration
if (evaluateDataLossResult.migrationSteps.length) {
console.log('✨ There has been a change to your Keystone schema that requires a migration');
let migrationCanBeApplied = !evaluateDataLossResult.unexecutableSteps.length;
// see the link below for what "unexecutable steps" are
// https://github.com/prisma/prisma-engines/blob/c65d20050f139a7917ef2efc47a977338070ea61/migration-engine/connectors/sql-migration-connector/src/sql_destructive_change_checker/unexecutable_step_check.rs
// the tl;dr is "making things non null when there are nulls in the db"
if (!migrationCanBeApplied) {
console.log(`${chalk.bold.red('\n⚠️ We found changes that cannot be executed:\n')}`);
for (const item of evaluateDataLossResult.unexecutableSteps) {
console.log(` • Step ${item.stepIndex} ${item.message}`);
}
}
// warnings mean "if the migration was applied to the database you're connected to, you will lose x data"
// note that if you have a field where all of the values are null on your local db and you've removed it, you won't get a warning here.
// there will be a warning in a comment in the generated migration though.
if (evaluateDataLossResult.warnings.length) {
console.log(chalk.bold(`\n⚠️ Warnings:\n`));
for (const warning of evaluateDataLossResult.warnings) {
console.log(` • ${warning.message}`);
}
}

console.log(); // for an empty line

let migrationName = await getMigrationName();

// note this only creates the migration, it does not apply it
let { generatedMigrationName } = await runMigrateWithDbUrl(dbUrl, () =>
migrate.createMigration({
migrationsDirectoryPath: migrate.migrationsDirectoryPath,
// https://github.com/prisma/prisma-engines/blob/11dfcc85d7f9b55235e31630cd87da7da3aed8cc/migration-engine/core/src/commands/create_migration.rs#L16-L17
// draft means "create an empty migration even if there are no changes rather than exiting"
// because this whole thing only happens when there are changes to the schema, this can be false
// (we should also ofc have a way to create an empty migration but that's a separate thing)
draft: false,
prismaSchema,
migrationName,
})
);

console.log(
`✨ A migration has been created at .keystone/prisma/migrations/${generatedMigrationName}`
);

let shouldApplyMigration =
migrationCanBeApplied && (await confirmPrompt('Would you like to apply this migration?'));
if (shouldApplyMigration) {
await runMigrateWithDbUrl(dbUrl, () => migrate.applyMigrations());
console.log('✅ The migration has been applied');
} else {
console.log(
'Please edit the migration and run keystone-next dev again to apply the migration'
);
process.exit(0);
}
} else {
if (appliedMigrationNames.length) {
console.log('✨ Your migrations are up to date, no new migrations need to be created');
} else {
console.log('✨ Your database is up to date, no migrations need to be created or applied');
}
}
} finally {
migrate.stop();
}
}

// based on https://github.com/prisma/prisma/blob/3fed5919545bfae0a82d35134a4f1d21359118cb/src/packages/migrate/src/utils/promptForMigrationName.ts
const MAX_MIGRATION_NAME_LENGTH = 200;
async function getMigrationName() {
let migrationName = await textPrompt('Name of migration');
return slugify(migrationName, { separator: '_' }).substring(0, MAX_MIGRATION_NAME_LENGTH);
}

function printFilesFromMigrationIds(migrationIds: string[]) {
return `.keystone/prisma/migrations/\n${migrationIds
.map(migrationId => ` └─ ${printMigrationId(migrationId)}/\n └─ migration.sql`)
.join('\n')}`;
}

function printMigrationId(migrationId: string): string {
const words = migrationId.split('_');
if (words.length === 1) {
return chalk.cyan.bold(migrationId);
}
return `${words[0]}_${chalk.cyan.bold(words.slice(1).join('_'))}`;
}

async function ensureDatabaseExists(dbUrl: string, schemaDir: string) {
// createDatabase will return false when the database already exists
const result = await createDatabase(dbUrl, schemaDir);
Expand Down
29 changes: 29 additions & 0 deletions packages/adapter-prisma/src/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import prompts from 'prompts';

// prompts is badly typed so we have some more specific typed APIs
// prompts also returns an undefined value on SIGINT which we really just want to exit on

export async function confirmPrompt(message: string): Promise<boolean> {
const { value } = await prompts({
name: 'value',
type: 'confirm',
message,
initial: true,
});
if (value === undefined) {
process.exit(1);
}
return value;
}

export async function textPrompt(message: string): Promise<string> {
const { value } = await prompts({
name: 'value',
type: 'text',
message,
});
if (value === undefined) {
process.exit(1);
}
return value;
}
9 changes: 8 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3529,6 +3529,13 @@
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.2.tgz#e2280c89ddcbeef340099d6968d8c86ba155fdf6"
integrity sha512-i99hy7Ki19EqVOl77WplDrvgNugHnsSjECVR/wUrzw2TJXz1zlUfT2ngGckR6xN7yFYaijsMAqPkOLx9HgUqHg==

"@types/prompts@^2.0.9":
version "2.0.9"
resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.0.9.tgz#19f419310eaa224a520476b19d4183f6a2b3bd8f"
integrity sha512-TORZP+FSjTYMWwKadftmqEn6bziN5RnfygehByGsjxoK5ydnClddtv6GikGWPvCm24oI+YBwck5WDxIIyNxUrA==
dependencies:
"@types/node" "*"

"@types/prop-types@*":
version "15.7.3"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
Expand Down Expand Up @@ -12834,7 +12841,7 @@ promise-inflight@^1.0.1:
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=

prompts@^2.0.1, prompts@^2.3.2:
prompts@^2.0.1, prompts@^2.3.2, prompts@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.0.tgz#4aa5de0723a231d1ee9121c40fdf663df73f61d7"
integrity sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ==
Expand Down

1 comment on commit cdd889d

@vercel
Copy link

@vercel vercel bot commented on cdd889d Mar 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.