diff --git a/packages/payload/package.json b/packages/payload/package.json index c096d10f9d4..19c6e8b5e21 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -65,6 +65,11 @@ "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" + }, "./__testing__/predefinedMigration": { "import": "./src/__testing__/predefinedMigration.js", "default": "./src/__testing__/predefinedMigration.js" @@ -181,6 +186,11 @@ "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" + }, "./__testing__/predefinedMigration": { "import": "./dist/__testing__/predefinedMigration.js", "default": "./dist/__testing__/predefinedMigration.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 1b7baee83c1..435fe087f79 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1810,6 +1810,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..db5f847b58e --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/down.ts @@ -0,0 +1,252 @@ +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.version__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 version__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', 'version__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 version__status column + payload.logger.info({ msg: `Dropping version__status column from ${localesTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(localesTable)} DROP COLUMN version__status + `, + }) + } + + // 4. Restore _status to main collection/global table if it was dropped + const mainTable = collectionSlug ? toSnakeCase(collectionSlug) : toSnakeCase(globalSlug!) + const mainLocalesTable = `${mainTable}_locales` + + // Check if _status exists in the main table + const statusInMainTableCheck = 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 (!statusInMainTableCheck.rows[0]?.exists) { + // _status column doesn't exist in main table, need to restore it + // Check if main collection/global has a locales table + const mainLocalesTableCheck = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${mainLocalesTable} + ) as exists + `, + }) + + if (mainLocalesTableCheck.rows[0]?.exists) { + // Locales table exists - check if _status is there + const statusInLocalesCheck = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${mainLocalesTable} + AND column_name = '_status' + ) as exists + `, + }) + + if (statusInLocalesCheck.rows[0]?.exists) { + // Add _status back to main table + payload.logger.info({ msg: `Restoring _status column to ${mainTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(mainTable)} ADD COLUMN _status VARCHAR + `, + }) + + // Copy status from default locale back to main table + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(mainTable)} m + SET _status = l._status + FROM ${sql.identifier(mainLocalesTable)} l + WHERE m.id = l._parent_id + AND l._locale = ${defaultLocale} + `, + }) + + // Drop _status from locales table + payload.logger.info({ msg: `Dropping _status column from ${mainLocalesTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(mainLocalesTable)} DROP COLUMN _status + `, + }) + } + } else { + // No locales table exists - this means collection/global has no localized fields + // Just add _status back to main table with default values + payload.logger.info({ + msg: `Restoring _status column to ${mainTable} (no locales table exists)`, + }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(mainTable)} ADD COLUMN _status VARCHAR + `, + }) + + // Set default status based on latest version status for each document + // Get all documents in the collection + const documents = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT DISTINCT id + FROM ${sql.identifier(mainTable)} + `, + }) + + for (const doc of documents.rows) { + // Get latest version status for this document + const latestVersionStatus = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT version__status + FROM ${sql.identifier(versionsTable)} + WHERE parent_id = ${doc.id} + ORDER BY created_at DESC + LIMIT 1 + `, + }) + + const status = latestVersionStatus.rows[0]?.version__status || 'draft' + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(mainTable)} + SET _status = ${status} + WHERE id = ${doc.id} + `, + }) + } + + payload.logger.info({ msg: `Restored _status for ${documents.rows.length} documents` }) + } + } + + 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..aeebc9c0a43 --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainCollection.ts @@ -0,0 +1,69 @@ +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) + const mainLocalesTable = `${mainTable}_locales` + + payload.logger.info({ msg: `Migrating main collection locales for: ${mainLocalesTable}` }) + + // Get all documents + const documents = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT DISTINCT id + FROM ${sql.identifier(mainTable)} + `, + }) + + for (const doc of documents.rows) { + // For each locale, get the latest version status + for (const locale of locales) { + const latestVersionStatus = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT l.version__status as _status + FROM ${sql.identifier(versionsTable)} v + JOIN ${sql.raw(`${versionsTable}_locales`)} l ON l._parent_id = v.id + WHERE v.parent_id = ${doc.id} + AND l._locale = ${locale} + ORDER BY v.created_at DESC + LIMIT 1 + `, + }) + + const status = latestVersionStatus.rows[0]?._status || 'draft' + + // Update the main collection's locales table with this status + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(mainLocalesTable)} + SET _status = ${status} + WHERE _parent_id = ${doc.id} + AND _locale = ${locale} + `, + }) + } + } + + 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..e3469d4a617 --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/migrateMainGlobal.ts @@ -0,0 +1,72 @@ +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) + const globalLocalesTable = `${globalTable}_locales` + + payload.logger.info({ msg: `Migrating main global locales for: ${globalLocalesTable}` }) + + // For each locale, get the latest version status + for (const locale of locales) { + const latestVersionStatus = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT l.version__status as _status + FROM ${sql.identifier(versionsTable)} v + JOIN ${sql.raw(`${versionsTable}_locales`)} l ON l._parent_id = v.id + WHERE l._locale = ${locale} + ORDER BY v.created_at DESC + LIMIT 1 + `, + }) + + const status = latestVersionStatus.rows[0]?._status || 'draft' + + // Get the global document ID from the globals table + const globalDoc = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT id FROM ${sql.identifier(globalTable)} LIMIT 1 + `, + }) + + if (globalDoc.rows.length === 0) { + payload.logger.warn({ msg: `No global document found for ${globalSlug}, skipping` }) + continue + } + + const globalId = globalDoc.rows[0].id + + // Update the global's locales table with this status + await db.execute({ + drizzle: db.drizzle, + sql: sql` + UPDATE ${sql.identifier(globalLocalesTable)} + SET _status = ${status} + WHERE _parent_id = ${globalId} + AND _locale = ${locale} + `, + }) + } + + 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..0c959bc4dcf --- /dev/null +++ b/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts @@ -0,0 +1,311 @@ +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, + version__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, version__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 version__status column to existing locales table + payload.logger.info({ msg: `Adding version__status column to existing table: ${localesTable}` }) + + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(localesTable)} ADD COLUMN version__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 version__status = ${status} + WHERE _parent_id = ${versionId} + AND _locale = ${locale} + `, + }) + updateCount++ + } + } + + payload.logger.info({ msg: `Updated ${updateCount} locale rows with status` }) + } + + // 3. Drop the old version__status column from main versions table + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(versionsTable)} DROP COLUMN version__status + `, + }) + + // 4. Create and populate _status column in main collection/global locales table + // With localizeStatus enabled, _status is a localized field stored in the collection's locales table + // We need to create this column and populate it based on the latest version status per locale + const mainTable = collectionSlug ? toSnakeCase(collectionSlug) : toSnakeCase(globalSlug!) + const mainLocalesTable = `${mainTable}_locales` + + const localesTableCheck = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${mainLocalesTable} + ) as exists + `, + }) + + if (localesTableCheck.rows[0]?.exists) { + // Check if _status column already exists in the locales table + const statusColumnCheck = await db.execute({ + drizzle: db.drizzle, + sql: sql` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${mainLocalesTable} + AND column_name = '_status' + ) as exists + `, + }) + + if (!statusColumnCheck.rows[0]?.exists) { + // Add _status column to locales table + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(mainLocalesTable)} + ADD COLUMN _status VARCHAR DEFAULT 'draft' + `, + }) + } + + // Now populate the _status values from the latest version + 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: `No locales table found: ${mainLocalesTable} (collection/global not localized)`, + }) + } + + // 5. Drop _status from main table if it exists (it will be in locales table now) + const mainTableStatusCheck = 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 (mainTableStatusCheck.rows[0]?.exists) { + await db.execute({ + drizzle: db.drizzle, + sql: sql` + ALTER TABLE ${sql.identifier(mainTable)} DROP COLUMN _status + `, + }) + } + + 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..7ae501b4eff --- /dev/null +++ b/test/localization/localizeStatus.int.spec.ts @@ -0,0 +1,732 @@ +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 { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +let payload: Payload + +describe('localizeStatus migration', () => { + beforeAll(async () => { + const result = await initPayloadInt(dirname, undefined, undefined, 'localizeStatus.config.ts') + payload = result.payload + }) + afterAll(async () => { + if (payload?.db && typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + describe.skipIf(process.env.PAYLOAD_DATABASE !== 'postgres')('PostgreSQL', () => { + 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, version__status as _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 version__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 = 'version__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.version__status as _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 version__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 = 'version__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.version__status as _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.version__status as _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) + }) + }) + }) + + describe.skipIf(process.env.PAYLOAD_DATABASE !== 'mongodb')('MongoDB', () => { + 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 payload.create({ + collection: 'testMigrationPosts', + data: { title: 'MongoDB Test Post' }, + }) + + // Publish the post + await payload.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 = (payload.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, + }) + + // 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 payload.create({ + collection: 'testMigrationArticles', + data: { title: 'Article' }, + locale: 'en', + }) + + // Step 2: Publish only English locale + await payload.update({ + id: article.id, + collection: 'testMigrationArticles', + data: { _status: 'published', title: 'Published Article' }, + publishSpecificLocale: 'en', + }) + + // Step 3: Run the migration + const connection = (payload.db as any).connection + const versionsCollection = '_testmigrationarticles_versions' + await localizeStatus.up({ + collectionSlug: 'testMigrationArticles', + payload, + }) + + // 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 = (payload.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 payload.create({ + collection: 'testMigrationPosts', + data: { title: 'Rollback Test' }, + }) + + await payload.update({ + id: post.id, + collection: 'testMigrationPosts', + data: { _status: 'published' }, + }) + + // Step 2: Run up migration + await localizeStatus.up({ + collectionSlug: 'testMigrationPosts', + payload, + }) + + // 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, + }) + + // 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/payload-types.ts b/test/localization/payload-types.ts index 55fd6361c15..f69e51f0fe9 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -124,7 +124,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; fallbackLocale: | ('false' | 'none' | 'null') @@ -135,10 +135,12 @@ export interface Config { globals: { 'global-array': GlobalArray; 'global-text': GlobalText; + 'global-drafts': GlobalDraft; }; globalsSelect: { 'global-array': GlobalArraySelect | GlobalArraySelect; 'global-text': GlobalTextSelect | GlobalTextSelect; + 'global-drafts': GlobalDraftsSelect | GlobalDraftsSelect; }; locale: 'xx' | 'en' | 'es' | 'pt' | 'ar' | 'hu'; user: User & { @@ -172,7 +174,7 @@ export interface UserAuthOperations { * via the `definition` "richText". */ export interface RichText { - id: string; + id: number; richText?: | { [k: string]: unknown; @@ -201,7 +203,7 @@ export interface RichText { * via the `definition` "blocks-fields". */ export interface BlocksField { - id: string; + id: number; title?: string | null; tabContent?: | { @@ -244,12 +246,12 @@ export interface BlocksField { * via the `definition` "nested-arrays". */ export interface NestedArray { - id: string; + id: number; arrayWithBlocks?: | { blocksWithinArray?: | { - relationWithinBlock?: (string | null) | LocalizedPost; + relationWithinBlock?: (number | null) | LocalizedPost; myGroup?: { text?: string | null; }; @@ -263,7 +265,7 @@ export interface NestedArray { | null; arrayWithLocalizedRelation?: | { - localizedRelation?: (string | null) | LocalizedPost; + localizedRelation?: (number | null) | LocalizedPost; id?: string | null; }[] | null; @@ -276,12 +278,12 @@ export interface NestedArray { * via the `definition` "localized-posts". */ export interface LocalizedPost { - id: string; + id: number; title?: string | null; description?: string | null; localizedDescription?: string | null; localizedCheckbox?: boolean | null; - children?: (string | LocalizedPost)[] | null; + children?: (number | LocalizedPost)[] | null; group?: { children?: string | null; }; @@ -294,18 +296,18 @@ export interface LocalizedPost { * via the `definition` "nested-field-tables". */ export interface NestedFieldTable { - id: string; + id: number; array?: | { relation?: { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null; - hasManyRelation?: (string | LocalizedPost)[] | null; + hasManyRelation?: (number | LocalizedPost)[] | null; hasManyPolyRelation?: | { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; }[] | null; select?: ('one' | 'two' | 'three')[] | null; @@ -320,7 +322,7 @@ export interface NestedFieldTable { | { relation?: { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null; id?: string | null; blockName?: string | null; @@ -331,7 +333,7 @@ export interface NestedFieldTable { | { relation?: { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null; id?: string | null; }[] @@ -349,7 +351,7 @@ export interface NestedFieldTable { * via the `definition` "localized-drafts". */ export interface LocalizedDraft { - id: string; + id: number; title?: string | null; updatedAt: string; createdAt: string; @@ -360,7 +362,7 @@ export interface LocalizedDraft { * via the `definition` "localized-date-fields". */ export interface LocalizedDateField { - id: string; + id: number; localizedDate?: string | null; date?: string | null; updatedAt: string; @@ -372,7 +374,7 @@ export interface LocalizedDateField { * via the `definition` "all-fields-localized". */ export interface AllFieldsLocalized { - id: string; + id: number; text?: string | null; textarea?: string | null; number?: number | null; @@ -454,7 +456,7 @@ export interface AllFieldsLocalized { | null; }; }; - selfRelation?: (string | null) | AllFieldsLocalized; + selfRelation?: (number | null) | AllFieldsLocalized; updatedAt: string; createdAt: string; _status?: ('draft' | 'published') | null; @@ -464,9 +466,9 @@ export interface AllFieldsLocalized { * via the `definition` "users". */ export interface User { - id: string; + id: number; name?: string | null; - relation?: (string | null) | LocalizedPost; + relation?: (number | null) | LocalizedPost; updatedAt: string; createdAt: string; email: string; @@ -490,7 +492,7 @@ export interface User { * via the `definition` "no-localized-fields". */ export interface NoLocalizedField { - id: string; + id: number; text?: string | null; group?: { en?: { @@ -506,7 +508,7 @@ export interface NoLocalizedField { * via the `definition` "array-fields". */ export interface ArrayField { - id: string; + id: number; items?: | { text?: string | null; @@ -521,7 +523,7 @@ export interface ArrayField { * via the `definition` "localized-required". */ export interface LocalizedRequired { - id: string; + id: number; title: string; nav: { layout: ( @@ -581,27 +583,27 @@ export interface LocalizedRequired { * via the `definition` "with-localized-relationship". */ export interface WithLocalizedRelationship { - id: string; - localizedRelationship?: (string | null) | LocalizedPost; - localizedRelationHasManyField?: (string | LocalizedPost)[] | null; + id: number; + localizedRelationship?: (number | null) | LocalizedPost; + localizedRelationHasManyField?: (number | LocalizedPost)[] | null; localizedRelationMultiRelationTo?: | ({ relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } | null); localizedRelationMultiRelationToHasMany?: | ( | { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | { relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } )[] | null; @@ -613,7 +615,7 @@ export interface WithLocalizedRelationship { * via the `definition` "cannot-create-default-locale". */ export interface CannotCreateDefaultLocale { - id: string; + id: number; name?: string | null; updatedAt: string; createdAt: string; @@ -623,33 +625,33 @@ export interface CannotCreateDefaultLocale { * via the `definition` "relationship-localized". */ export interface RelationshipLocalized { - id: string; - relationship?: (string | null) | LocalizedPost; - relationshipHasMany?: (string | LocalizedPost)[] | null; + id: number; + relationship?: (number | null) | LocalizedPost; + relationshipHasMany?: (number | LocalizedPost)[] | null; relationMultiRelationTo?: | ({ relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } | null); relationMultiRelationToHasMany?: | ( | { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | { relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } )[] | null; arrayField?: | { - nestedRelation?: (string | null) | LocalizedPost; + nestedRelation?: (number | null) | LocalizedPost; id?: string | null; }[] | null; @@ -661,7 +663,7 @@ export interface RelationshipLocalized { * via the `definition` "nested". */ export interface Nested { - id: string; + id: number; blocks?: | { someText?: string | null; @@ -698,7 +700,7 @@ export interface Nested { * via the `definition` "groups". */ export interface Group { - id: string; + id: number; groupLocalizedRow?: { text?: string | null; }; @@ -732,7 +734,7 @@ export interface Group { * via the `definition` "tabs". */ export interface Tab { - id: string; + id: number; tabLocalized?: { title?: string | null; array?: @@ -769,7 +771,7 @@ export interface Tab { * via the `definition` "localized-sort". */ export interface LocalizedSort { - id: string; + id: number; title?: string | null; date?: string | null; updatedAt: string; @@ -780,7 +782,7 @@ export interface LocalizedSort { * via the `definition` "blocks-same-name". */ export interface BlocksSameName { - id: string; + id: number; blocks?: | ( | { @@ -805,7 +807,7 @@ export interface BlocksSameName { * via the `definition` "localized-within-localized". */ export interface LocalizedWithinLocalized { - id: string; + id: number; myTab?: { shouldNotBeLocalized?: string | null; }; @@ -834,7 +836,7 @@ export interface LocalizedWithinLocalized { * via the `definition` "array-with-fallback-fields". */ export interface ArrayWithFallbackField { - id: string; + id: number; items: { text?: string | null; id?: string | null; @@ -853,7 +855,7 @@ export interface ArrayWithFallbackField { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: string; + id: number; key: string; data: | { @@ -870,104 +872,100 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'richText'; - value: string | RichText; + value: number | RichText; } | null) | ({ relationTo: 'blocks-fields'; - value: string | BlocksField; + value: number | BlocksField; } | null) | ({ relationTo: 'nested-arrays'; - value: string | NestedArray; + value: number | NestedArray; } | null) | ({ relationTo: 'nested-field-tables'; - value: string | NestedFieldTable; + value: number | NestedFieldTable; } | null) | ({ relationTo: 'localized-drafts'; - value: string | LocalizedDraft; + value: number | LocalizedDraft; } | null) | ({ relationTo: 'localized-date-fields'; - value: string | LocalizedDateField; + value: number | LocalizedDateField; } | null) | ({ relationTo: 'all-fields-localized'; - value: string | AllFieldsLocalized; + value: number | AllFieldsLocalized; } | null) | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null) | ({ relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null) | ({ relationTo: 'no-localized-fields'; - value: string | NoLocalizedField; + value: number | NoLocalizedField; } | null) | ({ relationTo: 'array-fields'; - value: string | ArrayField; + value: number | ArrayField; } | null) | ({ relationTo: 'localized-required'; - value: string | LocalizedRequired; + value: number | LocalizedRequired; } | null) | ({ relationTo: 'with-localized-relationship'; - value: string | WithLocalizedRelationship; + value: number | WithLocalizedRelationship; } | null) | ({ relationTo: 'relationship-localized'; - value: string | RelationshipLocalized; + value: number | RelationshipLocalized; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } | null) | ({ relationTo: 'nested'; - value: string | Nested; + value: number | Nested; } | null) | ({ relationTo: 'groups'; - value: string | Group; + value: number | Group; } | null) | ({ relationTo: 'tabs'; - value: string | Tab; + value: number | Tab; } | null) | ({ relationTo: 'localized-sort'; - value: string | LocalizedSort; + value: number | LocalizedSort; } | null) | ({ relationTo: 'blocks-same-name'; - value: string | BlocksSameName; + value: number | BlocksSameName; } | null) | ({ relationTo: 'localized-within-localized'; - value: string | LocalizedWithinLocalized; + value: number | LocalizedWithinLocalized; } | null) | ({ relationTo: 'array-with-fallback-fields'; - value: string | ArrayWithFallbackField; - } | null) - | ({ - relationTo: 'payload-kv'; - value: string | PayloadKv; + value: number | ArrayWithFallbackField; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -977,10 +975,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -1000,7 +998,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -1721,7 +1719,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "global-array". */ export interface GlobalArray { - id: string; + id: number; array?: | { text?: string | null; @@ -1736,8 +1734,19 @@ export interface GlobalArray { * via the `definition` "global-text". */ export interface GlobalText { - id: string; + id: number; + text?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "global-drafts". + */ +export interface GlobalDraft { + id: number; text?: string | null; + _status?: ('draft' | 'published') | null; updatedAt?: string | null; createdAt?: string | null; } @@ -1766,6 +1775,17 @@ export interface GlobalTextSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "global-drafts_select". + */ +export interface GlobalDraftsSelect { + text?: T; + _status?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/test/localization/testMigration.ts b/test/localization/testMigration.ts new file mode 100755 index 00000000000..3482e36c010 --- /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.version__status as _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) +})