@jungvonmatt/sb-migrate
Version:
CLI tool for managing Storyblok schema and content migrations
318 lines • 13.8 kB
JavaScript
;
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