refactor: adding logger and catch blocks

This commit is contained in:
2026-06-12 20:19:25 +03:30
parent 2565da621f
commit 6111016245
4 changed files with 484 additions and 208 deletions

View File

@@ -4,8 +4,9 @@ SOURCE_S3_ACCESS_KEY="sirCxqNKw85GdnlLfzko"
SOURCE_S3_SECRET_KEY="qbyQPjiCd5RcfIFpA3l2sG7EW9nT1gtVYkKJN08w" SOURCE_S3_SECRET_KEY="qbyQPjiCd5RcfIFpA3l2sG7EW9nT1gtVYkKJN08w"
SOURCE_S3_BUCKET="test" SOURCE_S3_BUCKET="test"
DEST_S3_ENDPOINT="http://95.156.253.15:9001/" # ParsPack: endpoint is https://<bucket-id>.parspack.net and bucket name is the same id (e.g. c530168)
DEST_S3_REGION="default" DEST_S3_ENDPOINT="https://c530168.parspack.net"
DEST_S3_ACCESS_KEY="sirCxqNKw85GdnlLfzko" DEST_S3_REGION="us-west-2"
DEST_S3_SECRET_KEY="qbyQPjiCd5RcfIFpA3l2sG7EW9nT1gtVYkKJN08w" DEST_S3_ACCESS_KEY="your-access-key"
DEST_S3_BUCKET="test" DEST_S3_SECRET_KEY="your-secret-key"
DEST_S3_BUCKET="c530168"

View File

@@ -3,16 +3,26 @@ import { DataSource } from "typeorm";
import { AssetEntity } from "./entity/Assets.js"; import { AssetEntity } from "./entity/Assets.js";
import { BrandEntity } from "./entity/Brand.js"; import { BrandEntity } from "./entity/Brand.js";
import { BrandAssetEntity } from "./entity/BrandAsset.js"; import { BrandAssetEntity } from "./entity/BrandAsset.js";
import { logger } from "./logger.js";
export const AppDataSource = new DataSource({ const dbConfig = {
type: "mysql", type: "mysql",
host: "95.156.253.15", host: "31.214.255.158",
port: 3306, port: 3301,
username: "root", username: "brandprod",
password: "Aa9923994970", password: "cdcc9b7152905d21baa7758586fed2aa",
database: "temporary", database: "brandifa_production",
logging: false, logging: process.env.DB_LOGGING === "true",
entities: [AssetEntity, BrandEntity, BrandAssetEntity], entities: [AssetEntity, BrandEntity, BrandAssetEntity],
migrations: [], migrations: [],
subscribers: [], subscribers: [],
};
logger.info("data-source", "creating AppDataSource", {
host: dbConfig.host,
port: dbConfig.port,
database: dbConfig.database,
logging: dbConfig.logging,
}); });
export const AppDataSource = new DataSource(dbConfig);

View File

