Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated keystone-next dev so that it interactively prompts for creating and applying a migration #5135

Merged
merged 18 commits into from
Mar 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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