Skip to content

Commit

Permalink
feat(core, medusa, cli): Enable migration scripts (medusajs#10960)
Browse files Browse the repository at this point in the history
* feat(core, medusa): Enable migration scripts

* spacing

* rm unnecessary import

* Allow to skip script migration

* fix missing options

* add options

* add tests and small changes

* update

* add checks

* add lock mechanism to be extra safe

* Create six-bears-vanish.md

* update queries

* fix tests

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
  • Loading branch information
2 people authored and jimrarras committed Jan 28, 2025
1 parent b4841ff commit 7a22adf
Show file tree
Hide file tree
Showing 11 changed files with 589 additions and 1 deletion.
7 changes: 7 additions & 0 deletions .changeset/six-bears-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@medusajs/medusa": patch
"@medusajs/framework": patch
"@medusajs/cli": patch
---

feat(core, medusa, cli): Enable migration scripts
4 changes: 4 additions & 0 deletions packages/cli/medusa-cli/src/create-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ function buildLocalCommands(cli, isLocalProject) {
command: "db:migrate",
desc: "Migrate the database by executing pending migrations",
builder: (builder) => {
builder.option("skip-scripts", {
type: "boolean",
describe: "Do not run migration scripts",
})
builder.option("skip-links", {
type: "boolean",
describe: "Do not sync links",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"./orchestration": "./dist/orchestration/index.js",
"./workflows-sdk": "./dist/workflows-sdk/index.js",
"./workflows-sdk/composer": "./dist/workflows-sdk/composer.js",
"./modules-sdk": "./dist/modules-sdk/index.js"
"./modules-sdk": "./dist/modules-sdk/index.js",
"./migrations": "./dist/migrations/index.js"
},
"engines": {
"node": ">=20"
Expand Down
1 change: 1 addition & 0 deletions packages/core/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from "./subscribers"
export * from "./workflows"
export * from "./telemetry"
export * from "./zod"
export * from "./migrations"

export const MEDUSA_CLI_PATH = require.resolve("@medusajs/cli")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { MedusaContainer } from "@medusajs/types"
import { MigrationScriptsMigrator } from "../run-migration-scripts"
import { jest } from "@jest/globals"
import path from "path"
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"

const mockPgConnection = {
raw: jest.fn(),
}

const mockLockService = {
acquire: jest.fn(),
release: jest.fn(),
}

const mockContainer = {
resolve: (key: string) => {
if (key === ContainerRegistrationKeys.PG_CONNECTION) {
return mockPgConnection
}
if (key === Modules.LOCKING) {
return mockLockService
}

throw new Error(`Unknown key: ${key}`)
},
} as unknown as MedusaContainer

describe("MigrationScriptsMigrator", () => {
let migrator: MigrationScriptsMigrator

beforeEach(() => {
jest.clearAllMocks()
migrator = new MigrationScriptsMigrator({ container: mockContainer })
// @ts-ignore
migrator.pgConnection = mockPgConnection
})

describe("run", () => {
it("should successfully run migration scripts", async () => {
const mockScript = jest.fn()
const scriptPath = "/path/to/migration.ts"

jest
.spyOn(migrator as any, "getPendingMigrations")
.mockResolvedValue([scriptPath])
jest
.spyOn(migrator as any, "insertMigration")
.mockResolvedValue(undefined)
jest
.spyOn(migrator as any, "trackDuration")
.mockReturnValue({ getSeconds: () => 1 })

jest.mock(
scriptPath,
() => ({
default: mockScript,
}),
{ virtual: true }
)

await migrator.run([scriptPath])

expect(mockScript).toHaveBeenCalled()

expect(mockPgConnection.raw).toHaveBeenCalledWith(
expect.stringContaining("UPDATE script_migrations"),
[path.basename(scriptPath)]
)
expect(migrator["insertMigration"]).toHaveBeenCalledWith([
{ script_name: `'${path.basename(scriptPath)}'` },
])
})

it("should handle failed migrations by cleaning up", async () => {
const scriptPath = "/path/to/failing-migration.ts"
const error = new Error("Migration failed")

jest
.spyOn(migrator as any, "getPendingMigrations")
.mockResolvedValue([scriptPath])
jest
.spyOn(migrator as any, "insertMigration")
.mockResolvedValue(undefined)
jest
.spyOn(migrator as any, "trackDuration")
.mockReturnValue({ getSeconds: () => 1 })

const mockFailingScript = jest.fn().mockRejectedValue(error as never)
jest.mock(
scriptPath,
() => ({
default: mockFailingScript,
}),
{ virtual: true }
)

await expect(migrator.run([scriptPath])).rejects.toThrow(
"Migration failed"
)

expect(mockPgConnection.raw).toHaveBeenCalledWith(
expect.stringContaining("DELETE FROM script_migrations"),
[path.basename(scriptPath)]
)
})

it("should skip migration when unique constraint error occurs", async () => {
const scriptPath = "/path/to/migration.ts"
const uniqueError = new Error("Unique constraint violation")
;(uniqueError as any).constraint = "idx_script_name_unique"

jest
.spyOn(migrator as any, "getPendingMigrations")
.mockResolvedValue([scriptPath])
jest
.spyOn(migrator as any, "insertMigration")
.mockRejectedValue(uniqueError)
jest
.spyOn(migrator as any, "trackDuration")
.mockReturnValue({ getSeconds: () => 1 })

const mockScript = jest.fn()
jest.mock(
scriptPath,
() => ({
default: mockScript,
}),
{ virtual: true }
)

await migrator.run([scriptPath])

expect(mockScript).not.toHaveBeenCalled()
expect(mockPgConnection.raw).not.toHaveBeenCalledWith(
expect.stringContaining("UPDATE script_migrations")
)
})
})

describe("getPendingMigrations", () => {
it("should return only non-executed migrations", async () => {
const executedMigration = "executed.ts"
const pendingMigration = "pending.ts"

jest
.spyOn(migrator as any, "getExecutedMigrations")
.mockResolvedValue([{ script_name: executedMigration }])
jest
.spyOn(migrator as any, "loadMigrationFiles")
.mockResolvedValue([
`/path/to/${executedMigration}`,
`/path/to/${pendingMigration}`,
])

const result = await migrator.getPendingMigrations(["/path/to"])

expect(result).toHaveLength(1)
expect(result[0]).toContain(pendingMigration)
})
})

describe("createMigrationTable", () => {
it("should create migration table if it doesn't exist", async () => {
await (migrator as any).createMigrationTable()

expect(mockPgConnection.raw).toHaveBeenCalledWith(
expect.stringContaining("CREATE TABLE IF NOT EXISTS script_migrations")
)
})
})
})
2 changes: 2 additions & 0 deletions packages/core/framework/src/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./migrator"
export * from "./run-migration-scripts"
158 changes: 158 additions & 0 deletions packages/core/framework/src/migrations/migrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { join } from "path"
import { glob } from "glob"
import { logger } from "../logger"
import { MedusaContainer } from "@medusajs/types"
import { ContainerRegistrationKeys } from "../utils"
import { Knex } from "@mikro-orm/knex"

export abstract class Migrator {
protected abstract migration_table_name: string

protected container: MedusaContainer
protected pgConnection: Knex<any>

#alreadyLoadedPaths: Map<string, any> = new Map()

constructor({ container }: { container: MedusaContainer }) {
this.container = container
this.pgConnection = this.container.resolve(
ContainerRegistrationKeys.PG_CONNECTION
)
}

/**
* Util to track duration using hrtime
*/
protected trackDuration() {
const startTime = process.hrtime()
return {
getSeconds() {
const duration = process.hrtime(startTime)
return (duration[0] + duration[1] / 1e9).toFixed(2)
},
}
}

async ensureDatabase(): Promise<void> {
const pgConnection = this.container.resolve(
ContainerRegistrationKeys.PG_CONNECTION
)

try {
await pgConnection.raw("SELECT 1 + 1;")
} catch (error) {
if (error.code === "3D000") {
logger.error(
`Cannot run migrations. ${error.message.replace("error: ", "")}`
)
logger.info(`Run command "db:create" to create the database`)
} else {
logger.error(error)
}
throw error
}
}

async ensureMigrationsTable(): Promise<void> {
try {
// Check if table exists
const tableExists = await this.pgConnection.raw(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = '${this.migration_table_name}'
);
`)

if (!tableExists.rows[0].exists) {
logger.info(
`Creating migrations table '${this.migration_table_name}'...`
)
await this.createMigrationTable()
logger.info("Migrations table created successfully")
}
} catch (error) {
logger.error("Failed to ensure migrations table exists:", error)
throw error
}
}

async getExecutedMigrations(): Promise<{ script_name: string }[]> {
try {
const result = await this.pgConnection.raw(
`SELECT * FROM ${this.migration_table_name}`
)
return result.rows
} catch (error) {
logger.error("Failed to get executed migrations:", error)
throw error
}
}

async insertMigration(records: Record<string, any>[]): Promise<void> {
try {
const values = records.map((record) => Object.values(record))
const columns = Object.keys(records[0])

await this.pgConnection.raw(
`INSERT INTO ${this.migration_table_name} (${columns.join(
", "
)}) VALUES (${new Array(values.length).fill("?").join(",")})`,
values
)
} catch (error) {
logger.error(
`Failed to update migration table '${this.migration_table_name}':`,
error
)
throw error
}
}

/**
* Load migration files from the given paths
*
* @param paths - The paths to load migration files from
* @param options - The options for loading migration files
* @param options.force - Whether to force loading migration files even if they have already been loaded
* @returns The loaded migration file paths
*/
async loadMigrationFiles(
paths: string[],
{ force }: { force?: boolean } = { force: false }
): Promise<string[]> {
const allScripts: string[] = []

for (const basePath of paths) {
if (!force && this.#alreadyLoadedPaths.has(basePath)) {
allScripts.push(...this.#alreadyLoadedPaths.get(basePath))
continue
}

try {
const scriptFiles = glob.sync("*.{js,ts}", {
cwd: basePath,
ignore: ["**/index.{js,ts}"],
})

if (!scriptFiles?.length) {
continue
}

const filePaths = scriptFiles.map((script) => join(basePath, script))
this.#alreadyLoadedPaths.set(basePath, filePaths)

allScripts.push(...filePaths)
} catch (error) {
logger.error(`Failed to load migration files from ${basePath}:`, error)
throw error
}
}

return allScripts
}

protected abstract createMigrationTable(): Promise<void>
abstract run(...args: any[]): Promise<any>
abstract getPendingMigrations(migrationPaths: string[]): Promise<string[]>
}
Loading

0 comments on commit 7a22adf

Please sign in to comment.