@@ -1,4 +1,4 @@
import "dotenv/config" import "dotenv/config";
import { import {
GetObjectCommand, GetObjectCommand,
HeadObjectCommand, HeadObjectCommand,
@@ -9,11 +9,32 @@ import { AppDataSource } from "./data-source.js";
import { AssetEntity } from "./entity/Assets.js"; import { AssetEntity } from "./entity/Assets.js";
import { BrandEntity } from "./entity/Brand.js"; import { BrandEntity } from "./entity/Brand.js";
import { BrandAssetEntity } from "./entity/BrandAsset.js"; import { BrandAssetEntity } from "./entity/BrandAsset.js";
import { logger, runStep } from "./logger.js";
const AVATAR_PREFIX = "avatar"; const AVATAR_PREFIX = "avatar";
const THUMBNAIL_PREFIX = "lg"; const THUMBNAIL_PREFIX = "lg";
const DRY_RUN = process.env.DRY_RUN === "true"; const DRY_RUN = process.env.DRY_RUN === "true";
function resolveParsPackBucket(endpoint, bucket) {
const match = endpoint?.match(/https?:\/\/(c\d+)\.parspack\.net/i);
if (!match) return bucket;
const bucketId = match[1];
if (bucket !== bucketId) {
logger.warn(
"resolveParsPackBucket",
`ParsPack bucket name must match the endpoint id; using "${bucketId}" instead of "${bucket}"`,
);
}
return bucketId;
}
function normalizeParsPackEndpoint(endpoint) {
if (!endpoint?.includes("parspack.net")) return endpoint;
return endpoint.replace(/^http:/i, "https:").replace(/\/$/, "");
}
const SOURCE_S3 = { const SOURCE_S3 = {
endpoint: process.env.SOURCE_S3_ENDPOINT ?? "http://95.156.253.15:9001/", endpoint: process.env.SOURCE_S3_ENDPOINT ?? "http://95.156.253.15:9001/",
region: process.env.SOURCE_S3_REGION ?? "default", region: process.env.SOURCE_S3_REGION ?? "default",
@@ -24,13 +45,21 @@ const SOURCE_S3 = {
bucket: process.env.SOURCE_S3_BUCKET ?? "test", bucket: process.env.SOURCE_S3_BUCKET ?? "test",
}; };
const DEST_S3 = { const destEndpoint = normalizeParsPackEndpoint(
endpoint: process.env.DEST_S3_ENDPOINT ??
process.env.DEST_S3_ENDPOINT ??
process.env.SOURCE_S3_ENDPOINT ?? process.env.SOURCE_S3_ENDPOINT ??
"http://95.156.253.15:9001/", "http://95.156.253.15:9001/",
);
const destBucket = resolveParsPackBucket(
destEndpoint,
process.env.DEST_S3_BUCKET ?? "migrations",
);
const DEST_S3 = {
endpoint: destEndpoint,
region: region:
process.env.DEST_S3_REGION ?? process.env.SOURCE_S3_REGION ?? "default", process.env.DEST_S3_REGION ??
(destEndpoint.includes("parspack.net") ? "us-west-2" : process.env.SOURCE_S3_REGION ?? "default"),
accessKeyId: accessKeyId:
process.env.DEST_S3_ACCESS_KEY ?? process.env.DEST_S3_ACCESS_KEY ??
process.env.SOURCE_S3_ACCESS_KEY ?? process.env.SOURCE_S3_ACCESS_KEY ??
@@ -39,61 +68,105 @@ const DEST_S3 = {
process.env.DEST_S3_SECRET_KEY ?? process.env.DEST_S3_SECRET_KEY ??
process.env.SOURCE_S3_SECRET_KEY ?? process.env.SOURCE_S3_SECRET_KEY ??
"qbyQPjiCd5RcfIFpA3l2sG7EW9nT1gtVYkKJN08w", "qbyQPjiCd5RcfIFpA3l2sG7EW9nT1gtVYkKJN08w",
bucket: process.env.DEST_S3_BUCKET ?? "migrations", bucket: destBucket,
}; };
function createS3Client(config) { function createS3Client(config, label) {
return new S3Client({ const step = `createS3Client:${label}`;
region: config.region, logger.info(step, "creating S3 client", {
endpoint: config.endpoint, endpoint: config.endpoint,
forcePathStyle: true, region: config.region,
credentials: { bucket: config.bucket,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
}); });
try {
const client = new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: true,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
logger.info(step, "S3 client created");
return client;
} catch (error) {
logger.error(step, "failed to create S3 client", logger.formatError(error));
throw error;
}
} }
async function getAssetById(id) { async function getAssetById(id) {
const assetRepository = AppDataSource.getRepository(AssetEntity); const step = `getAssetById:${id}`;
return assetRepository.findOneBy({ id }); try {
const assetRepository = AppDataSource.getRepository(AssetEntity);
const asset = await assetRepository.findOneBy({ id });
logger.debug(step, asset ? "asset found" : "asset not found");
return asset;
} catch (error) {
logger.error(step, "database query failed", logger.formatError(error));
throw error;
}
} }
async function getBrandAssetsByGroupId(groupId) { async function getBrandAssetsByGroupId(groupId) {
const brandAssetRepo = AppDataSource.getRepository(BrandAssetEntity); const step = `getBrandAssetsByGroupId:${groupId}`;
return brandAssetRepo.find({ try {
where: { const brandAssetRepo = AppDataSource.getRepository(BrandAssetEntity);
groupId, const brandAssets = await brandAssetRepo.find({
deleted: false, where: {
}, groupId,
}); deleted: false,
},
});
logger.debug(step, `found ${brandAssets.length} brand assets`);
return brandAssets;
} catch (error) {
logger.error(step, "database query failed", logger.formatError(error));
throw error;
}
} }
async function getBrandUrlNames() { async function getBrandUrlNames() {
const brandRepo = AppDataSource.getRepository(BrandEntity); const step = "getBrandUrlNames";
const brands = await brandRepo.find({ try {
where: { const brandRepo = AppDataSource.getRepository(BrandEntity);
deleted: false, const brands = await brandRepo.find({
}, where: {
}); deleted: false,
},
const seen = new Set();
const result = [];
for (const brand of brands) {
if (!brand.urlName) continue;
const key = `${brand.groupId}:${brand.urlName.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
name: brand.urlName,
groupId: brand.groupId,
}); });
} logger.info(step, `loaded ${brands.length} brands from database`);
return result; const seen = new Set();
const result = [];
for (const brand of brands) {
if (!brand.urlName) {
logger.debug(step, `skipping brand id=${brand.id}: missing urlName`);
continue;
}
const key = `${brand.groupId}:${brand.urlName.toLowerCase()}`;
if (seen.has(key)) {
logger.debug(step, `skipping duplicate brand urlName=${brand.urlName}`);
continue;
}
seen.add(key);
result.push({
name: brand.urlName,
groupId: brand.groupId,
});
}
logger.info(step, `resolved ${result.length} unique brands`);
return result;
} catch (error) {
logger.error(step, "failed to load brands", logger.formatError(error));
throw error;
}
} }
function resolveAssetSourceKey(asset) { function resolveAssetSourceKey(asset) {
@@ -101,8 +174,10 @@ function resolveAssetSourceKey(asset) {
} }
async function objectExists(client, bucket, key) { async function objectExists(client, bucket, key) {
const step = `objectExists:${bucket}/${key}`;
try { try {
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
logger.debug(step, "object exists");
return true; return true;
} catch (error) { } catch (error) {
const statusCode = const statusCode =
@@ -115,7 +190,12 @@ async function objectExists(client, bucket, key) {
? error.$metadata.httpStatusCode ? error.$metadata.httpStatusCode
: undefined; : undefined;
if (statusCode === 404) return false; if (statusCode === 404) {
logger.debug(step, "object not found");
return false;
}
logger.error(step, "head object failed", logger.formatError(error));
throw error; throw error;
} }
} }
@@ -128,29 +208,43 @@ async function copyFileCrossClient(
sourceKey, sourceKey,
destKey, destKey,
) { ) {
const response = await sourceClient.send( const step = `copyFileCrossClient:${sourceBucket}/${sourceKey}->${destBucket}/${destKey}`;
new GetObjectCommand({ try {
Bucket: sourceBucket, logger.debug(step, "fetching source object");
Key: sourceKey, const response = await sourceClient.send(
}), new GetObjectCommand({
); Bucket: sourceBucket,
Key: sourceKey,
}),
);
if (!response.Body) { if (!response.Body) {
throw new Error(`Empty response body for ${sourceBucket}/${sourceKey}`); throw new Error(`Empty response body for ${sourceBucket}/${sourceKey}`);
}
logger.debug(step, "uploading to destination", {
contentType: response.ContentType,
contentLength: response.ContentLength,
});
await destClient.send(
new PutObjectCommand({
Bucket: destBucket,
Key: destKey,
Body: response.Body,
...(response.ContentType ? { ContentType: response.ContentType } : {}),
...(response.ContentLength !== undefined
? { ContentLength: response.ContentLength }
: {}),
...(response.Metadata ? { Metadata: response.Metadata } : {}),
}),
);
logger.info(step, "copy completed");
} catch (error) {
logger.error(step, "copy failed", logger.formatError(error));
throw error;
} }
await destClient.send(
new PutObjectCommand({
Bucket: destBucket,
Key: destKey,
Body: response.Body,
...(response.ContentType ? { ContentType: response.ContentType } : {}),
...(response.ContentLength !== undefined
? { ContentLength: response.ContentLength }
: {}),
...(response.Metadata ? { Metadata: response.Metadata } : {}),
}),
);
} }
async function copyIfNeeded( async function copyIfNeeded(
@@ -163,62 +257,93 @@ async function copyIfNeeded(
dryRun, dryRun,
type = "file", type = "file",
) { ) {
if (type !== "directory") { const step = `copyIfNeeded:${type}:${destBucket}/${destKey}`;
const sourceExists = await objectExists( try {
sourceClient, if (type !== "directory") {
sourceBucket, const sourceExists = await objectExists(
sourceKey, sourceClient,
); sourceBucket,
if (!sourceExists) return "missing"; sourceKey,
} );
if (!sourceExists) {
logger.warn(step, "source object missing", {
sourceBucket,
sourceKey,
});
return "missing";
}
}
const destExists = await objectExists(destClient, destBucket, destKey); const destExists = await objectExists(destClient, destBucket, destKey);
if (destExists) return "skipped"; if (destExists) {
logger.debug(step, "destination already exists, skipping");
return "skipped";
}
if (dryRun) { if (dryRun) {
console.log( logger.info(step, "[dry-run] would copy object", {
`[dry-run] copy ${sourceBucket}/${sourceKey} -> ${destBucket}/${destKey}`, source: `${sourceBucket}/${sourceKey}`,
); destination: `${destBucket}/${destKey}`,
});
return "copied";
}
if (type === "file") {
await copyFileCrossClient(
sourceClient,
destClient,
sourceBucket,
destBucket,
sourceKey,
destKey,
);
} else {
logger.debug(step, "creating directory marker");
await destClient.send(
new PutObjectCommand({
Bucket: destBucket,
Key: destKey,
}),
);
}
logger.info(step, "copy action completed", { result: "copied" });
return "copied"; return "copied";
} catch (error) {
logger.error(step, "copyIfNeeded failed", logger.formatError(error));
throw error;
} }
if (type === "file") {
await copyFileCrossClient(
sourceClient,
destClient,
sourceBucket,
destBucket,
sourceKey,
destKey,
);
} else {
await destClient.send(
new PutObjectCommand({
Bucket: destBucket,
Key: destKey,
}),
);
}
return "copied";
} }
async function createBrandsFolders(sourceClient, destClient, brands, stats) { async function createBrandsFolders(sourceClient, destClient, brands, stats) {
for (const brand of brands) { const step = "createBrandsFolders";
const folderKey = `${brand.name.toLowerCase()}/`; logger.info(step, `creating folders for ${brands.length} brands`);
const result = await copyIfNeeded(
sourceClient,
destClient,
SOURCE_S3.bucket,
DEST_S3.bucket,
"",
folderKey,
DRY_RUN,
"directory",
);
if (result === "copied") stats.foldersCreated++; for (const brand of brands) {
if (result === "skipped") stats.foldersSkipped++; const brandStep = `${step}:${brand.name}`;
try {
const folderKey = `${brand.name.toLowerCase()}/`;
logger.debug(brandStep, "creating folder", { folderKey });
const result = await copyIfNeeded(
sourceClient,
destClient,
SOURCE_S3.bucket,
DEST_S3.bucket,
"",
folderKey,
DRY_RUN,
"directory",
);
if (result === "copied") stats.foldersCreated++;
if (result === "skipped") stats.foldersSkipped++;
logger.info(brandStep, "folder step completed", { result });
} catch (error) {
stats.errors++;
logger.error(brandStep, "folder creation failed", logger.formatError(error));
}
} }
} }
@@ -229,91 +354,144 @@ async function copyAssetVariant(
destKey, destKey,
stats, stats,
) { ) {
const result = await copyIfNeeded( const step = `copyAssetVariant:${destKey}`;
sourceClient, try {
destClient, const result = await copyIfNeeded(
SOURCE_S3.bucket, sourceClient,
DEST_S3.bucket, destClient,
sourceKey, SOURCE_S3.bucket,
destKey, DEST_S3.bucket,
DRY_RUN, sourceKey,
"file", destKey,
); DRY_RUN,
"file",
);
if (result === "copied") stats.assetsCopied++; if (result === "copied") stats.assetsCopied++;
if (result === "skipped") stats.assetsSkipped++; if (result === "skipped") stats.assetsSkipped++;
if (result === "missing") stats.assetsMissing++; if (result === "missing") stats.assetsMissing++;
logger.debug(step, "variant copy completed", { result });
} catch (error) {
logger.error(step, "variant copy failed", logger.formatError(error));
throw error;
}
} }
async function copyBrandAssets(sourceClient, destClient, brands, stats) { async function copyBrandAssets(sourceClient, destClient, brands, stats) {
const step = "copyBrandAssets";
logger.info(step, `copying assets for ${brands.length} brands`);
for (const brand of brands) { for (const brand of brands) {
const brandAssets = await getBrandAssetsByGroupId(brand.groupId); const brandStep = `${step}:${brand.name}`;
const brandFolder = brand.name.toLowerCase(); try {
const brandAssets = await getBrandAssetsByGroupId(brand.groupId);
const brandFolder = brand.name.toLowerCase();
console.log( logger.info(brandStep, "processing brand assets", {
`Processing brand "${brand.name}" (groupId=${brand.groupId}, assets=${brandAssets.length})`, groupId: brand.groupId,
); assetCount: brandAssets.length,
});
for (const brandAsset of brandAssets) { for (const brandAsset of brandAssets) {
const asset = await getAssetById(brandAsset.assetId); const assetStep = `${brandStep}:asset:${brandAsset.assetId}`;
if (!asset || asset.deleted) { try {
console.warn( const asset = await getAssetById(brandAsset.assetId);
`Skipping brand_asset ${brandAsset.id}: asset ${brandAsset.assetId} not found or deleted`, if (!asset || asset.deleted) {
); logger.warn(assetStep, "asset not found or deleted", {
stats.assetsMissing++; brandAssetId: brandAsset.id,
continue; assetId: brandAsset.assetId,
});
stats.assetsMissing++;
continue;
}
const sourceKey = resolveAssetSourceKey(asset);
const destBase = `${brandFolder}/${asset.id}`;
await copyAssetVariant(
sourceClient,
destClient,
sourceKey,
destBase,
stats,
);
// await copyAssetVariant(
// sourceClient,
// destClient,
// `${AVATAR_PREFIX}/${sourceKey}`,
// `${destBase}/${AVATAR_PREFIX}`,
// stats,
// );
// await copyAssetVariant(
// sourceClient,
// destClient,
// `${THUMBNAIL_PREFIX}/${sourceKey}`,
// `${destBase}/${THUMBNAIL_PREFIX}`,
// stats,
// );
} catch (error) {
stats.errors++;
logger.error(assetStep, "asset copy failed", logger.formatError(error));
}
} }
const sourceKey = resolveAssetSourceKey(asset); logger.info(brandStep, "brand assets completed");
const destBase = `${brandFolder}/${asset.id}`; } catch (error) {
stats.errors++;
try { logger.error(brandStep, "brand processing failed", logger.formatError(error));
await copyAssetVariant(
sourceClient,
destClient,
sourceKey,
destBase,
stats,
);
// await copyAssetVariant(
// sourceClient,
// destClient,
// `${AVATAR_PREFIX}/${sourceKey}`,
// `${destBase}/${AVATAR_PREFIX}`,
// stats,
// );
// await copyAssetVariant(
// sourceClient,
// destClient,
// `${THUMBNAIL_PREFIX}/${sourceKey}`,
// `${destBase}/${THUMBNAIL_PREFIX}`,
// stats,
// );
} catch (error) {
stats.errors++;
console.error(
`Failed to copy asset ${asset.id} for brand "${brand.name}":`,
error,
);
}
} }
} }
} }
function printSummary(stats) { function printSummary(stats) {
console.log("\nMigration summary:"); const step = "printSummary";
console.log(` Folders created: ${stats.foldersCreated}`); logger.info(step, "migration summary", {
console.log(` Folders skipped: ${stats.foldersSkipped}`); foldersCreated: stats.foldersCreated,
console.log(` Assets copied: ${stats.assetsCopied}`); foldersSkipped: stats.foldersSkipped,
console.log(` Assets skipped: ${stats.assetsSkipped}`); assetsCopied: stats.assetsCopied,
console.log(` Assets missing: ${stats.assetsMissing}`); assetsSkipped: stats.assetsSkipped,
console.log(` Errors: ${stats.errors}`); assetsMissing: stats.assetsMissing,
if (DRY_RUN) console.log(" (dry-run mode — no objects were written)"); errors: stats.errors,
dryRun: DRY_RUN,
});
}
async function initializeDataSource() {
const step = "initializeDataSource";
try {
await AppDataSource.initialize();
logger.info(step, "data source initialized", {
host: AppDataSource.options.host,
port: AppDataSource.options.port,
database: AppDataSource.options.database,
});
} catch (error) {
logger.error(step, "data source initialization failed", logger.formatError(error));
throw error;
}
}
async function destroyDataSource() {
const step = "destroyDataSource";
if (!AppDataSource.isInitialized) {
logger.debug(step, "data source not initialized, skipping destroy");
return;
}
try {
await AppDataSource.destroy();
logger.info(step, "data source destroyed");
} catch (error) {
logger.error(step, "data source destroy failed", logger.formatError(error));
throw error;
}
} }
async function main() { async function main() {
const sourceClient = createS3Client(SOURCE_S3); const step = "main";
const destClient = createS3Client(DEST_S3); logger.info(step, "migration started", { dryRun: DRY_RUN });
const stats = { const stats = {
foldersCreated: 0, foldersCreated: 0,
foldersSkipped: 0, foldersSkipped: 0,
@@ -324,30 +502,52 @@ async function main() {
}; };
try { try {
await AppDataSource.initialize(); const sourceClient = await runStep("createSourceS3Client", async () =>
console.log("Data source initialized"); createS3Client(SOURCE_S3, "source"),
console.log(`Source S3: ${SOURCE_S3.endpoint} (${SOURCE_S3.bucket})`); );
console.log(`Dest S3: ${DEST_S3.endpoint} (${DEST_S3.bucket})`); const destClient = await runStep("createDestS3Client", async () =>
createS3Client(DEST_S3, "dest"),
);
const brands = await getBrandUrlNames(); logger.info(step, "S3 configuration", {
console.log(`Found ${brands.length} brands to migrate`); source: { endpoint: SOURCE_S3.endpoint, bucket: SOURCE_S3.bucket },
dest: { endpoint: DEST_S3.endpoint, bucket: DEST_S3.bucket },
});
await createBrandsFolders(sourceClient, destClient, brands, stats); await runStep("initializeDataSource", initializeDataSource);
await copyBrandAssets(sourceClient, destClient, brands, stats);
const brands = await runStep("getBrandUrlNames", getBrandUrlNames);
await runStep("createBrandsFolders", () =>
createBrandsFolders(sourceClient, destClient, brands, stats),
);
await runStep("copyBrandAssets", () =>
copyBrandAssets(sourceClient, destClient, brands, stats),
);
printSummary(stats); printSummary(stats);
if (stats.errors > 0) { if (stats.errors > 0) {
logger.warn(step, `migration finished with ${stats.errors} error(s)`);
process.exitCode = 1; process.exitCode = 1;
} else {
logger.info(step, "migration completed successfully");
} }
} catch (err) { } catch (error) {
console.error("Migration failed:", err); logger.error(step, "migration failed", logger.formatError(error));
process.exitCode = 1; process.exitCode = 1;
} finally { } finally {
if (AppDataSource.isInitialized) { try {
await AppDataSource.destroy(); await destroyDataSource();
} catch (error) {
logger.error(step, "cleanup failed", logger.formatError(error));
process.exitCode = 1;
} }
} }
} }
main(); main().catch((error) => {
logger.error("unhandledRejection", "fatal error in main()", logger.formatError(error));
process.exitCode = 1;
});

65
src/logger.js Normal file
View File

@@ -0,0 +1,65 @@
function timestamp() {
return new Date().toISOString();
}
function formatError(error) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
...(typeof error === "object" && error !== null && "$metadata" in error
? { metadata: error.$metadata }
: {}),
};
}
return { value: String(error) };
}
const consoleMethods = {
info: console.log,
warn: console.warn,
error: console.error,
debug: console.log,
};
function log(level, step, message, details) {
const prefix = `[${timestamp()}] [${level}] [${step}]`;
const write = consoleMethods[level] ?? console.log;
if (details !== undefined) {
write(`${prefix} ${message}`, details);
return;
}
write(`${prefix} ${message}`);
}
export const logger = {
info(step, message, details) {
log("info", step, message, details);
},
warn(step, message, details) {
log("warn", step, message, details);
},
error(step, message, details) {
log("error", step, message, details);
},
debug(step, message, details) {
log("debug", step, message, details);
},
formatError,
};
export async function runStep(step, fn) {
logger.info(step, "starting");
try {
const result = await fn();
logger.info(step, "completed");
return result;
} catch (error) {
logger.error(step, "failed", formatError(error));
throw error;
}
}