diff --git a/packages/payload/package.json b/packages/payload/package.json index 085f4264cbc..af0b7a223ac 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -64,6 +64,11 @@ "import": "./src/exports/i18n/*.ts", "types": "./src/exports/i18n/*.ts", "default": "./src/exports/i18n/*.ts" + }, + "./migrations": { + "import": "./src/exports/migrations.ts", + "types": "./src/exports/migrations.ts", + "default": "./src/exports/migrations.ts" } }, "main": "./src/index.ts", @@ -174,6 +179,11 @@ "import": "./dist/exports/i18n/*.js", "types": "./dist/exports/i18n/*.d.ts", "default": "./dist/exports/i18n/*.js" + }, + "./migrations": { + "import": "./dist/exports/migrations.js", + "types": "./dist/exports/migrations.d.ts", + "default": "./dist/exports/migrations.js" } }, "main": "./dist/index.js", diff --git a/packages/payload/src/database/migrations/templates/localizeStatus.ts b/packages/payload/src/database/migrations/templates/localizeStatus.ts new file mode 100644 index 00000000000..3ac1f46d48a --- /dev/null +++ b/packages/payload/src/database/migrations/templates/localizeStatus.ts @@ -0,0 +1,62 @@ +/** + * Template for localizeStatus migration + * Transforms version._status from single value to per-locale object + */ + +export const localizeStatusTemplate = (options: { + collectionSlug?: string + dbType: 'mongodb' | 'postgres' | 'sqlite' + globalSlug?: string +}): string => { + const { collectionSlug, dbType, globalSlug } = options + const entity = collectionSlug + ? `collectionSlug: '${collectionSlug}'` + : `globalSlug: '${globalSlug}'` + + if (dbType === 'mongodb') { + return `import { MigrateUpArgs, MigrateDownArgs } from '@payloadcms/db-mongodb' +import { localizeStatus } from 'payload' + +export async function up({ payload, req }: MigrateUpArgs): Promise { + await localizeStatus.up({ + ${entity}, + payload, + req, + }) +} + +export async function down({ payload, req }: MigrateDownArgs): Promise { + await localizeStatus.down({ + ${entity}, + payload, + req, + }) +} +` + } + + // SQL databases (Postgres, SQLite) + return `import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-${dbType}' +import { localizeStatus } from 'payload' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await localizeStatus.up({ + ${entity}, + db, + payload, + req, + sql, + }) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await localizeStatus.down({ + ${entity}, + db, + payload, + req, + sql, + }) +} +` +} diff --git a/packages/payload/src/exports/migrations.ts b/packages/payload/src/exports/migrations.ts new file mode 100644 index 00000000000..1882254f3eb --- /dev/null +++ b/packages/payload/src/exports/migrations.ts @@ -0,0 +1,19 @@ +/** + * Exports for Payload migrations + * + * This module provides migration utilities that users can import in their migration files. + * + * @example + * ```ts + * import { localizeStatus } from 'payload/migrations' + * + * export async function up({ payload }) { + * await localizeStatus.up({ + * collectionSlug: 'posts', + * payload, + * }) + * } + * ``` + */ + +export { localizeStatus } from '../versions/migrations/localizeStatus/index.js' diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index c9a5f123956..0dc829d1160 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1784,6 +1784,11 @@ export { getQueryDraftsSort } from './versions/drafts/getQueryDraftsSort.js' export { enforceMaxVersions } from './versions/enforceMaxVersions.js' export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js' export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' +export { localizeStatus } from './versions/migrations/localizeStatus/index.js' +export type { + MongoLocalizeStatusArgs, + SqlLocalizeStatusArgs, +} from './versions/migrations/localizeStatus/index.js' export { saveVersion } from './versions/saveVersion.js' export type { SchedulePublishTaskInput } from './versions/schedule/types.js' export type { SchedulePublish, TypeWithVersion } from './versions/types.js' diff --git a/packages/payload/src/versions/migrations/localizeStatus/README.md b/packages/payload/src/versions/migrations/localizeStatus/README.md new file mode 100644 index 00000000000..ca50b0e54fe --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/README.md @@ -0,0 +1,229 @@ +# localizeStatus Migration + +Migrate your existing version data to support per-locale draft/published status when enabling `localizeStatus: true`. + +**Supported databases**: PostgreSQL, SQLite, MongoDB + +## Quick Start + +### 1. Create a migration file + +```bash +payload migrate:create localize_status +``` + +### 2. Add the migration code + +**PostgreSQL / SQLite:** + +```typescript +import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-postgres' +import { sql } from '@payloadcms/db-postgres' +import { localizeStatus } from 'payload/migrations' + +export async function up({ db, payload }: MigrateUpArgs): Promise { + await localizeStatus.up({ + collectionSlug: 'posts', // πŸ‘ˆ Change to your collection + db, + payload, + sql, + }) +} + +export async function down({ db, payload }: MigrateDownArgs): Promise { + await localizeStatus.down({ + collectionSlug: 'posts', + db, + payload, + sql, + }) +} +``` + +**MongoDB:** + +```typescript +import type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/db-mongodb' +import { localizeStatus } from 'payload/migrations' + +export async function up({ payload }: MigrateUpArgs): Promise { + await localizeStatus.up({ + collectionSlug: 'posts', // πŸ‘ˆ Change to your collection + payload, + }) +} + +export async function down({ payload }: MigrateDownArgs): Promise { + await localizeStatus.down({ + collectionSlug: 'posts', + payload, + }) +} +``` + +**For globals**, use `globalSlug` instead: + +```typescript +await localizeStatus.up({ + globalSlug: 'settings', // πŸ‘ˆ Your global slug + payload, +}) +``` + +### 3. Run the migration + +```bash +payload migrate +``` + +## What it does + +### BEFORE (old schema) + +- **One status for all locales**: When you publish, ALL locales are published +- No way to have draft content in one locale while another is published + +### AFTER (new schema) + +- **Per-locale status**: Each locale can be draft or published independently +- Full control over which locales are published at any time + +## Migration behavior + +The migration processes your version history chronologically to determine the correct status for each locale: + +1. **Published with specific locale** (`publishedLocale: 'en'`) + + - That locale becomes 'published' + - Other locales remain 'draft' + +2. **Published without locale** (all locales) + + - All locales become 'published' + +3. **Draft save** + - All locales become 'draft' (unpublish everything) + +### Example + +Version history: + +1. V1: Publish all β†’ `{ en: 'published', es: 'published', de: 'published' }` +2. V2: Draft save β†’ `{ en: 'draft', es: 'draft', de: 'draft' }` +3. V3: Publish EN only β†’ `{ en: 'published', es: 'draft', de: 'draft' }` + +## When to use this + +Use this migration when: + +1. βœ… You have existing collections with `versions.drafts` enabled +2. βœ… You want to enable `versions.drafts.localizeStatus: true` +3. βœ… You need to preserve existing version history + +Don't use this if: + +- Starting a fresh project (just enable `localizeStatus: true` from the start) +- Collection doesn't have versions enabled +- Collection isn't localized + +## Safety + +### ⚠️ BACKUP YOUR DATABASE FIRST + +This migration modifies your database schema: + +- **PostgreSQL/SQLite**: Drops `version__status` column, adds `_status` to locales table +- **MongoDB**: Transforms `version._status` from string to object +- **Preserves**: `published_locale` column (needed for rollback) + +**Create a backup before running:** + +```bash +# PostgreSQL +pg_dump your_database > backup_before_migration.sql + +# MongoDB +mongodump --db your_database --out ./backup_before_migration +``` + +### Migration guarantees + +βœ… **Idempotent**: Safe to run multiple times (skips if already migrated) +βœ… **Validated**: Checks schema before proceeding +βœ… **Chronological**: Processes versions oldest-first for accuracy +βœ… **Rollback**: Includes `down()` to revert changes + +## Enable localizeStatus in your config + +After migrating, enable the feature: + +```typescript +// Before +{ + slug: 'posts', + versions: { + drafts: true, + }, +} + +// After +{ + slug: 'posts', + versions: { + drafts: { + localizeStatus: true, + }, + }, +} +``` + +## Rollback + +To revert the migration: + +```bash +payload migrate:down +``` + +**Note**: Rollback uses "ANY locale published = globally published" logic, so some granularity may be lost. + +## Troubleshooting + +### "version\_\_status column not found" + +**Cause**: Migration already run, or column doesn't exist. + +**Solution**: Check if already migrated. If yes, no action needed. + +### Migration completes but data looks wrong + +**Cause**: `publishedLocale` field may have been null in original data. + +**Solution**: Review version history in database. The migration respects what's recorded in your data. + +### Want to migrate multiple collections? + +Call the migration multiple times: + +```typescript +export async function up({ db, payload }: MigrateUpArgs): Promise { + await localizeStatus.up({ collectionSlug: 'posts', db, payload, sql }) + await localizeStatus.up({ collectionSlug: 'articles', db, payload, sql }) + await localizeStatus.up({ globalSlug: 'settings', db, payload, sql }) +} +``` + +## Pre-flight checklist + +Before running: + +- [ ] Database backup created +- [ ] Tested on staging/development database +- [ ] Verified version data looks correct +- [ ] `localizeStatus: true` ready to enable in config +- [ ] Reviewed expected behavior +- [ ] Rollback plan ready + +## Support + +Issues? Check [GitHub](https://github.com/payloadcms/payload/issues) or the [Discord community](https://discord.com/invite/payload). diff --git a/packages/payload/src/versions/migrations/localizeStatus/index.ts b/packages/payload/src/versions/migrations/localizeStatus/index.ts new file mode 100644 index 00000000000..5114a5b3890 --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/index.ts @@ -0,0 +1,52 @@ +import type { LocalizeStatusArgs as MongoArgs } from './mongo/index.js' +import type { LocalizeStatusArgs as SqlArgs } from './sql/index.js' + +import { localizeStatus as mongoLocalizeStatus } from './mongo/index.js' +import { localizeStatus as sqlLocalizeStatus } from './sql/index.js' + +type LocalizeStatusMigration = { + down: (args: any) => Promise + up: (args: any) => Promise +} + +/** + * Main entry point for localizeStatus migration. + * Detects database type and dispatches to appropriate implementation. + */ +export const localizeStatus: LocalizeStatusMigration = { + async up(args: MongoArgs | SqlArgs): Promise { + // Detect database type by checking which parameters are present + if ('db' in args && 'sql' in args) { + // SQL database (Postgres, SQLite, etc.) + return sqlLocalizeStatus.up(args) + } else if ('payload' in args && !('db' in args)) { + // MongoDB + return mongoLocalizeStatus.up(args) + } else { + throw new Error( + 'Unable to detect database type. Expected either { db, sql } for SQL databases ' + + 'or { payload } for MongoDB.', + ) + } + }, + + async down(args: MongoArgs | SqlArgs): Promise { + // Detect database type by checking which parameters are present + if ('db' in args && 'sql' in args) { + // SQL database (Postgres, SQLite, etc.) + return sqlLocalizeStatus.down(args) + } else if ('payload' in args && !('db' in args)) { + // MongoDB + return mongoLocalizeStatus.down(args) + } else { + throw new Error( + 'Unable to detect database type. Expected either { db, sql } for SQL databases ' + + 'or { payload } for MongoDB.', + ) + } + }, +} + +export type { LocalizeStatusArgs as MongoLocalizeStatusArgs } from './mongo/index.js' +// Re-export types for convenience +export type { LocalizeStatusArgs as SqlLocalizeStatusArgs } from './sql/index.js' diff --git a/packages/payload/src/versions/migrations/localizeStatus/mongo/down.ts b/packages/payload/src/versions/migrations/localizeStatus/mongo/down.ts new file mode 100644 index 00000000000..80d9b94118a --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/mongo/down.ts @@ -0,0 +1,146 @@ +import type { Payload } from '../../../../types/index.js' + +import { hasLocalizeStatusEnabled } from '../../../../utilities/getVersionsConfig.js' + +export type LocalizeStatusArgs = { + collectionSlug?: string + globalSlug?: string + payload: Payload + req?: any +} + +export async function down(args: LocalizeStatusArgs): Promise { + const { collectionSlug, globalSlug, payload } = args + + if (!collectionSlug && !globalSlug) { + throw new Error('Either collectionSlug or globalSlug must be provided') + } + + if (collectionSlug && globalSlug) { + throw new Error('Cannot provide both collectionSlug and globalSlug') + } + + const entitySlug = collectionSlug || globalSlug + // MongoDB collection names are case-insensitive and stored as lowercase + const versionsCollection = `_${entitySlug}_versions`.toLowerCase() + + if (!payload.config.localization) { + throw new Error('Localization is not enabled in payload config') + } + + const entityConfig = collectionSlug + ? payload.config.collections.find((c) => c.slug === collectionSlug) + : payload.config.globals.find((g) => g.slug === globalSlug!) + + if (!entityConfig) { + throw new Error( + `${collectionSlug ? 'Collection' : 'Global'} not found: ${collectionSlug || globalSlug}`, + ) + } + + if (hasLocalizeStatusEnabled(entityConfig)) { + throw new Error( + `${entitySlug} has localizeStatus enabled, cannot run down migration. ` + + `Please disable localizeStatus in your config before rolling back this migration.`, + ) + } + + const defaultLocale = payload.config.localization.defaultLocale + + payload.logger.info({ + msg: `Rolling back _status localization migration for ${collectionSlug ? 'collection' : 'global'}: ${entitySlug}`, + }) + + // Get MongoDB connection + const connection = (payload.db as any).connection + + payload.logger.info({ msg: 'Fetching all version documents...' }) + + // Get all versions + const allVersions = await connection.collection(versionsCollection).find({}).toArray() + + payload.logger.info({ msg: `Found ${allVersions.length} version documents` }) + + // Update each version document: convert version._status from object to string + let updateCount = 0 + for (const doc of allVersions) { + const currentStatus = doc.version?._status + + if (!currentStatus || typeof currentStatus === 'string') { + // Already rolled back or never migrated + continue + } + + // Convert from { en: 'published', es: 'draft' } to 'published' (using default locale) + const statusValue = + typeof currentStatus === 'object' ? currentStatus[defaultLocale] || 'draft' : 'draft' + + await connection.collection(versionsCollection).updateOne( + { _id: doc._id }, + { + $set: { + 'version._status': statusValue, + }, + }, + ) + + updateCount++ + } + + payload.logger.info({ msg: `Updated ${updateCount} version documents` }) + + // Rollback main collection/global document status + if (collectionSlug) { + const mainCollection = collectionSlug + const mainDoc = await connection.collection(mainCollection).findOne({}) + + if (mainDoc && '_status' in mainDoc && typeof mainDoc._status === 'object') { + payload.logger.info({ msg: `Rolling back main collection documents for: ${mainCollection}` }) + + const allDocs = await connection.collection(mainCollection).find({}).toArray() + + for (const doc of allDocs) { + if (typeof doc._status === 'object' && !Array.isArray(doc._status)) { + // Convert from { en: 'published', es: 'draft' } to 'published' (using default locale) + const statusValue = doc._status[defaultLocale] || 'draft' + + await connection.collection(mainCollection).updateOne( + { _id: doc._id }, + { + $set: { + _status: statusValue, + }, + }, + ) + } + } + + payload.logger.info({ msg: `Rolled back ${allDocs.length} collection documents` }) + } + } else if (globalSlug) { + const globalDoc = await connection.collection('globals').findOne({ globalType: globalSlug }) + + if (globalDoc && '_status' in globalDoc && typeof globalDoc.status === 'object') { + payload.logger.info({ msg: `Rolling back main global document for: ${globalSlug}` }) + + // Convert from { en: 'published', es: 'draft' } to 'published' (using default locale) + const statusValue = + typeof globalDoc._status === 'object' && !Array.isArray(globalDoc._status) + ? globalDoc._status[defaultLocale] || 'draft' + : 'draft' + + await connection.collection('globals').updateOne( + { _id: globalDoc._id, globalType: globalSlug }, + { + $set: { + _status: statusValue, + }, + }, + ) + + payload.logger.info({ msg: 'Rolled back global document' }) + } + } + + payload.logger.info({ msg: 'Rollback completed successfully' }) +} diff --git a/packages/payload/src/versions/migrations/localizeStatus/mongo/index.ts b/packages/payload/src/versions/migrations/localizeStatus/mongo/index.ts new file mode 100644 index 00000000000..ed509d7656b --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/mongo/index.ts @@ -0,0 +1,9 @@ +import { down } from './down.js' +import { up } from './up.js' + +export const localizeStatus = { + down, + up, +} + +export type { LocalizeStatusArgs } from './up.js' diff --git a/packages/payload/src/versions/migrations/localizeStatus/mongo/up.ts b/packages/payload/src/versions/migrations/localizeStatus/mongo/up.ts new file mode 100644 index 00000000000..6df31de5432 --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/mongo/up.ts @@ -0,0 +1,263 @@ +import type { Payload } from '../../../../types/index.js' + +import { calculateVersionLocaleStatuses, type VersionRecord } from '../shared.js' + +export type LocalizeStatusArgs = { + collectionSlug?: string + globalSlug?: string + payload: Payload + req?: any +} + +export async function up(args: LocalizeStatusArgs): Promise { + const { collectionSlug, globalSlug, payload, req } = args + + if (!collectionSlug && !globalSlug) { + throw new Error('Either collectionSlug or globalSlug must be provided') + } + + if (collectionSlug && globalSlug) { + throw new Error('Cannot provide both collectionSlug and globalSlug') + } + + const entitySlug = collectionSlug || globalSlug + // MongoDB collection names are case-insensitive and stored as lowercase + const versionsCollection = `_${entitySlug}_versions`.toLowerCase() + + if (!payload.config.localization) { + throw new Error('Localization is not enabled in payload config') + } + + // Check if versions are enabled on this collection/global + let entityConfig + if (collectionSlug) { + const collection = payload.config.collections.find((c) => c.slug === collectionSlug) + if (collection) { + entityConfig = collection + } + } else if (globalSlug) { + const global = payload.config.globals.find((g) => g.slug === globalSlug) + if (global) { + entityConfig = global + } + } + + if (!entityConfig) { + throw new Error( + `${collectionSlug ? 'Collection' : 'Global'} not found: ${collectionSlug || globalSlug}`, + ) + } + + payload.logger.info({ + msg: `Starting _status localization migration for ${collectionSlug ? 'collection' : 'global'}: ${entitySlug}`, + }) + + // Check if versions are enabled in config (skip if not) + if (!entityConfig.versions) { + payload.logger.info({ + msg: `Skipping migration for ${collectionSlug ? 'collection' : 'global'}: ${entitySlug} - versions not enabled`, + }) + return + } + + // Get MongoDB connection + const connection = (payload.db as any).connection + + // Get filtered locales if filterAvailableLocales is defined + let locales = payload.config.localization.localeCodes + if (typeof payload.config.localization.filterAvailableLocales === 'function') { + const filteredLocaleObjects = await payload.config.localization.filterAvailableLocales({ + locales: payload.config.localization.locales, + req, + }) + locales = filteredLocaleObjects.map((locale) => locale.code) + } + payload.logger.info({ msg: `Locales: ${locales.join(', ')}` }) + + // Check if version._status exists and is NOT already localized + const sampleDoc = await connection.collection(versionsCollection).findOne({}) + + if (!sampleDoc) { + payload.logger.info({ msg: 'No version documents found, nothing to migrate' }) + return + } + + // Check if _status is already localized + if ( + sampleDoc.version?._status && + typeof sampleDoc.version._status === 'object' && + !Array.isArray(sampleDoc.version._status) + ) { + payload.logger.info({ + msg: 'version._status is already localized, migration already completed', + }) + return + } + + // Validate that version._status exists and is a string + if ( + !sampleDoc.version || + typeof sampleDoc.version._status !== 'string' || + Array.isArray(sampleDoc.version._status) + ) { + throw new Error( + `Migration aborted: version._status field not found or has unexpected format in ${versionsCollection}. ` + + `This migration should only run on schemas that have NOT yet been migrated to per-locale status.`, + ) + } + + payload.logger.info({ msg: 'Fetching all version documents...' }) + + // Get all versions, sorted chronologically + const allVersions = await connection + .collection(versionsCollection) + .find({}) + .sort({ createdAt: 1, parent: 1 }) + .toArray() + + payload.logger.info({ msg: `Found ${allVersions.length} version documents` }) + + // Transform MongoDB documents to VersionRecord format + const versionRecords: VersionRecord[] = allVersions.map((doc: any) => ({ + id: doc._id.toString(), + _status: doc.version._status as 'draft' | 'published', + createdAt: doc.createdAt, + parent: doc.parent?.toString(), + publishedLocale: doc.publishedLocale, + snapshot: doc.snapshot || false, + })) + + // Calculate status per locale using shared logic + const versionLocaleStatus = calculateVersionLocaleStatuses(versionRecords, locales, payload) + + payload.logger.info({ msg: 'Updating version documents with per-locale status...' }) + + // Update each version document + let updateCount = 0 + for (const doc of allVersions) { + const versionId = doc._id.toString() + const localeStatusMap = versionLocaleStatus.get(versionId) + + if (!localeStatusMap) { + payload.logger.warn({ msg: `No status map found for version ${versionId}, skipping` }) + continue + } + + // Build the new _status object: { en: 'published', es: 'draft', ... } + const newStatus: Record = {} + for (const [locale, status] of localeStatusMap.entries()) { + newStatus[locale] = status + } + + // Update the document: change version._status from string to object + await connection.collection(versionsCollection).updateOne( + { _id: doc._id }, + { + $set: { + 'version._status': newStatus, + }, + }, + ) + + updateCount++ + } + + payload.logger.info({ msg: `Updated ${updateCount} version documents` }) + + // Migrate main collection/global document _status to per-locale status object + // Only if it has a status field + if (collectionSlug) { + const mainCollection = collectionSlug + const mainDoc = await connection.collection(mainCollection).findOne({}) + + if (mainDoc && '_status' in mainDoc) { + payload.logger.info({ msg: `Migrating main collection documents for: ${mainCollection}` }) + + const allDocs = await connection.collection(mainCollection).find({}).toArray() + + for (const doc of allDocs) { + if (!doc._id) { + continue + } + + // Get the latest version for this document to determine status per locale + const latestVersions = await connection + .collection(versionsCollection) + .find({ parent: doc._id }) + .sort({ createdAt: -1 }) + .limit(1) + .toArray() + + let statusObj: Record = {} + + if (latestVersions.length > 0 && latestVersions[0]?.version?._status) { + // Use the status from the latest version + statusObj = latestVersions[0].version._status + } else { + // Fallback: set all locales to draft + for (const locale of locales) { + statusObj[locale] = 'draft' + } + } + + // Update main document + await connection.collection(mainCollection).updateOne( + { _id: doc._id }, + { + $set: { + _status: statusObj, + }, + }, + ) + } + + payload.logger.info({ msg: `Migrated ${allDocs.length} collection documents` }) + } else { + payload.logger.info({ + msg: 'Skipping main document status migration (no status field found)', + }) + } + } else if (globalSlug) { + // Globals are stored in a single 'globals' collection with globalType discriminator + const globalDoc = await connection.collection('globals').findOne({ globalType: globalSlug }) + if (globalDoc && '_status' in globalDoc && globalDoc._id) { + payload.logger.info({ msg: `Migrating main global document for: ${globalSlug}` }) + + // Get the latest version for the global + const latestVersions = await connection + .collection(versionsCollection) + .find({}) + .sort({ createdAt: -1 }) + .limit(1) + .toArray() + + let statusObj: Record = {} + + if (latestVersions.length > 0 && latestVersions[0]?.version?._status) { + statusObj = latestVersions[0].version._status + } else { + for (const locale of locales) { + statusObj[locale] = 'draft' + } + } + + // Update global document + await connection.collection('globals').updateOne( + { _id: globalDoc._id, globalType: globalSlug }, + { + $set: { + _status: statusObj, + }, + }, + ) + + payload.logger.info({ msg: 'Migrated global document' }) + } else { + payload.logger.info({ + msg: 'Skipping main document status migration (no status field found)', + }) + } + } + + payload.logger.info({ msg: 'Migration completed successfully' }) +} diff --git a/packages/payload/src/versions/migrations/localizeStatus/shared.ts b/packages/payload/src/versions/migrations/localizeStatus/shared.ts new file mode 100644 index 00000000000..485055812ac --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/shared.ts @@ -0,0 +1,155 @@ +import type { Payload } from '../../../types/index.js' + +/** + * Convert to snake_case (matches to-snake-case library behavior) + * Handles camelCase, PascalCase, and hyphens + */ +export const toSnakeCase = (str: string): string => { + return str + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, '') + .replace(/-/g, '_') // Convert hyphens to underscores +} + +export type VersionRecord = { + _status: 'draft' | 'published' + created_at?: Date | string + createdAt?: Date | string + id: number | string + parent: number | string + published_locale?: string + publishedLocale?: string + snapshot?: boolean +} + +export type VersionLocaleStatusMap = Map> + +/** + * Core logic for calculating the status of each locale for each version + * by processing version history chronologically. + * + * This works by: + * 1. Processing versions in chronological order (oldest first) + * 2. Tracking the cumulative published state for each document as we process versions + * 3. For each version, determining what status each locale should have based on: + * - Publish events with publishedLocale: mark that locale as published, version shows NEW state + * - Publish events without publishedLocale: mark all locales as published, version shows NEW state + * - Draft saves (_status='draft'): mark all locales as draft (unpublish everything) + * - Snapshots: preserve state AFTER publish (snapshots created after publishing specific locale) + * + * Snapshot creation flow when publishing one locale: + * 1. Merge incoming content with last published β†’ update main table + * 2. Create snapshot object (preserves other locales' draft content + updates published locale) + * 3. Create publish version (_status='published', publishedLocale set) + * 4. Create snapshot version (_status='draft', snapshot=true) + * - Snapshot CONTENT is mixed (draft + published content) + * - Snapshot STATUS reflects which locales are actually published + * + * Example scenario: + * - V1: publish all locales (no snapshot) β†’ state: {en: published, es: published, de: published} + * - V2: draft save β†’ state: {en: draft, es: draft, de: draft} + * - V3: publish en only β†’ state: {en: published, es: draft, de: draft} + * - V4: snapshot after publishing en β†’ state: {en: published, es: draft, de: draft} + * - V5: publish all locales (no snapshot) β†’ state: {en: published, es: published, de: published} + * + * @param versions - Array of version records (must be sorted by parent, then createdAt ASC) + * @param locales - Array of locale codes (e.g., ['en', 'es', 'pt']) + * @param payload - Payload instance for logging + * @returns Map of versionId -> Map of locale -> status + */ +export function calculateVersionLocaleStatuses( + versions: VersionRecord[], + locales: string[], + payload: Payload, +): VersionLocaleStatusMap { + payload.logger.info({ msg: `Processing ${versions.length} version records` }) + + // Track the cumulative published state for each document across all locales + // This represents what IS published at any given point in the version history + const documentPublishState = new Map>() + + // Map to store the final status for each version + const versionLocaleStatus: VersionLocaleStatusMap = new Map() + + // Process versions chronologically to build up status history + for (const version of versions) { + const versionId = version.id + const documentId = version.parent + const status = version._status + const publishedLocale = version.published_locale || version.publishedLocale + const isSnapshot = version.snapshot === true + + // Initialize document state if first time seeing this document + if (!documentPublishState.has(documentId)) { + const localeMap = new Map() + for (const locale of locales) { + localeMap.set(locale, 'draft') + } + documentPublishState.set(documentId, localeMap) + } + + const currentPublishState = documentPublishState.get(documentId)! + const versionStatusMap = new Map() + + if (isSnapshot) { + // Snapshots are created AFTER publishing a specific locale + // Snapshot CONTENT is mixed: preserves other locales' draft content + new published locale content + // But snapshot STATUS should reflect publish state: which locales are published vs draft + // We use currentPublishState to track this, which has been updated by the previous publish + for (const [locale, publishedStatus] of currentPublishState.entries()) { + versionStatusMap.set(locale, publishedStatus) + } + } else if (status === 'published') { + // This is a publish event + if (publishedLocale) { + // Publishing ONE locale - update the document's published state for that locale + currentPublishState.set(publishedLocale, 'published') + + // This version should show the NEW state (after this publish) + for (const [locale, publishedStatus] of currentPublishState.entries()) { + versionStatusMap.set(locale, publishedStatus) + } + } else { + // Publishing ALL locales - update all locales to published + for (const locale of locales) { + currentPublishState.set(locale, 'published') + versionStatusMap.set(locale, 'published') + } + } + } else { + // This is a draft save - in the OLD system, _status='draft' meant unpublish ALL locales + for (const locale of locales) { + currentPublishState.set(locale, 'draft') + versionStatusMap.set(locale, 'draft') + } + } + + // Store the status for this version + versionLocaleStatus.set(versionId, versionStatusMap) + } + + return versionLocaleStatus +} + +/** + * Sorts version records by parent document, then by creation date (oldest first) + * + * @param versions - Array of version records + * @returns Sorted array of version records + */ +export function sortVersionsChronologically(versions: VersionRecord[]): VersionRecord[] { + return versions.sort((a, b) => { + // First sort by parent + const parentA = String(a.parent) + const parentB = String(b.parent) + if (parentA !== parentB) { + return parentA.localeCompare(parentB) + } + + // Then sort by creation date + const dateA = new Date(a.created_at || a.createdAt || 0) + const dateB = new Date(b.created_at || b.createdAt || 0) + return dateA.getTime() - dateB.getTime() + }) +} diff --git a/packages/payload/src/versions/migrations/localizeStatus/sql/down.ts b/packages/payload/src/versions/migrations/localizeStatus/sql/down.ts new file mode 100644 index 00000000000..eba3fa9ec75 --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/down.ts @@ -0,0 +1,121 @@ +import type { Payload } from '../../../../types/index.js' + +import { hasLocalizeStatusEnabled } from '../../../../utilities/getVersionsConfig.js' +import { toSnakeCase } from '../shared.js' + +export type LocalizeStatusArgs = { + collectionSlug?: string + db: any + globalSlug?: string + payload: Payload + req?: any + sql: any +} + +export async function down(args: LocalizeStatusArgs): Promise { + const { collectionSlug, db, globalSlug, payload, sql } = args + + if (!collectionSlug && !globalSlug) { + throw new Error('Either collectionSlug or globalSlug must be provided') + } + + if (collectionSlug && globalSlug) { + throw new Error('Cannot provide both collectionSlug and globalSlug') + } + + const entitySlug = collectionSlug || globalSlug + // Convert camelCase slugs to snake_case and add version prefix/suffix + const versionsTable = collectionSlug + ? `_${toSnakeCase(collectionSlug)}_v` + : `_${toSnakeCase(globalSlug!)}_v` + const localesTable = `${versionsTable}_locales` + + if (!payload.config.localization) { + throw new Error('Localization is not enabled in payload config') + } + + const entityConfig = collectionSlug + ? payload.config.collections.find((c) => c.slug === collectionSlug) + : payload.config.globals.find((g) => g.slug === globalSlug!) + + if (!entityConfig) { + throw new Error( + `${collectionSlug ? 'Collection' : 'Global'} not found: ${collectionSlug || globalSlug}`, + ) + } + + if (hasLocalizeStatusEnabled(entityConfig)) { + throw new Error( + `${entitySlug} has localizeStatus enabled, cannot run down migration. ` + + `Please disable localizeStatus in your config before rolling back this migration.`, + ) + } + + const defaultLocale = payload.config.localization.defaultLocale + + payload.logger.info({ + msg: `Rolling back _status localization migration for ${collectionSlug ? 'collection' : 'global'}: ${entitySlug}`, + }) + + // 1. Restore version__status column to main table + payload.logger.info({ msg: `Restoring version__status column to ${versionsTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(versionsTable)} ADD COLUMN version__status VARCHAR + `, + }) + + // 2. Copy status from default locale back to main table + payload.logger.info({ + msg: `Copying status from default locale (${defaultLocale}) back to main table`, + }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(versionsTable)} pv + SET version__status = pl._status + FROM ${sql.identifier(localesTable)} pl + WHERE pv.id = pl._parent_id + AND pl._locale = ${defaultLocale} + `, + }) + + // 3. Check if there are other localized fields besides _status + const columnCheckResult = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT COUNT(*) as count + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${localesTable} + AND column_name NOT IN ('id', '_locale', '_parent_id', '_status') + `, + }) + + const hasOtherLocalizedFields = Number(columnCheckResult.rows[0]?.count) > 0 + + if (!hasOtherLocalizedFields) { + // SCENARIO 1 ROLLBACK: No other localized fields, drop entire table + payload.logger.info({ msg: `Dropping entire locales table: ${localesTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql`DROP TABLE ${sql.identifier(localesTable)} CASCADE`, + }) + } else { + // SCENARIO 2 ROLLBACK: Other localized fields exist, just drop _status column + payload.logger.info({ msg: `Dropping _status column from ${localesTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(localesTable)} DROP COLUMN _status + `, + }) + } + + payload.logger.info({ msg: 'Rollback completed successfully' }) +} diff --git a/packages/payload/src/versions/migrations/localizeStatus/sql/index.ts b/packages/payload/src/versions/migrations/localizeStatus/sql/index.ts new file mode 100644 index 00000000000..ed509d7656b --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/index.ts @@ -0,0 +1,9 @@ +import { down } from './down.js' +import { up } from './up.js' + +export const localizeStatus = { + down, + up, +} + +export type { LocalizeStatusArgs } from './up.js' diff --git a/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainCollection.ts b/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainCollection.ts new file mode 100644 index 00000000000..0f8d7c8e4d6 --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainCollection.ts @@ -0,0 +1,75 @@ +import type { Payload } from '../../../../types/index.js' + +import { toSnakeCase } from '../shared.js' + +/** + * Migrates main collection documents from _status to per-locale status object + */ +export async function migrateMainCollectionStatus({ + collectionSlug, + db, + locales, + payload, + sql, + versionsTable, +}: { + collectionSlug: string + db: any + locales: string[] + payload: Payload + sql: any + versionsTable: string +}): Promise { + const mainTable = toSnakeCase(collectionSlug) + + payload.logger.info({ msg: `Migrating main collection documents for: ${mainTable}` }) + + // Get all documents with their latest version status per locale + const documents = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT DISTINCT doc.id + FROM ${sql.identifier(mainTable)} doc + `, + }) + + for (const doc of documents.rows) { + // Get the latest version for this document and check published status per locale + const latestVersionStatuses = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT l._locale, l._status + FROM ${sql.identifier(versionsTable)} v + JOIN ${sql.raw(`${versionsTable}_locales`)} l ON l._parent_id = v.id + WHERE v.parent_id = ${doc.id} + ORDER BY v.created_at DESC + LIMIT ${locales.length} + `, + }) + + // Build status object { en: 'published', es: 'draft', ... } + const statusObj: Record = {} + for (const row of latestVersionStatuses.rows) { + statusObj[row._locale] = row._status + } + + // If no statuses found, set all to draft + if (latestVersionStatuses.rows.length === 0) { + for (const locale of locales) { + statusObj[locale] = 'draft' + } + } + + // Update the document with the new status object + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(mainTable)} + SET status = ${JSON.stringify(statusObj)} + WHERE id = ${doc.id} + `, + }) + } + + payload.logger.info({ msg: `Migrated ${documents.rows.length} collection documents` }) +} diff --git a/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainGlobal.ts b/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainGlobal.ts new file mode 100644 index 00000000000..2c3aed6e6cc --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainGlobal.ts @@ -0,0 +1,62 @@ +import type { Payload } from '../../../../types/index.js' + +import { toSnakeCase } from '../shared.js' + +/** + * Migrates main global document from _status to per-locale status object + */ +export async function migrateMainGlobalStatus({ + db, + globalSlug, + locales, + payload, + sql, + versionsTable, +}: { + db: any + globalSlug: string + locales: string[] + payload: Payload + sql: any + versionsTable: string +}): Promise { + const globalTable = toSnakeCase(globalSlug) + + payload.logger.info({ msg: `Migrating main global document for: ${globalTable}` }) + + // Get the latest version status per locale for the global + const latestVersionStatuses = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT l._locale, l._status + FROM ${sql.identifier(versionsTable)} v + JOIN ${sql.raw(`${versionsTable}_locales`)} l ON l._parent_id = v.id + ORDER BY v.created_at DESC + LIMIT ${locales.length} + `, + }) + + // Build status object { en: 'published', es: 'draft', ... } + const statusObj: Record = {} + for (const row of latestVersionStatuses.rows) { + statusObj[row._locale] = row._status + } + + // If no statuses found, set all to draft + if (latestVersionStatuses.rows.length === 0) { + for (const locale of locales) { + statusObj[locale] = 'draft' + } + } + + // Update the global document with the new status object + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(globalTable)} + SET status = ${JSON.stringify(statusObj)} + `, + }) + + payload.logger.info({ msg: 'Migrated global document' }) +} diff --git a/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts b/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts new file mode 100644 index 00000000000..ab02943cda4 --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts @@ -0,0 +1,265 @@ +import type { Payload } from '../../../../types/index.js' + +import { calculateVersionLocaleStatuses, toSnakeCase } from '../shared.js' +import { migrateMainCollectionStatus } from './migrateMainCollection.js' +import { migrateMainGlobalStatus } from './migrateMainGlobal.js' + +export type LocalizeStatusArgs = { + collectionSlug?: string + db: any + globalSlug?: string + payload: Payload + req?: any + sql: any +} + +export async function up(args: LocalizeStatusArgs): Promise { + const { collectionSlug, db, globalSlug, payload, req, sql } = args + + if (!collectionSlug && !globalSlug) { + throw new Error('Either collectionSlug or globalSlug must be provided') + } + + if (collectionSlug && globalSlug) { + throw new Error('Cannot provide both collectionSlug and globalSlug') + } + + const entitySlug = collectionSlug || globalSlug + // Convert camelCase slugs to snake_case and add version prefix/suffix + const versionsTable = collectionSlug + ? `_${toSnakeCase(collectionSlug)}_v` + : `_${toSnakeCase(globalSlug!)}_v` + const localesTable = `${versionsTable}_locales` + + if (!payload.config.localization) { + throw new Error('Localization is not enabled in payload config') + } + + // Check if versions are enabled on this collection/global + let entityConfig + if (collectionSlug) { + const collection = payload.config.collections.find((c) => c.slug === collectionSlug) + if (collection) { + entityConfig = collection + } + } else if (globalSlug) { + const global = payload.config.globals.find((g) => g.slug === globalSlug) + if (global) { + entityConfig = global + } + } + + if (!entityConfig) { + throw new Error( + `${collectionSlug ? 'Collection' : 'Global'} not found: ${collectionSlug || globalSlug}`, + ) + } + + payload.logger.info({ + msg: `Starting _status localization migration for ${collectionSlug ? 'collection' : 'global'}: ${entitySlug}`, + }) + + // Get filtered locales if filterAvailableLocales is defined + let locales = payload.config.localization.localeCodes + if (typeof payload.config.localization.filterAvailableLocales === 'function') { + const filteredLocaleObjects = await payload.config.localization.filterAvailableLocales({ + locales: payload.config.localization.locales, + req, + }) + locales = filteredLocaleObjects.map((locale) => locale.code) + } + payload.logger.info({ msg: `Locales: ${locales.join(', ')}` }) + + // Check if versions are enabled in config (skip if not) + if (!entityConfig.versions) { + payload.logger.info({ + msg: `Skipping migration for ${collectionSlug ? 'collection' : 'global'}: ${entitySlug} - versions not enabled`, + }) + return + } + + // Validate that version__status column exists before proceeding + const columnCheckResult = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${versionsTable} + AND column_name = 'version__status' + ) as exists + `, + }) + + if (!columnCheckResult.rows[0]?.exists) { + throw new Error( + `Migration aborted: version__status column not found in ${versionsTable} table. ` + + `This migration should only run on schemas that have NOT yet been migrated to per-locale status. ` + + `If you've already run this migration, no action is needed.`, + ) + } + + // 1. Check if the locales table exists + const localesTableCheckResult = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${localesTable} + ) as exists + `, + }) + + const localesTableExists = localesTableCheckResult.rows[0]?.exists + + if (!localesTableExists) { + // SCENARIO 1: Create the locales table (first localized field in versions) + payload.logger.info({ msg: `Creating new locales table: ${localesTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + CREATE TABLE ${sql.identifier(localesTable)} ( + id SERIAL PRIMARY KEY, + _locale VARCHAR NOT NULL, + _parent_id INTEGER NOT NULL, + _status VARCHAR, + UNIQUE(_locale, _parent_id), + FOREIGN KEY (_parent_id) REFERENCES ${sql.identifier(versionsTable)}(id) ON DELETE CASCADE + ) + `, + }) + + // Create one row per locale per version record + // Simple approach: copy the same status to all locales + for (const locale of locales) { + const inserted = await db.execute({ + drizzle: db.drizzle, + sql: sql` + INSERT INTO ${sql.identifier(localesTable)} (_locale, _parent_id, _status) + SELECT ${locale}, id, version__status + FROM ${sql.identifier(versionsTable)} + RETURNING id + `, + }) + payload.logger.info({ + msg: `Inserted ${inserted.length} rows for locale: ${locale}`, + }) + } + } else { + // SCENARIO 2: Add _status column to existing locales table + payload.logger.info({ msg: `Adding _status column to existing table: ${localesTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(localesTable)} ADD COLUMN _status VARCHAR + `, + }) + + // INTELLIGENT DATA MIGRATION using historical publishedLocale data + payload.logger.info({ msg: 'Processing version history to determine status per locale...' }) + + // First, get the list of locales that actually exist in the locales table + // This is important because the config may have more locales defined than what's in the OLD schema + const existingLocalesResult = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT DISTINCT _locale + FROM ${sql.identifier(localesTable)} + ORDER BY _locale + `, + }) + const existingLocales = existingLocalesResult.rows.map((row: any) => row._locale as string) + payload.logger.info({ + msg: `Found existing locales in table: ${existingLocales.join(', ')}`, + }) + + // Get all version records grouped by parent document, ordered chronologically + const versionsResult = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT id, parent_id as parent, version__status as _status, published_locale, snapshot, created_at + FROM ${sql.identifier(versionsTable)} + ORDER BY parent_id, created_at ASC + `, + }) + + // Use shared function to calculate version locale statuses + // Only process locales that actually exist in the locales table + const versionLocaleStatus = calculateVersionLocaleStatuses( + versionsResult.rows, + existingLocales, + payload, + ) + + // Now update the locales table with the calculated status for each version + payload.logger.info({ msg: 'Updating locales table with calculated statuses...' }) + + let updateCount = 0 + for (const [versionId, localeMap] of versionLocaleStatus.entries()) { + for (const [locale, status] of localeMap.entries()) { + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(localesTable)} + SET _status = ${status} + WHERE _parent_id = ${versionId} + AND _locale = ${locale} + `, + }) + updateCount++ + } + } + + payload.logger.info({ msg: `Updated ${updateCount} locale rows with status` }) + } + + // 3. Drop the old _status column from main versions table + payload.logger.info({ msg: `Dropping version__status column from ${versionsTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(versionsTable)} DROP COLUMN version__status + `, + }) + + // 4. Migrate main collection/global document _status to per-locale status object + // Only if the main table has a status column (versions.localizeStatusEnabled: true) + const mainTable = collectionSlug ? toSnakeCase(collectionSlug) : toSnakeCase(globalSlug!) + + const statusColumnCheck = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${mainTable} + AND column_name = 'status' + ) as exists + `, + }) + + if (statusColumnCheck.rows[0]?.exists) { + if (collectionSlug) { + await migrateMainCollectionStatus({ + collectionSlug, + db, + locales, + payload, + sql, + versionsTable, + }) + } else if (globalSlug) { + await migrateMainGlobalStatus({ db, globalSlug, locales, payload, sql, versionsTable }) + } + } else { + payload.logger.info({ + msg: 'Skipping main document status migration (no status column found)', + }) + } + + payload.logger.info({ msg: 'Migration completed successfully' }) +} diff --git a/test/localization/localizeStatus.config.ts b/test/localization/localizeStatus.config.ts new file mode 100644 index 00000000000..4cb2a0541cc --- /dev/null +++ b/test/localization/localizeStatus.config.ts @@ -0,0 +1,63 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + collections: [ + { + slug: 'users', + auth: true, + fields: [], + }, + { + slug: 'testMigrationPosts', + fields: [ + { + name: 'title', + type: 'text', + // NOT localized - so no locales table for versions will be created + }, + ], + versions: { + drafts: true, // This adds _status field to versions + // localizeStatus: false by default - creates OLD schema + }, + }, + { + slug: 'testMigrationArticles', + fields: [ + { + name: 'title', + type: 'text', + localized: true, // This WILL create a locales table + }, + ], + versions: { + drafts: true, + // localizeStatus: false by default - creates OLD schema + }, + }, + { + slug: 'testNoVersions', + fields: [ + { + name: 'title', + type: 'text', + localized: true, + }, + ], + // NO versions config - migration should skip this collection + }, + ], + localization: { + defaultLocale: 'en', + locales: ['en', 'es', 'de'], + }, + typescript: { + outputFile: path.resolve(dirname, 'localizeStatus-payload-types.ts'), + }, +}) diff --git a/test/localization/localizeStatus.int.spec.ts b/test/localization/localizeStatus.int.spec.ts new file mode 100644 index 00000000000..dd9aaf797b5 --- /dev/null +++ b/test/localization/localizeStatus.int.spec.ts @@ -0,0 +1,755 @@ +import type { Payload } from 'payload' + +import { sql } from '@payloadcms/db-postgres' +import { Types } from 'mongoose' +import path from 'path' +import { localizeStatus } from 'payload/migrations' +import { fileURLToPath } from 'url' + +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('localizeStatus migration', () => { + // PostgreSQL-specific tests + const describePostgres = + process.env.PAYLOAD_DATABASE === 'postgres' ? global.describe : global.describe.skip + + let payload: Payload + + describePostgres('PostgreSQL', () => { + beforeAll(async () => { + const result = await initPayloadInt(dirname, undefined, undefined, 'localizeStatus.config.ts') + payload = result.payload + }, 30000) + + afterAll(async () => { + if (payload?.db && typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }, 30000) + + describe('Scenario 1: Creating new locales table', () => { + it('should migrate non-localized _status to localized', async () => { + const db = payload.db + + // At this point, Payload has created: + // - test_migration_posts table + // - _test_migration_posts_v table (versions) with _status column (because drafts: true) + // But NO _test_migration_posts_v_locales table (no localized fields in versions) + + // Step 1: Create some test data + const post1 = await payload.create({ + collection: 'testMigrationPosts', + data: { title: 'Post 1' }, + }) + + // Publish the post + await payload.update({ + id: post1.id, + collection: 'testMigrationPosts', + data: { _status: 'published', title: 'Post 1 Updated' }, + }) + + // Step 2: Verify "before" state + const beforeVersions = await db.drizzle.execute(sql` + SELECT id, parent_id as parent, version__status as _status + FROM _test_migration_posts_v + WHERE parent_id = ${post1.id} + `) + + expect(beforeVersions.rows.length).toBeGreaterThan(0) + // Verify version records exist + const latestVersion = beforeVersions.rows[beforeVersions.rows.length - 1] + expect(latestVersion).toBeDefined() + + // Step 3: Run the migration + await localizeStatus.up({ + collectionSlug: 'testMigrationPosts', + db, + payload, + sql, + }) + + // Step 4: Verify "after" state + // Check that locales table was created + const tableCheck = await db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_test_migration_posts_v_locales' + ) as exists + `) + + expect(tableCheck.rows[0].exists).toBe(true) + + // Check that _status column was dropped from main table + const columnCheck = await db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '_test_migration_posts_v' + AND column_name = 'version__status' + ) as exists + `) + + expect(columnCheck.rows[0].exists).toBe(false) + + // Check that _status data was migrated to locales table + const localesData = await db.drizzle.execute(sql` + SELECT _locale, _parent_id, _status + FROM _test_migration_posts_v_locales + ORDER BY _parent_id, _locale + `) + + // Should have 3 locales * number of versions + expect(localesData.rows).toHaveLength(beforeVersions.rows.length * 3) // 3 locales + + // All locales should have the same status (copied from original) + const enRows = localesData.rows.filter((row) => row._locale === 'en') + const esRows = localesData.rows.filter((row) => row._locale === 'es') + const deRows = localesData.rows.filter((row) => row._locale === 'de') + + expect(enRows.length).toBeGreaterThan(0) + expect(esRows).toHaveLength(enRows.length) + expect(deRows).toHaveLength(enRows.length) + + // Verify all locales have the same status (copied from original version__status) + enRows.forEach((enRow, idx) => { + const esRow = esRows[idx]! + const deRow = deRows[idx]! + expect(enRow._status).toBe(esRow._status) + expect(enRow._status).toBe(deRow._status) + }) + + // Step 5: Test rollback + await localizeStatus.down({ + collectionSlug: 'testMigrationPosts', + db, + payload, + sql, + }) + + // Verify _status column restored to main table + const afterDownColumnCheck = await db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '_test_migration_posts_v' + AND column_name = 'version__status' + ) as exists + `) + + expect(afterDownColumnCheck.rows[0].exists).toBe(true) + + // Verify locales table was dropped + const afterDownTableCheck = await db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_test_migration_posts_v_locales' + ) as exists + `) + + expect(afterDownTableCheck.rows[0].exists).toBe(false) + }) + }) + + describe('Scenario 2: Adding to existing locales table', () => { + it('should add _status column to existing locales table', async () => { + const db = payload.db + + // At this point, Payload has created: + // - _test_migration_articles_v table with _status column (because drafts: true) + // - _test_migration_articles_v_locales table with 'title' column (because title is localized) + + // Step 1: Create test data with localized content + const article = await payload.create({ + collection: 'testMigrationArticles', + data: { + title: 'English Title', + }, + locale: 'en', + }) + + // Add Spanish translation + await payload.update({ + id: article.id, + collection: 'testMigrationArticles', + data: { + title: 'TΓ­tulo EspaΓ±ol', + }, + locale: 'es', + }) + + // Publish in English only + await payload.update({ + id: article.id, + collection: 'testMigrationArticles', + data: { + _status: 'published', + }, + locale: 'en', + }) + + // Step 2: Run the migration + await localizeStatus.up({ + collectionSlug: 'testMigrationArticles', + db, + payload, + sql, + }) + + // Step 3: Verify that _status column was added to locales table + const columnCheck = await db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '_test_migration_articles_v_locales' + AND column_name = '_status' + ) as exists + `) + + expect(columnCheck.rows[0].exists).toBe(true) + + // Step 4: Verify migration completed successfully + const localesData = await db.drizzle.execute(sql` + SELECT l._locale, l._status + FROM _test_migration_articles_v_locales l + JOIN _test_migration_articles_v v ON l._parent_id = v.id + WHERE v.parent_id = ${article.id} + ORDER BY v.created_at DESC, l._locale + `) + + // Verify that _status column exists and has data for each locale + expect(localesData.rows.length).toBeGreaterThan(0) + const enRows = localesData.rows.filter((row) => row._locale === 'en') + const esRows = localesData.rows.filter((row) => row._locale === 'es') + + expect(enRows.length).toBeGreaterThan(0) + expect(esRows.length).toBeGreaterThan(0) + + // Verify each row has a status value + enRows.forEach((row) => { + expect(row._status).toBeDefined() + expect(['draft', 'published']).toContain(row._status) + }) + + esRows.forEach((row) => { + expect(row._status).toBeDefined() + expect(['draft', 'published']).toContain(row._status) + }) + + // Step 5: Test rollback + await localizeStatus.down({ + collectionSlug: 'testMigrationArticles', + db, + payload, + sql, + }) + + // Verify _status column dropped from locales table + const afterDownColumnCheck = await db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = '_test_migration_articles_v_locales' + AND column_name = '_status' + ) as exists + `) + + expect(afterDownColumnCheck.rows[0].exists).toBe(false) + + // Verify locales table still exists (because title is still localized) + const afterDownTableCheck = await db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_test_migration_articles_v_locales' + ) as exists + `) + + expect(afterDownTableCheck.rows[0].exists).toBe(true) + }) + }) + + describe('Scenario 3: Demonstrate version history migration', () => { + it('should show how version rows are transformed', async () => { + const db = payload.db + + // Create a complex version history + const post = await payload.create({ + collection: 'testMigrationPosts', + data: { title: 'Initial Draft' }, + }) + + // Publish it + await payload.update({ + id: post.id, + collection: 'testMigrationPosts', + data: { _status: 'published', title: 'Published Version' }, + }) + + // Make a draft change + await payload.update({ + id: post.id, + collection: 'testMigrationPosts', + data: { _status: 'draft', title: 'Draft Changes' }, + }) + + // Publish again + await payload.update({ + id: post.id, + collection: 'testMigrationPosts', + data: { _status: 'published', title: 'Re-published' }, + }) + + // Query BEFORE migration + const beforeVersions = await db.drizzle.execute(sql` + SELECT + id, + parent_id as parent, + version__status as _status, + created_at, + snapshot + FROM _test_migration_posts_v + WHERE parent_id = ${post.id} + ORDER BY created_at ASC + `) + + console.log('\n========== BEFORE MIGRATION ==========') + console.log('Version rows (OLD system with single _status column):') + beforeVersions.rows.forEach((row, idx) => { + console.log( + `V${idx + 1}: id=${row.id}, _status=${row._status}, snapshot=${row.snapshot || false}`, + ) + }) + + // Run migration + await localizeStatus.up({ + collectionSlug: 'testMigrationPosts', + db, + payload, + sql, + }) + + // Query AFTER migration + const afterLocales = await db.drizzle.execute(sql` + SELECT + v.id as version_id, + v.created_at, + l._locale, + l._status + FROM _test_migration_posts_v v + JOIN _test_migration_posts_v_locales l ON l._parent_id = v.id + WHERE v.parent_id = ${post.id} + ORDER BY v.created_at ASC, l._locale ASC + `) + + console.log('\n========== AFTER MIGRATION ==========') + console.log('Version rows (NEW system with per-locale _status):') + + let currentVersionId: any = null + let versionIndex = 0 + + afterLocales.rows.forEach((row) => { + if (row.version_id !== currentVersionId) { + currentVersionId = row.version_id + versionIndex++ + console.log(`\nV${versionIndex}: version_id=${row.version_id}`) + } + console.log(` ${row._locale}: ${row._status}`) + }) + console.log('\n======================================\n') + + // Verify the migration logic + expect(beforeVersions.rows.length).toBeGreaterThan(0) + expect(afterLocales.rows).toHaveLength(beforeVersions.rows.length * 3) // 3 locales + }) + }) + + describe('Scenario 4: Test publishedLocale handling', () => { + it('should handle publishedLocale correctly', async () => { + const db = payload.db + + // Use testMigrationArticles which has localized fields and thus an existing locales table + // This will trigger the intelligent migration path with publishedLocale handling + await db.drizzle.execute(sql`DELETE FROM _test_migration_articles_v`) + await db.drizzle.execute(sql`DELETE FROM _test_migration_articles_v_locales`) + + // Create a parent article document + const article = await payload.create({ + collection: 'testMigrationArticles' as any, + data: { + title: 'Test Article for publishedLocale', + }, + }) + + const parentId = article.id + + // Delete the auto-created version so we can insert our own manual test versions + await db.drizzle.execute(sql` + DELETE FROM _test_migration_articles_v WHERE parent_id = ${parentId} + `) + await db.drizzle.execute(sql` + DELETE FROM _test_migration_articles_v_locales WHERE _parent_id IN ( + SELECT id FROM _test_migration_articles_v WHERE parent_id = ${parentId} + ) + `) + + // Helper to insert version with locales rows + const insertVersion = async ( + status: 'draft' | 'published', + publishedLocale: null | string, + intervalSeconds: number, + ) => { + const result = await db.drizzle.execute(sql` + INSERT INTO _test_migration_articles_v (parent_id, version__status, published_locale, created_at, updated_at) + VALUES ( + ${parentId}, + ${status}, + ${publishedLocale}, + NOW() + INTERVAL '${sql.raw(intervalSeconds.toString())} seconds', + NOW() + INTERVAL '${sql.raw(intervalSeconds.toString())} seconds' + ) + RETURNING id + `) + const versionId = result.rows[0]?.id + if (!versionId) { + throw new Error('Failed to insert version') + } + + // Create locales rows for this version (without _status, that will be added by migration) + for (const locale of ['en', 'es', 'de']) { + await db.drizzle.execute(sql` + INSERT INTO _test_migration_articles_v_locales (_locale, _parent_id) + VALUES (${locale}, ${versionId}) + `) + } + } + + // V1: Initial draft + await insertVersion('draft', null, 0) + + // V2: Publish all locales (no publishedLocale) + await insertVersion('published', null, 1) + + // V3: Publish only 'en' locale + await insertVersion('published', 'en', 2) + + // V4: Draft save (unpublish all) + await insertVersion('draft', null, 3) + + // V5: Publish only 'es' locale + await insertVersion('published', 'es', 4) + + // V6: Publish only 'de' locale + await insertVersion('published', 'de', 5) + + // Query BEFORE migration + const beforeVersions = await db.drizzle.execute(sql` + SELECT + id, + version__status as _status, + published_locale + FROM _test_migration_articles_v + WHERE parent_id = ${parentId} + ORDER BY created_at ASC + `) + + console.log('\n========== BEFORE MIGRATION (with publishedLocale) ==========') + beforeVersions.rows.forEach((row, idx) => { + console.log( + `V${idx + 1}: _status=${row._status}, publishedLocale=${row.published_locale || 'null'}`, + ) + }) + + // Run migration + await localizeStatus.up({ + collectionSlug: 'testMigrationArticles', + db, + payload, + sql, + }) + + // Query AFTER migration + const afterLocales = await db.drizzle.execute(sql` + SELECT + v.id as version_id, + l._locale, + l._status + FROM _test_migration_articles_v v + JOIN _test_migration_articles_v_locales l ON l._parent_id = v.id + WHERE v.parent_id = ${parentId} + ORDER BY v.created_at ASC, l._locale ASC + `) + + console.log('\n========== AFTER MIGRATION (with publishedLocale) ==========') + + let currentVersionId: any = null + let versionIndex = 0 + + afterLocales.rows.forEach((row) => { + if (row.version_id !== currentVersionId) { + currentVersionId = row.version_id + versionIndex++ + console.log(`\nV${versionIndex}:`) + } + console.log(` ${row._locale}: ${row._status}`) + }) + console.log('\n======================================\n') + + // Verify the expected results + const versionGroups = afterLocales.rows.reduce( + (acc, row) => { + if (!acc[row.version_id]) { + acc[row.version_id] = [] + } + acc[row.version_id].push(row) + return acc + }, + {} as Record, + ) + + const versions = Object.values(versionGroups) + + // V1: Initial draft β†’ all draft + expect(versions[0].every((row) => row._status === 'draft')).toBe(true) + + // V2: Publish all (no publishedLocale) β†’ all published + expect(versions[1].every((row) => row._status === 'published')).toBe(true) + + // V3: Publish only 'en' β†’ en=published, others stay published + const v3 = versions[2] + expect(v3.find((r) => r._locale === 'en')._status).toBe('published') + expect(v3.find((r) => r._locale === 'es')._status).toBe('published') + expect(v3.find((r) => r._locale === 'de')._status).toBe('published') + + // V4: Draft save β†’ all draft + expect(versions[3].every((row) => row._status === 'draft')).toBe(true) + + // V5: Publish only 'es' β†’ es=published, others stay draft + const v5 = versions[4] + expect(v5.find((r) => r._locale === 'en')._status).toBe('draft') + expect(v5.find((r) => r._locale === 'es')._status).toBe('published') + expect(v5.find((r) => r._locale === 'de')._status).toBe('draft') + + // V6: Publish only 'de' β†’ de=published, en=draft, es=published + const v6 = versions[5] + expect(v6.find((r) => r._locale === 'en')._status).toBe('draft') + expect(v6.find((r) => r._locale === 'es')._status).toBe('published') + expect(v6.find((r) => r._locale === 'de')._status).toBe('published') + }) + }) + + describe('Scenario 5: Skip collections without versions', () => { + it('should skip migration for collections without versions enabled', async () => { + // Create a document in the collection without versions + const doc = await payload.create({ + collection: 'testNoVersions', + data: { title: 'Test document' }, + }) + + expect(doc.id).toBeDefined() + + // Attempt to run the migration - it should return early without error + await expect( + localizeStatus.up({ + collectionSlug: 'testNoVersions', + db: payload.db, + payload, + sql, + }), + ).resolves.not.toThrow() + + // Verify that no versions table was created (since versions weren't enabled) + const tableExists = await payload.db.drizzle.execute(sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = '_test_no_versions_v' + ) as exists + `) + + expect(tableExists.rows[0].exists).toBe(false) + }) + }) + }) + + // MongoDB-specific tests + const describeMongo = + process.env.PAYLOAD_DATABASE === 'mongodb' ? global.describe : global.describe.skip + + let mongoPayload: Payload + + describeMongo('MongoDB', () => { + // eslint-disable-next-line jest/no-duplicate-hooks + beforeAll(async () => { + const result = await initPayloadInt(dirname, undefined, undefined, 'localizeStatus.config.ts') + mongoPayload = result.payload + }, 30000) + + // eslint-disable-next-line jest/no-duplicate-hooks + afterAll(async () => { + if (mongoPayload?.db && typeof mongoPayload.db.destroy === 'function') { + await mongoPayload.db.destroy() + } + }, 30000) + + describe('MongoDB version status migration', () => { + it('should migrate version._status from string to per-locale object', async () => { + // Step 1: Create a post with a version + const post = await mongoPayload.create({ + collection: 'testMigrationPosts', + data: { title: 'MongoDB Test Post' }, + }) + + // Publish the post + await mongoPayload.update({ + id: post.id, + collection: 'testMigrationPosts', + data: { _status: 'published', title: 'MongoDB Test Post Published' }, + }) + + // Step 2: Get MongoDB connection and verify "before" state + const connection = (mongoPayload.db as any).connection + const versionsCollection = '_testmigrationposts_versions' // MongoDB uses lowercase + + // MongoDB stores parent as ObjectId, not string + const beforeVersions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(post.id) }) + .toArray() + + expect(beforeVersions.length).toBeGreaterThan(0) + // Verify version._status is currently a string + const latestVersion = beforeVersions[beforeVersions.length - 1] + expect(typeof latestVersion.version._status).toBe('string') + + // Step 3: Run the migration + await localizeStatus.up({ + collectionSlug: 'testMigrationPosts', + payload: mongoPayload, + }) + + // Step 4: Verify "after" state - version._status should now be an object + const afterVersions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(post.id) }) + .toArray() + + expect(afterVersions.length).toBeGreaterThan(0) + const migratedVersion = afterVersions[afterVersions.length - 1] + expect(typeof migratedVersion.version._status).toBe('object') + expect(migratedVersion.version._status).toHaveProperty('en') + expect(migratedVersion.version._status).toHaveProperty('es') + expect(migratedVersion.version._status).toHaveProperty('de') + + // Verify statuses match the published state (all locales published) + expect(migratedVersion.version._status.en).toBe('published') + expect(migratedVersion.version._status.es).toBe('published') + expect(migratedVersion.version._status.de).toBe('published') + }) + + it('should handle publishedLocale when migrating', async () => { + // Step 1: Create an article + const article = await mongoPayload.create({ + collection: 'testMigrationArticles', + data: { title: 'Article' }, + locale: 'en', + }) + + // Step 2: Publish only English locale + await mongoPayload.update({ + id: article.id, + collection: 'testMigrationArticles', + data: { _status: 'published', title: 'Published Article' }, + publishSpecificLocale: 'en', + }) + + // Step 3: Run the migration + const connection = (mongoPayload.db as any).connection + const versionsCollection = '_testmigrationarticles_versions' + await localizeStatus.up({ + collectionSlug: 'testMigrationArticles', + payload: mongoPayload, + }) + + // Step 4: Verify the latest version has correct per-locale statuses + // MongoDB stores parent as ObjectId, not string + const versions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(article.id) }) + .sort({ createdAt: -1 }) + .limit(1) + .toArray() + + expect(versions).toHaveLength(1) + const latestVersion = versions[0] + + // English should be published, other locales should be draft + expect(latestVersion.version._status.en).toBe('published') + expect(latestVersion.version._status.es).toBe('draft') + expect(latestVersion.version._status.de).toBe('draft') + }) + + it('should rollback migration correctly', async () => { + // Step 0: Clear existing data to start fresh (test 1 already migrated this collection) + const connection = (mongoPayload.db as any).connection + const versionsCollection = '_testmigrationposts_versions' // MongoDB uses lowercase + const mainCollection = 'testmigrationposts' + + await connection.collection(versionsCollection).deleteMany({}) + await connection.collection(mainCollection).deleteMany({}) + + // Step 1: Create test data + const post = await mongoPayload.create({ + collection: 'testMigrationPosts', + data: { title: 'Rollback Test' }, + }) + + await mongoPayload.update({ + id: post.id, + collection: 'testMigrationPosts', + data: { _status: 'published' }, + }) + + // Step 2: Run up migration + await localizeStatus.up({ + collectionSlug: 'testMigrationPosts', + payload: mongoPayload, + }) + + // Verify status is now an object (check latest version) + const afterUpVersions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(post.id) }) + .sort({ createdAt: -1 }) + .limit(1) + .toArray() + const afterUp = afterUpVersions[0] + expect(typeof afterUp.version._status).toBe('object') + + // Step 3: Run down migration + await localizeStatus.down({ + collectionSlug: 'testMigrationPosts', + payload: mongoPayload, + }) + + // Step 4: Verify status is back to a string + // Get the LATEST version (most recent) + const afterDownVersions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(post.id) }) + .sort({ createdAt: -1 }) + .limit(1) + .toArray() + const afterDown = afterDownVersions[0] + + expect(typeof afterDown.version._status).toBe('string') + expect(afterDown.version._status).toBe('published') + }) + }) + }) +}) diff --git a/test/localization/testMigration.ts b/test/localization/testMigration.ts new file mode 100644 index 00000000000..f63573b49a2 --- /dev/null +++ b/test/localization/testMigration.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env tsx +/** + * Interactive test script for localizeStatus migration + * + * Usage: + * # PostgreSQL: + * PAYLOAD_DATABASE=postgres tsx test/localization/testMigration.ts + * + * # MongoDB: + * PAYLOAD_DATABASE=mongodb tsx test/localization/testMigration.ts + */ + +import { sql } from '@payloadcms/db-postgres' +import { Types } from 'mongoose' +import path from 'path' +import { localizeStatus } from 'payload/migrations' +import { fileURLToPath } from 'url' + +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +async function main() { + console.log('πŸš€ Starting localizeStatus migration test...\n') + + const dbType = process.env.PAYLOAD_DATABASE || 'mongodb' + console.log(`Database: ${dbType}\n`) + + // Initialize Payload + const { payload } = await initPayloadInt( + dirname, + undefined, + undefined, + 'localizeStatus.config.ts', + ) + + console.log('βœ… Payload initialized\n') + + // Step 1: Create test data + console.log('πŸ“ Creating test data...') + const post = await payload.create({ + collection: 'testMigrationPosts', + data: { title: 'Test Post' }, + }) + console.log(` Created post: ${post.id}`) + + await payload.update({ + id: post.id, + collection: 'testMigrationPosts', + data: { _status: 'published', title: 'Published Post' }, + }) + console.log(` Published post: ${post.id}\n`) + + // Step 2: Check "before" state + console.log('πŸ” Checking BEFORE state...') + if (dbType === 'mongodb') { + const connection = (payload.db as any).connection + const versionsCollection = '_testmigrationposts_versions' + const versions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(post.id) }) + .toArray() + + console.log(` Found ${versions.length} versions`) + if (versions.length > 0) { + const latest = versions[versions.length - 1] + console.log(` Latest version._status type: ${typeof latest.version._status}`) + console.log(` Latest version._status value: ${latest.version._status}`) + } + } else { + const db = payload.db as any + const result = await db.drizzle.execute(sql` + SELECT id, version__status as _status + FROM _test_migration_posts_v + WHERE parent_id = ${post.id} + ORDER BY created_at DESC + LIMIT 1 + `) + if (result.rows.length > 0) { + console.log(` Found ${result.rows.length} versions`) + console.log(` Latest version._status: ${result.rows[0]._status}`) + } + } + console.log() + + // Step 3: Run migration + console.log('πŸ”„ Running UP migration...') + if (dbType === 'mongodb') { + await localizeStatus.up({ + collectionSlug: 'testMigrationPosts', + payload, + }) + } else { + await localizeStatus.up({ + collectionSlug: 'testMigrationPosts', + db: payload.db, + payload, + sql, + }) + } + console.log('βœ… UP migration completed\n') + + // Step 4: Check "after" state + console.log('πŸ” Checking AFTER state...') + if (dbType === 'mongodb') { + const connection = (payload.db as any).connection + const versionsCollection = '_testmigrationposts_versions' + const versions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(post.id) }) + .toArray() + + if (versions.length > 0) { + const latest = versions[versions.length - 1] + console.log(` Latest version._status type: ${typeof latest.version._status}`) + console.log( + ` Latest version._status value:`, + JSON.stringify(latest.version._status, null, 2), + ) + } + } else { + const db = payload.db as any + const result = await db.drizzle.execute(sql` + SELECT v.id as version_id, l._locale, l._status + FROM _test_migration_posts_v v + JOIN _test_migration_posts_v_locales l ON l._parent_id = v.id + WHERE v.parent_id = ${post.id} + ORDER BY v.created_at DESC, l._locale + LIMIT 3 + `) + if (result.rows.length > 0) { + console.log(' Localized statuses:') + result.rows.forEach((row: any) => { + console.log(` ${row._locale}: ${row._status}`) + }) + } + } + console.log() + + // Step 5: Run rollback + console.log('βͺ Running DOWN migration (rollback)...') + if (dbType === 'mongodb') { + await localizeStatus.down({ + collectionSlug: 'testMigrationPosts', + payload, + }) + } else { + await localizeStatus.down({ + collectionSlug: 'testMigrationPosts', + db: payload.db, + payload, + sql, + }) + } + console.log('βœ… DOWN migration completed\n') + + // Step 6: Check "rolled back" state + console.log('πŸ” Checking ROLLED BACK state...') + if (dbType === 'mongodb') { + const connection = (payload.db as any).connection + const versionsCollection = '_testmigrationposts_versions' + const versions = await connection + .collection(versionsCollection) + .find({ parent: new Types.ObjectId(post.id) }) + .toArray() + + if (versions.length > 0) { + const latest = versions[versions.length - 1] + console.log(` Latest version._status type: ${typeof latest.version._status}`) + console.log(` Latest version._status value: ${latest.version._status}`) + } + } else { + const db = payload.db as any + const result = await db.drizzle.execute(sql` + SELECT id, version__status as _status + FROM _test_migration_posts_v + WHERE parent_id = ${post.id} + ORDER BY created_at DESC + LIMIT 1 + `) + if (result.rows.length > 0) { + console.log(` Latest version._status: ${result.rows[0]._status}`) + } + } + console.log() + + // Cleanup + await payload.db.destroy() + console.log('✨ Test completed successfully!') +} + +main().catch((err) => { + console.error('❌ Error:', err) + process.exit(1) +})