UNPKG

@jungvonmatt/sb-migrate

Version:

CLI tool for managing Storyblok schema and content migrations

318 lines 13.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.helper = exports.Component = exports.showMigrationChanges = exports.runMigration = exports.isStoryWithUnpublishedChanges = exports.isStoryPublishedWithoutChanges = void 0; exports.defineMigration = defineMigration; exports.findMigrations = findMigrations; const picocolors_1 = __importDefault(require("picocolors")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const IComponent_1 = require("../types/IComponent"); const storyblok_1 = require("./storyblok"); const api_1 = require("./api"); const lodash_1 = require("lodash"); const component_1 = require("./component"); /** * @method isStoryPublishedWithoutChanges * @param {Object} story * @return {Boolean} */ const isStoryPublishedWithoutChanges = (story) => { return story.published === true && story.unpublished_changes === false; }; exports.isStoryPublishedWithoutChanges = isStoryPublishedWithoutChanges; /** * @method isStoryWithUnpublishedChanges * @param {Object} story * @return {Boolean} */ const isStoryWithUnpublishedChanges = (story) => { return story.published === true && story.unpublished_changes === true; }; exports.isStoryWithUnpublishedChanges = isStoryWithUnpublishedChanges; /** * Executes a migration function on all stories containing a specific component. * * This function: * 1. Retrieves all stories containing the specified component * 2. Applies the migration function to each story's content * 3. Updates stories with changes and handles publishing based on options * 4. Creates rollback data for successful changes * 5. Tracks and reports statistics on the migration execution * * @param {string} component - The component name to migrate * @param {MigrationFn} migrationFn - Function that performs the actual content migration * @param {RunMigrationOptions} [options] - Optional configuration for the migration * @param {boolean} [options.isDryrun] - If true, only simulate changes without saving * @param {string} [options.migrationPath] - Path to the migration file * @param {string} [options.publishLanguages] - Languages to publish changes for * @param {"all"|"published"|"published-with-changes"} [options.publish] - Publishing strategy * * @returns {Promise<{executed: boolean, motive?: string}>} Result of the migration execution * @throws {Error} If migration function is missing or execution fails * * @example * ```ts * await runMigration('my-component', (content) => { * content.title = 'New Title'; * }, { publish: 'all' }); * ``` */ const runMigration = async (component, migrationFn, options) => { const { deepEqual } = await import("fast-equals"); const publish = options?.publish || "published"; const publishLanguages = options?.publishLanguages || "ALL_LANGUAGES"; try { const rollbackData = []; if (typeof migrationFn !== "function") { throw new Error("Missing the migration function"); } console.log(`${picocolors_1.default.blue("-")} Getting stories for ${component} component`); const response = await api_1.api.stories.getAll({ contain_component: component }); const stories = response?.data?.stories; if ((0, lodash_1.isEmpty)(stories)) { console.log(`${picocolors_1.default.blue("-")} There are no stories for component ${component}!`); return Promise.resolve({ executed: false, motive: "NO_STORIES", }); } const stats = { success: 0, skip: 0, error: 0, }; for (const story of stories) { if (!story.id) { stats.skip = stats.skip + 1; continue; } try { console.log(`${picocolors_1.default.blue("-")} Processing story ${story.full_slug}`); const storyResponse = await api_1.api.stories.get(story.id); const storyData = storyResponse.data?.story ?? {}; const oldContent = (0, lodash_1.cloneDeep)(storyData.content); await (0, storyblok_1.processMigration)(storyData.content, component, migrationFn, story.full_slug ?? ""); const isChangeContent = !deepEqual(oldContent, storyData.content); if (!options?.isDryrun && isChangeContent) { stats.success = stats.success + 1; console.log(`${picocolors_1.default.blue("-")} Updating story ${story.full_slug}`); rollbackData.push({ id: storyData.id, full_slug: storyData.full_slug, content: oldContent, }); const payload = { force_update: "1", }; if (publish === "published" && (0, exports.isStoryPublishedWithoutChanges)(storyData)) { payload.publish = "1"; } if (publish === "published-with-changes" && (0, exports.isStoryWithUnpublishedChanges)(story)) { payload.publish = "1"; } if (publish === "all") { payload.publish = "1"; } if (!(0, lodash_1.isNull)(publishLanguages) && !(0, lodash_1.isEmpty)(publishLanguages)) { payload.lang = publishLanguages; } await api_1.api.stories.update(storyData, payload); console.log(`${picocolors_1.default.blue("-")} Story updated with success!`); } else { stats.skip = stats.skip + 1; } } catch (e) { stats.error = stats.error + 1; console.error(`${picocolors_1.default.red("X")} An error occurred when try to execute migration and update the story: ${e instanceof Error ? e.message : e}`); } console.log(); } if (!(0, lodash_1.isEmpty)(rollbackData)) { await (0, storyblok_1.createRollbackFile)(rollbackData, component, "generic"); } console.log(`${picocolors_1.default.green("✓")} The migration was executed with success for ${stories.length} entries!`); console.log("Success:", picocolors_1.default.green(stats.success)); console.log("Error:", picocolors_1.default.red(stats.error)); console.log("Skipped:", picocolors_1.default.yellow(stats.skip)); return Promise.resolve({ executed: true, }); } catch (e) { return Promise.reject(e); } }; exports.runMigration = runMigration; /** * @method showMigrationChanges * @param {String} path field name * @param {unknown} value updated value * @param {unknown} oldValue previous value */ const showMigrationChanges = async (path, value, oldValue) => { // Dynamically import deepEqual const { deepEqual } = await import("fast-equals"); if (oldValue === undefined) { const _value = await (0, component_1.getPreview)(value); console.log(` ${picocolors_1.default.green("-")} Created field "${picocolors_1.default.green(path)}" with value "${picocolors_1.default.green(_value)}"`); return; } if (value === undefined) { console.log(` ${picocolors_1.default.red("-")} Removed the field "${picocolors_1.default.red(path)}"`); return; } if (value !== oldValue || !deepEqual(value, oldValue)) { const _value = await (0, component_1.getPreview)(value); const _oldValue = await (0, component_1.getPreview)(oldValue); console.log(` ${picocolors_1.default.blue("-")} Updated field "${picocolors_1.default.blue(path)}" from "${picocolors_1.default.blue(_oldValue)}" to "${picocolors_1.default.blue(_value)}"`); } }; exports.showMigrationChanges = showMigrationChanges; class Component { instance; async create(name) { const response = await api_1.api.components.create({ name }); this.instance = response.data.component; } async load(name) { const response = await api_1.api.components.get(name); this.instance = response.data.component; } addOrUpdateFields(fields, tabConfig) { console.log(`Updating fields for component: ${picocolors_1.default.cyan(this.instance.name)}`); const tabs = tabConfig || { general: [], deprecated: [], }; const order = Object.values(tabs).flat(); const newEntries = Object.entries(fields); const existingEntries = Object.entries(this.instance.schema || {}).filter(([key]) => !Object.keys(fields).includes(key)); const allEntries = [...newEntries, ...existingEntries] .sort(([a], [b]) => { const posA = order.includes(a) ? order.indexOf(a) : Infinity; const posB = order.includes(b) ? order.indexOf(b) : Infinity; return posA - posB; }) .map(([key, field], index) => [ key, { ...field, pos: index }, ]); this.instance = (0, storyblok_1.addOrUpdateFields)(this.instance, Object.fromEntries(allEntries)); const keysConfigured = Object.values(tabs).flat(); allEntries .filter(([, value]) => (0, IComponent_1.isComponentSchemaBaseItem)(value)) .map(([key]) => key) .forEach((key) => { if (!keysConfigured.includes(key)) { const deprecated = tabs.deprecated || []; tabs.deprecated = [...new Set([...deprecated, key])]; } }); const deprecatedFields = Object.fromEntries(allEntries .filter(([key]) => tabs.deprecated?.includes(key)) .map(([key, value]) => [ key, { ...value, required: false }, ])); this.instance = (0, storyblok_1.addOrUpdateFields)(this.instance, deprecatedFields); Object.entries(tabs).forEach(([name, keys], index) => { if (!["general", "default"].includes(name.toLowerCase()) && keys.length) { this.instance = (0, storyblok_1.moveToTab)(this.instance, name, keys, name.toLowerCase() === "deprecated" ? Object.entries(tabs).length + 10 : index); } }); } getPos(fieldId) { const field = this.instance?.schema?.[fieldId]; if ((0, IComponent_1.isComponentSchemaBaseItem)(field)) { return field?.pos ?? undefined; } return undefined; } async transformEntries(migrationFn, options = {}) { return (0, exports.runMigration)(this.instance.name, migrationFn, options); } async save() { await api_1.api.components.update(this.instance); } } exports.Component = Component; exports.helper = { ensureDatasource: storyblok_1.addOrUpdateDatasource, async updateComponent(name) { const component = new Component(); try { await component.load(name); } catch { await component.create(name); } return component; }, }; /** * Define a type-safe migration object for Storyblok schema changes. * This helper function provides compile-time type checking for different migration types. * * @param migration A strongly-typed migration object that must match one of the following types: * - ComponentGroupMigration (create/update/delete component groups) * - ComponentMigration (create/update/delete components) * - DatasourceMigration (create/update/delete datasources) * - StoryMigration (create/update/delete stories) * - TransformEntriesMigration (bulk transform content entries) * * @returns The provided migration object without any transformation * @example * ```ts * const migration = defineMigration({ * type: 'create-component', * name: 'my-component', * component: { * name: 'My Component', * // ... component definition * } * }); * ``` */ function defineMigration(migration) { return migration; } /** * Finds all migration files (.js and .ts) in the migrations directory and its subdirectories * @returns Array of relative paths to migration files */ async function findMigrations() { const migrationsDir = path_1.default.join(process.cwd(), "migrations"); if (!fs_1.default.existsSync(migrationsDir)) { return []; } const migrations = []; // Recursively find all .js and .ts files in the migrations directory function findFiles(dir, basePath = "") { const entries = fs_1.default.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path_1.default.join(dir, entry.name); const relativePath = path_1.default.join(basePath, entry.name); if (entry.isDirectory()) { // Recursively search subdirectories findFiles(fullPath, relativePath); } else if (entry.isFile() && (entry.name.endsWith(".js") || entry.name.endsWith(".ts"))) { // Add .js and .ts files to the list migrations.push(relativePath); } } } findFiles(migrationsDir); return migrations.sort(); } //# sourceMappingURL=migration.js.map