nextjs-cms 0.9.20 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/api/index.d.ts +8 -48
  2. package/dist/api/index.d.ts.map +1 -1
  3. package/dist/api/lib/serverActions.d.ts +9 -49
  4. package/dist/api/lib/serverActions.d.ts.map +1 -1
  5. package/dist/api/lib/serverActions.js +82 -34
  6. package/dist/api/root.d.ts +16 -96
  7. package/dist/api/root.d.ts.map +1 -1
  8. package/dist/api/routers/gallery.d.ts +1 -0
  9. package/dist/api/routers/gallery.d.ts.map +1 -1
  10. package/dist/api/routers/gallery.js +36 -8
  11. package/dist/api/routers/hasItemsSection.d.ts +3 -30
  12. package/dist/api/routers/hasItemsSection.d.ts.map +1 -1
  13. package/dist/api/routers/navigation.d.ts +3 -3
  14. package/dist/api/routers/simpleSection.d.ts +1 -15
  15. package/dist/api/routers/simpleSection.d.ts.map +1 -1
  16. package/dist/cli/lib/update-sections.d.ts.map +1 -1
  17. package/dist/cli/lib/update-sections.js +606 -198
  18. package/dist/core/factories/FieldFactory.d.ts +1 -1
  19. package/dist/core/factories/FieldFactory.d.ts.map +1 -1
  20. package/dist/core/factories/FieldFactory.js +11 -9
  21. package/dist/core/fields/date-range.d.ts +4 -4
  22. package/dist/core/fields/date.d.ts.map +1 -1
  23. package/dist/core/fields/date.js +10 -6
  24. package/dist/core/fields/select.d.ts +23 -11
  25. package/dist/core/fields/select.d.ts.map +1 -1
  26. package/dist/core/fields/select.js +9 -5
  27. package/dist/core/fields/selectMultiple.d.ts +12 -4
  28. package/dist/core/fields/selectMultiple.d.ts.map +1 -1
  29. package/dist/core/fields/selectMultiple.js +10 -6
  30. package/dist/core/sections/category.d.ts +8 -6
  31. package/dist/core/sections/category.d.ts.map +1 -1
  32. package/dist/core/sections/hasItems.d.ts +8 -6
  33. package/dist/core/sections/hasItems.d.ts.map +1 -1
  34. package/dist/core/sections/section.d.ts +46 -25
  35. package/dist/core/sections/section.d.ts.map +1 -1
  36. package/dist/core/sections/section.js +18 -3
  37. package/dist/core/sections/simple.d.ts +6 -4
  38. package/dist/core/sections/simple.d.ts.map +1 -1
  39. package/dist/core/submit/ItemEditSubmit.d.ts.map +1 -1
  40. package/dist/core/submit/ItemEditSubmit.js +7 -1
  41. package/dist/core/submit/LocaleSubmit.d.ts +3 -3
  42. package/dist/core/submit/LocaleSubmit.d.ts.map +1 -1
  43. package/dist/core/submit/LocaleSubmit.js +9 -6
  44. package/dist/core/submit/submit.d.ts +4 -4
  45. package/dist/core/submit/submit.d.ts.map +1 -1
  46. package/dist/core/submit/submit.js +13 -9
  47. package/dist/core/types/index.d.ts +2 -2
  48. package/dist/core/types/index.d.ts.map +1 -1
  49. package/dist/db/cms-system-tables.d.ts.map +1 -1
  50. package/dist/db/cms-system-tables.js +2 -1
  51. package/dist/db/schema.d.ts +61 -0
  52. package/dist/db/schema.d.ts.map +1 -1
  53. package/dist/db/schema.js +8 -0
  54. package/package.json +3 -3
@@ -1,18 +1,17 @@
1
- import { db } from '../../db/client.js';
2
- import { NextJsCmsTablesTable } from '../../db/schema.js';
3
- import { eq, sql } from 'drizzle-orm';
4
1
  import fs from 'fs';
5
2
  import path from 'path';
6
- import { SectionFactory } from '../../core/factories/index.js';
7
- import { getCMSConfig } from '../../core/config/index.js';
8
- import { CheckboxField, ColorField, DateField, DateRangeField, MapField, NumberField, PasswordField, RichTextField, SelectField, SelectMultipleField, TagsField, TextAreaField, TextField, textAreaField, textField, numberField, } from '../../core/fields/index.js';
9
- import { is } from '../../core/helpers/index.js';
10
- import { FileField } from '../../core/fields/index.js';
3
+ import { intro, log, select, spinner } from '@clack/prompts';
11
4
  import chalk from 'chalk';
12
- import { intro, select, spinner, log } from '@clack/prompts';
5
+ import { eq, sql } from 'drizzle-orm';
6
+ import { getCMSConfig } from '../../core/config/index.js';
13
7
  import { MysqlTableChecker } from '../../core/db/index.js';
14
- import { generateDrizzleSchema, generateLocalesTableSchema, resolveCaseStyleFns } from '../utils/schema-generator.js';
8
+ import { SectionFactory } from '../../core/factories/index.js';
9
+ import { CheckboxField, ColorField, DateField, DateRangeField, FileField, MapField, NumberField, numberField, PasswordField, RichTextField, SelectField, SelectMultipleField, TagsField, TextAreaField, textAreaField, TextField, textField, } from '../../core/fields/index.js';
10
+ import { is } from '../../core/helpers/index.js';
11
+ import { db } from '../../db/client.js';
12
+ import { NextJsCmsTablesTable } from '../../db/schema.js';
15
13
  import { addTableKeys } from '../utils/add-table-keys.js';
14
+ import { generateDrizzleSchema, generateLocalesTableSchema, resolveCaseStyleFns } from '../utils/schema-generator.js';
16
15
  /**
17
16
  * Returns all DB column names a field owns.
18
17
  * Most fields own a single column (field.name), but rarely some fields own two,
@@ -215,6 +214,7 @@ function resolveCreateTableOptions(sectionType) {
215
214
  createdAt: true,
216
215
  };
217
216
  case 'destinationDb':
217
+ case 'selectDb':
218
218
  case 'locales':
219
219
  return {
220
220
  createdAt: true,
@@ -231,17 +231,157 @@ function resolveCreateTableOptions(sectionType) {
231
231
  };
232
232
  }
233
233
  }
234
- const STORED_DEFAULT_LOCALE_KEY = 'localization.defaultLocale';
235
- async function readStoredDefaultLocale() {
236
- const [rows] = await db.execute(sql `SELECT \`value\` FROM \`__nextjs_cms_config\` WHERE \`key\` = ${STORED_DEFAULT_LOCALE_KEY} LIMIT 1`);
234
+ const STORED_LOCALIZATION_KEY = 'localization';
235
+ const STORED_LOCALIZATION_VERSION = 1;
236
+ function isStoredLocaleConfig(value) {
237
+ return (typeof value === 'object' &&
238
+ value !== null &&
239
+ 'code' in value &&
240
+ 'label' in value &&
241
+ typeof value.code === 'string' &&
242
+ typeof value.label === 'string' &&
243
+ (!('rtl' in value) || typeof value.rtl === 'boolean' || value.rtl === undefined));
244
+ }
245
+ function isStoredLocalizationTransition(value) {
246
+ return (typeof value === 'object' &&
247
+ value !== null &&
248
+ 'type' in value &&
249
+ (value.type === 'enable' || value.type === 'disable') &&
250
+ 'status' in value &&
251
+ value.status === 'pending' &&
252
+ 'startedAt' in value &&
253
+ typeof value.startedAt === 'string' &&
254
+ (!('lastAttemptAt' in value) || typeof value.lastAttemptAt === 'string' || value.lastAttemptAt === undefined));
255
+ }
256
+ function isStoredLocalizationState(value) {
257
+ if (typeof value !== 'object' ||
258
+ value === null ||
259
+ !('version' in value) ||
260
+ value.version !== STORED_LOCALIZATION_VERSION ||
261
+ !('enabled' in value) ||
262
+ typeof value.enabled !== 'boolean') {
263
+ return false;
264
+ }
265
+ if (value.enabled === true) {
266
+ return ('defaultLocale' in value &&
267
+ typeof value.defaultLocale === 'string' &&
268
+ 'locales' in value &&
269
+ Array.isArray(value.locales) &&
270
+ value.locales.every(isStoredLocaleConfig) &&
271
+ (!('transition' in value) ||
272
+ value.transition === undefined ||
273
+ (isStoredLocalizationTransition(value.transition) && value.transition.type === 'disable')));
274
+ }
275
+ if ('transition' in value && value.transition !== undefined) {
276
+ return (isStoredLocalizationTransition(value.transition) &&
277
+ value.transition.type === 'enable' &&
278
+ 'defaultLocale' in value &&
279
+ typeof value.defaultLocale === 'string' &&
280
+ 'locales' in value &&
281
+ Array.isArray(value.locales) &&
282
+ value.locales.every(isStoredLocaleConfig));
283
+ }
284
+ return (!('defaultLocale' in value) && !('locales' in value) && !('previous' in value) && !('lastDisabledAt' in value));
285
+ }
286
+ function isStoredPendingEnableLocalizationState(state) {
287
+ return state !== null && state !== undefined && !state.enabled && state.transition?.type === 'enable';
288
+ }
289
+ function isStoredDisabledLocalizationState(state) {
290
+ return state !== null && state !== undefined && !state.enabled && state.transition === undefined;
291
+ }
292
+ function parseStoredLocalizationState(value) {
293
+ if (!value) {
294
+ return null;
295
+ }
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(value);
299
+ }
300
+ catch {
301
+ throw new Error(`Invalid stored localization state in '__nextjs_cms_config'.`);
302
+ }
303
+ if (!isStoredLocalizationState(parsed)) {
304
+ throw new Error(`Invalid stored localization state in '__nextjs_cms_config'.`);
305
+ }
306
+ return parsed;
307
+ }
308
+ async function readStoredLocalizationState() {
309
+ const [rows] = await db.execute(sql `SELECT \`value\` FROM \`__nextjs_cms_config\` WHERE \`key\` = ${STORED_LOCALIZATION_KEY} LIMIT 1`);
237
310
  const row = rows?.[0];
238
- return row?.value ?? null;
311
+ return parseStoredLocalizationState(row?.value ?? null);
312
+ }
313
+ async function writeStoredLocalizationState(state) {
314
+ const value = JSON.stringify(state);
315
+ await db.execute(sql `INSERT INTO \`__nextjs_cms_config\` (\`key\`, \`value\`) VALUES (${STORED_LOCALIZATION_KEY}, ${value}) ON DUPLICATE KEY UPDATE \`value\` = ${value}`);
239
316
  }
240
- async function writeStoredDefaultLocale(value) {
241
- await db.execute(sql `INSERT INTO \`__nextjs_cms_config\` (\`key\`, \`value\`) VALUES (${STORED_DEFAULT_LOCALE_KEY}, ${value}) ON DUPLICATE KEY UPDATE \`value\` = ${value}`);
317
+ function buildStoredEnabledLocalizationState(localization) {
318
+ return {
319
+ version: STORED_LOCALIZATION_VERSION,
320
+ enabled: true,
321
+ defaultLocale: localization.defaultLocale,
322
+ locales: localization.locales.map((locale) => ({
323
+ code: locale.code,
324
+ label: locale.label,
325
+ rtl: locale.rtl,
326
+ })),
327
+ };
328
+ }
329
+ function buildStoredPendingEnableLocalizationState(targetState, transition) {
330
+ return {
331
+ version: STORED_LOCALIZATION_VERSION,
332
+ enabled: false,
333
+ defaultLocale: targetState.defaultLocale,
334
+ locales: targetState.locales,
335
+ transition,
336
+ };
337
+ }
338
+ async function getRegisteredLocalizationArtifacts(existingTables) {
339
+ const localeColumnTables = [];
340
+ const localesTables = [];
341
+ for (const existing of existingTables) {
342
+ const cols = await MysqlTableChecker.getColumns(existing.name).catch(() => []);
343
+ if (cols.includes('locale')) {
344
+ localeColumnTables.push(existing.name);
345
+ }
346
+ if (existing.name.endsWith('_locales')) {
347
+ const stillExists = (await MysqlTableChecker.getExistingTableStructure(existing.name)) !== null;
348
+ if (stillExists) {
349
+ localesTables.push(existing.name);
350
+ }
351
+ }
352
+ }
353
+ return { localeColumnTables, localesTables };
242
354
  }
243
- async function deleteStoredDefaultLocale() {
244
- await db.execute(sql `DELETE FROM \`__nextjs_cms_config\` WHERE \`key\` = ${STORED_DEFAULT_LOCALE_KEY}`);
355
+ function isExternalSelectDbField(field) {
356
+ return ((is(field, SelectField) || is(field, SelectMultipleField)) &&
357
+ field.optionsType === 'db' &&
358
+ field.db !== null &&
359
+ field.db !== undefined);
360
+ }
361
+ function buildSelectDbFieldConfig(name, label, type = 'text', required = true) {
362
+ if (type === 'number') {
363
+ return numberField({
364
+ name,
365
+ label,
366
+ required,
367
+ order: 0,
368
+ });
369
+ }
370
+ return textField({
371
+ name,
372
+ label,
373
+ required,
374
+ order: 0,
375
+ });
376
+ }
377
+ function buildLocaleFieldConfig() {
378
+ return textField({
379
+ name: 'locale',
380
+ label: 'Locale',
381
+ required: true,
382
+ maxLength: 10,
383
+ order: 0,
384
+ });
245
385
  }
246
386
  async function ensureTableRegistryEntry(tableName, sectionName) {
247
387
  await db
@@ -370,6 +510,108 @@ async function renameTable(oldName, newName) {
370
510
  console.error(`Error renaming table \`${oldName}\` to \`${newName}\`:`, error);
371
511
  }
372
512
  }
513
+ const LOCALIZED_ASSET_DIRECTORIES = {
514
+ photo: ['.photos', '.thumbs'],
515
+ document: ['.documents'],
516
+ video: ['.videos'],
517
+ };
518
+ function getLocalizedAssetFields(fieldConfigs) {
519
+ return fieldConfigs
520
+ .filter((field) => {
521
+ return ('localized' in field &&
522
+ field.localized === true &&
523
+ (field.type === 'photo' || field.type === 'document' || field.type === 'video'));
524
+ })
525
+ .map((field) => ({
526
+ name: field.name,
527
+ type: field.type,
528
+ }));
529
+ }
530
+ async function unlinkIfExists(filePath) {
531
+ try {
532
+ await fs.promises.unlink(filePath);
533
+ return true;
534
+ }
535
+ catch (error) {
536
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
537
+ return false;
538
+ throw error;
539
+ }
540
+ }
541
+ async function deleteLocalizedAssetFilesFromLocalesTable(tableName, metadata, uploadsFolder) {
542
+ const result = { deletedFiles: 0, failedFiles: [] };
543
+ if (!metadata || metadata.fields.length === 0)
544
+ return result;
545
+ const columns = metadata.fields.map((field) => `\`${field.name}\``).join(', ');
546
+ const [rows] = await db.execute(sql `SELECT ${sql.raw(columns)} FROM ${sql.raw(`\`${tableName}\``)}`);
547
+ for (const row of rows ?? []) {
548
+ for (const field of metadata.fields) {
549
+ const fileName = row[field.name];
550
+ if (typeof fileName !== 'string' || fileName.trim() === '')
551
+ continue;
552
+ for (const directory of LOCALIZED_ASSET_DIRECTORIES[field.type]) {
553
+ const filePath = path.join(uploadsFolder, directory, metadata.sectionName, fileName);
554
+ try {
555
+ const unlinked = await unlinkIfExists(filePath);
556
+ if (unlinked)
557
+ result.deletedFiles++;
558
+ }
559
+ catch (error) {
560
+ // Non-ENOENT failure (e.g. EPERM, EACCES, EBUSY). Record and keep going so
561
+ // remaining files still get a chance to be cleaned before the table drops.
562
+ // TODO: report failed unlinks to the system_issues subsystem once it lands,
563
+ // so admins can retry/resolve them from the UI instead of digging through logs.
564
+ result.failedFiles.push({ filePath, error });
565
+ }
566
+ }
567
+ }
568
+ }
569
+ return result;
570
+ }
571
+ async function deleteGalleryLocaleFiles(tableName, photoFieldName, sectionName, defaultLocaleCode, uploadsFolder) {
572
+ const result = { deletedFiles: 0, failedFiles: [] };
573
+ const [rows] = await db.execute(sql `SELECT \`${sql.raw(photoFieldName)}\` AS photo FROM ${sql.raw(`\`${tableName}\``)} WHERE \`locale\` != ${defaultLocaleCode}`);
574
+ for (const row of rows ?? []) {
575
+ if (typeof row.photo !== 'string' || row.photo.trim() === '')
576
+ continue;
577
+ for (const directory of ['.photos', '.thumbs']) {
578
+ const filePath = path.join(uploadsFolder, directory, sectionName, row.photo);
579
+ try {
580
+ const unlinked = await unlinkIfExists(filePath);
581
+ if (unlinked)
582
+ result.deletedFiles++;
583
+ }
584
+ catch (error) {
585
+ // Non-ENOENT failure (e.g. EPERM, EACCES, EBUSY). Record and keep going so
586
+ // remaining files still get a chance to be cleaned before the rows are deleted.
587
+ // TODO: report failed unlinks to the system_issues subsystem once it lands,
588
+ // so admins can retry/resolve them from the UI instead of digging through logs.
589
+ result.failedFiles.push({ filePath, error });
590
+ }
591
+ }
592
+ }
593
+ return result;
594
+ }
595
+ async function deleteEditorPhotosLocaleFiles(defaultLocaleCode, uploadsFolder) {
596
+ const result = { deletedFiles: 0, failedFiles: [] };
597
+ const [nondefaultRows] = await db.execute(sql `SELECT \`photo\`, \`section\` FROM \`editor_photos\` WHERE \`locale\` != ${defaultLocaleCode}`);
598
+ for (const row of nondefaultRows ?? []) {
599
+ const filePath = path.join(uploadsFolder, '.photos', row.section, row.photo);
600
+ try {
601
+ const unlinked = await unlinkIfExists(filePath);
602
+ if (unlinked)
603
+ result.deletedFiles++;
604
+ }
605
+ catch (error) {
606
+ // Non-ENOENT failure (e.g. EPERM, EACCES, EBUSY). Record and keep going so
607
+ // remaining files still get a chance to be cleaned before the rows are deleted.
608
+ // TODO: report failed unlinks to the system_issues subsystem once it lands,
609
+ // so admins can retry/resolve them from the UI instead of digging through logs.
610
+ result.failedFiles.push({ filePath, error });
611
+ }
612
+ }
613
+ return result;
614
+ }
373
615
  async function updateTable(table, s, ctx) {
374
616
  console.log();
375
617
  console.log(chalk.blueBright(`Updating table '${table.name}' for section '${table.sectionName}'`));
@@ -428,14 +670,14 @@ async function updateTable(table, s, ctx) {
428
670
  }
429
671
  }
430
672
  /**
431
- * Special handling: if this is a destinationDb table and 'locale' is being added
673
+ * Special handling: if this is a localization-owned table and 'locale' is being added
432
674
  * (either because localization was newly enabled OR a field was flipped to localized:true),
433
675
  * run the 3-step migration inline so rows are backfilled with defaultLocale and the
434
676
  * column ends up NOT NULL (required for the primary key to include it).
435
677
  */
436
678
  const defaultLocaleCode = ctx?.defaultLocaleCode ?? 'en';
437
679
  const localeAddIndex = fieldsToAdd.findIndex((f) => f.name === 'locale');
438
- if (table.sectionType === 'destinationDb' && localeAddIndex !== -1) {
680
+ if ((table.sectionType === 'destinationDb' || table.sectionType === 'gallery') && localeAddIndex !== -1) {
439
681
  const [localeField] = fieldsToAdd.splice(localeAddIndex, 1);
440
682
  try {
441
683
  await db.execute(sql `ALTER TABLE \`${sql.raw(table.name)}\` ADD COLUMN \`locale\` VARCHAR(10) DEFAULT NULL`);
@@ -540,18 +782,21 @@ async function updateTable(table, s, ctx) {
540
782
  }
541
783
  }
542
784
  /**
543
- * Special handling: if this is a destinationDb table and 'locale' is being removed,
544
- * it means a select/tags field was changed from localized: true to localized: false.
785
+ * Special handling: if this is a localization-owned table and 'locale' is being removed.
786
+ * For destinationDb tables, this means a select/tags field was delocalized.
787
+ * For gallery tables, this means gallery.localized was disabled or global localization was disabled.
545
788
  * Handle this with a detailed prompt and proper data cleanup.
546
789
  */
547
- if (table.sectionType === 'destinationDb' && fieldsToRemove.includes('locale')) {
790
+ if ((table.sectionType === 'destinationDb' || table.sectionType === 'gallery') &&
791
+ fieldsToRemove.includes('locale')) {
548
792
  fieldsToRemove = fieldsToRemove.filter((f) => f !== 'locale');
549
- // When localization was globally disabled, we already asked upfront auto-confirm here.
793
+ // When localization was globally disabled, we already asked upfront - auto-confirm here.
550
794
  let shouldDelocalize = ctx?.localizationTransition === 'disable' ? 'yes' : 'no';
551
795
  if (ctx?.localizationTransition !== 'disable') {
552
796
  s.stop();
797
+ const tableDescription = table.sectionType === 'gallery' ? 'gallery table' : 'junction table';
553
798
  shouldDelocalize = await select({
554
- message: `The 'locale' column in junction table '${table.name}' is no longer needed (field is no longer localized). This will:\n` +
799
+ message: `The 'locale' column in ${tableDescription} '${table.name}' is no longer needed. This will:\n` +
555
800
  ` 1. Delete all non-default-locale rows (locale != '${defaultLocaleCode}')\n` +
556
801
  ` 2. Drop the 'locale' column from the table\n` +
557
802
  ` 3. Update the primary key to exclude 'locale'\n` +
@@ -566,6 +811,26 @@ async function updateTable(table, s, ctx) {
566
811
  }
567
812
  if (shouldDelocalize === 'yes') {
568
813
  try {
814
+ if (table.sectionType === 'gallery') {
815
+ const photoFieldName = table.gallery?.photoNameField ?? 'photo';
816
+ try {
817
+ const { deletedFiles, failedFiles } = await deleteGalleryLocaleFiles(table.name, photoFieldName, table.sectionName, defaultLocaleCode, ctx?.uploadsFolder ?? '');
818
+ if (deletedFiles > 0) {
819
+ console.log(chalk.gray(` - Deleted ${deletedFiles} gallery file(s) for non-default locales from '${table.sectionName}'`));
820
+ }
821
+ if (failedFiles.length > 0) {
822
+ console.error(chalk.red(` - Failed to delete ${failedFiles.length} gallery file(s) from '${table.sectionName}'. These files were left on disk:`));
823
+ for (const { filePath, error } of failedFiles) {
824
+ console.error(chalk.red(` ${filePath}: ${String(error)}`));
825
+ }
826
+ }
827
+ }
828
+ catch (error) {
829
+ // Reached only when the SELECT itself fails (DB-level error).
830
+ // Per-file unlink failures are collected inside and reported above.
831
+ console.error(chalk.red(` - Error reading '${table.name}' to clean up gallery locale files:`, error));
832
+ }
833
+ }
569
834
  // Delete all non-default-locale rows
570
835
  await db.execute(sql `DELETE FROM ${sql.raw(`\`${table.name}\``)} WHERE \`locale\` != ${defaultLocaleCode}`);
571
836
  log.info(chalk.gray(` - Deleted non-default-locale rows from '${table.name}'`));
@@ -573,6 +838,14 @@ async function updateTable(table, s, ctx) {
573
838
  catch (error) {
574
839
  console.error(chalk.red(` - Error deleting locale rows from '${table.name}':`, error));
575
840
  }
841
+ if (existingKeys?.primaryKeys.includes('locale')) {
842
+ alterTableSQLs.push({
843
+ field: 'primary key',
844
+ table: table.name,
845
+ action: 'remove',
846
+ sql: `DROP PRIMARY KEY`,
847
+ });
848
+ }
576
849
  alterTableSQLs.push({
577
850
  field: 'locale',
578
851
  table: table.name,
@@ -644,7 +917,8 @@ async function updateTable(table, s, ctx) {
644
917
  /**
645
918
  * Add the keys to the table
646
919
  */
647
- const keyErrors = await addTableKeys(table, existingKeys);
920
+ const updatedKeys = await MysqlTableChecker.getExistingKeys(table.name);
921
+ const keyErrors = await addTableKeys(table, updatedKeys);
648
922
  sqlErrors += keyErrors;
649
923
  s.stop(chalk.italic.hex(`${sqlErrors > 0 ? `#FFA500` : `#fafafa`}`)(`- Table \`${table.name}\` modified successfully ${sqlErrors > 0 ? `with ${sqlErrors} error(s).` : ''}`));
650
924
  }
@@ -671,6 +945,8 @@ const main = async (s) => {
671
945
  let sections = [];
672
946
  const desiredTables = [];
673
947
  let existingTables = [];
948
+ const externalSelectDbTables = new Set();
949
+ const localesTableAssetMetadata = new Map();
674
950
  sections = await SectionFactory.getSectionsSilently();
675
951
  console.log(`Found ${sections.length} section(s) to insert: `);
676
952
  console.log(chalk.gray(sections.map((s) => s.name).join(', ')));
@@ -678,25 +954,24 @@ const main = async (s) => {
678
954
  * Let's see if the table `__nextjs_cms_tables` exists in the database.
679
955
  * If it doesn't, we'll create it using the same schema as `NextJsCmsTablesTable`.
680
956
  */
681
- await db.execute(sql `
682
- CREATE TABLE IF NOT EXISTS __nextjs_cms_tables (
683
- name VARCHAR(100) NOT NULL PRIMARY KEY,
684
- section VARCHAR(200),
685
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
686
- )
957
+ await db.execute(sql `
958
+ CREATE TABLE IF NOT EXISTS __nextjs_cms_tables (
959
+ name VARCHAR(100) NOT NULL PRIMARY KEY,
960
+ section VARCHAR(200),
961
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
962
+ )
687
963
  `);
688
964
  /**
689
- * Persistent key/value store for CMS-level state that must survive config removal
690
- * most importantly the original `localization.defaultLocale`, which we need to clean
691
- * up locale-scoped rows correctly when localization is later disabled.
965
+ * Persistent key/value store for CMS-level state that must survive config removal.
692
966
  */
693
- await db.execute(sql `
694
- CREATE TABLE IF NOT EXISTS __nextjs_cms_config (
695
- \`key\` VARCHAR(100) NOT NULL PRIMARY KEY,
696
- \`value\` VARCHAR(255),
697
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
698
- )
967
+ await db.execute(sql `
968
+ CREATE TABLE IF NOT EXISTS __nextjs_cms_config (
969
+ \`key\` VARCHAR(100) NOT NULL PRIMARY KEY,
970
+ \`value\` LONGTEXT,
971
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
972
+ )
699
973
  `);
974
+ await db.execute(sql `ALTER TABLE \`__nextjs_cms_config\` MODIFY COLUMN \`value\` LONGTEXT`);
700
975
  /**
701
976
  * Get the existing tables from the database
702
977
  */
@@ -719,6 +994,10 @@ const main = async (s) => {
719
994
  for (const _s of sections) {
720
995
  const s = _s.build();
721
996
  s.buildFields();
997
+ localesTableAssetMetadata.set(s.localesTableName, {
998
+ sectionName: s.name,
999
+ fields: getLocalizedAssetFields(s.fieldConfigs),
1000
+ });
722
1001
  if (s.db.table.endsWith(RESERVED_TABLE_SUFFIX)) {
723
1002
  reservedNameErrors.push(`Section '${s.name}': table name '${s.db.table}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
724
1003
  }
@@ -735,6 +1014,15 @@ const main = async (s) => {
735
1014
  reservedNameErrors.push(`Section '${s.name}', field '${field.name}': destinationDb identifier column name '${RESERVED_COLUMN_NAME}' is reserved.`);
736
1015
  }
737
1016
  }
1017
+ if (isExternalSelectDbField(field)) {
1018
+ if (field.db.table.endsWith(RESERVED_TABLE_SUFFIX)) {
1019
+ reservedNameErrors.push(`Section '${s.name}', field '${field.name}': db.table '${field.db.table}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
1020
+ }
1021
+ const reservedColumns = [field.db.identifier, field.db.label, field.db.orderBy].filter((column) => column === RESERVED_COLUMN_NAME);
1022
+ if (reservedColumns.length > 0) {
1023
+ reservedNameErrors.push(`Section '${s.name}', field '${field.name}': db column name '${RESERVED_COLUMN_NAME}' is reserved.`);
1024
+ }
1025
+ }
738
1026
  }
739
1027
  const galleryForValidation = await s.getGallery();
740
1028
  if (galleryForValidation?.db.tableName && galleryForValidation.db.tableName.endsWith(RESERVED_TABLE_SUFFIX)) {
@@ -776,9 +1064,32 @@ const main = async (s) => {
776
1064
  * Check for `destinationDb`
777
1065
  */
778
1066
  for (const field of s.fields) {
779
- /**
780
- * TODO: We should also check for input.db in select fields, and add the table to the desiredTables array!
781
- */
1067
+ if (isExternalSelectDbField(field) && !externalSelectDbTables.has(field.db.table)) {
1068
+ externalSelectDbTables.add(field.db.table);
1069
+ const identifierFieldConfig = buildSelectDbFieldConfig(field.db.identifier, 'Option Id', field.db.identifierType ?? 'text');
1070
+ const labelFieldConfig = buildSelectDbFieldConfig(field.db.label, 'Option Label');
1071
+ const selectDbFieldConfigs = [identifierFieldConfig, labelFieldConfig];
1072
+ if (field.db.orderBy &&
1073
+ field.db.orderBy !== field.db.identifier &&
1074
+ field.db.orderBy !== field.db.label) {
1075
+ selectDbFieldConfigs.push(buildSelectDbFieldConfig(field.db.orderBy, 'Option Order', 'text', false));
1076
+ }
1077
+ desiredTables.push({
1078
+ name: field.db.table,
1079
+ fields: selectDbFieldConfigs.map((fieldConfig) => fieldConfig.build()),
1080
+ sectionName: s.name,
1081
+ sectionType: 'selectDb',
1082
+ identifier: identifierFieldConfig,
1083
+ primaryKey: [identifierFieldConfig],
1084
+ });
1085
+ const selectDbSchema = generateDrizzleSchema({
1086
+ name: field.db.table,
1087
+ fields: selectDbFieldConfigs,
1088
+ identifier: identifierFieldConfig,
1089
+ }, caseStyleFns);
1090
+ drizzleTableSchemas.push(selectDbSchema.schema);
1091
+ selectDbSchema.drizzleImports.forEach((type) => drizzleImports.add(type));
1092
+ }
782
1093
  if (field.destinationDb) {
783
1094
  console.log('Destination DB found for input:', field.name, 'with table:', field.destinationDb.table);
784
1095
  const parentIdentifierType = s.db.identifier.type;
@@ -869,6 +1180,7 @@ const main = async (s) => {
869
1180
  const gallery = await s.getGallery();
870
1181
  if (gallery?.db.tableName) {
871
1182
  console.log('Gallery found for section:', s.name, 'with table:', gallery.db.tableName);
1183
+ const isLocalizedGallery = gallery.localized === true && cmsConfig.localization?.enabled === true;
872
1184
  const photoField = textField({
873
1185
  name: gallery.db.photoNameField || 'photo',
874
1186
  label: 'Photo Name',
@@ -888,58 +1200,85 @@ const main = async (s) => {
888
1200
  required: true,
889
1201
  order: 0,
890
1202
  });
1203
+ const localeFieldConfig = isLocalizedGallery ? buildLocaleFieldConfig() : undefined;
1204
+ const galleryFields = [
1205
+ galleryRefField.build(),
1206
+ photoField.build(),
1207
+ textAreaField({
1208
+ name: gallery.db.metaField || 'meta',
1209
+ label: 'Photo Information',
1210
+ required: false,
1211
+ order: 0,
1212
+ }).build(),
1213
+ ...(localeFieldConfig ? [localeFieldConfig.build()] : []),
1214
+ ];
1215
+ const galleryPrimaryKey = localeFieldConfig ? [photoField, localeFieldConfig] : [photoField];
891
1216
  desiredTables.push({
892
1217
  name: gallery.db.tableName,
893
- fields: [
894
- galleryRefField.build(),
895
- photoField.build(),
896
- textAreaField({
897
- name: gallery.db.metaField || 'meta',
898
- label: 'Photo Information',
899
- required: false,
900
- order: 0,
901
- }).build(),
902
- ],
1218
+ fields: galleryFields,
903
1219
  sectionName: s.name,
904
1220
  sectionType: 'gallery',
905
1221
  /**
906
- * We can use the photoField as the identifier for the gallery table
907
- * since it's unique, we can also use it as the primary key
1222
+ * Non-localized galleries keep the photo field as the identifier.
1223
+ * Localized galleries use a composite primary key: photo + locale.
908
1224
  */
909
- identifier: photoField,
910
- primaryKey: [photoField],
1225
+ identifier: isLocalizedGallery ? undefined : photoField,
1226
+ primaryKey: galleryPrimaryKey,
1227
+ gallery: {
1228
+ referenceIdentifierField: gallery.db.referenceIdentifierField || 'reference_id',
1229
+ photoNameField: gallery.db.photoNameField || 'photo',
1230
+ metaField: gallery.db.metaField || 'meta',
1231
+ localized: isLocalizedGallery,
1232
+ },
911
1233
  });
912
- const gallerySchema = generateDrizzleSchema({
913
- name: gallery.db.tableName,
914
- fields: [
915
- {
916
- name: gallery.db.referenceIdentifierField || 'reference_id',
917
- type: s.db.identifier.type,
918
- required: true,
919
- },
920
- {
921
- name: gallery.db.photoNameField || 'photo',
922
- type: 'text',
923
- required: true,
924
- },
925
- {
926
- name: gallery.db.metaField || 'meta',
927
- type: 'textarea',
928
- required: false,
929
- },
930
- ],
931
- identifier: {
1234
+ const gallerySchemaFields = [
1235
+ {
1236
+ name: gallery.db.referenceIdentifierField || 'reference_id',
1237
+ type: s.db.identifier.type,
1238
+ required: true,
1239
+ },
1240
+ {
932
1241
  name: gallery.db.photoNameField || 'photo',
933
1242
  type: 'text',
1243
+ required: true,
1244
+ },
1245
+ {
1246
+ name: gallery.db.metaField || 'meta',
1247
+ type: 'textarea',
1248
+ required: false,
934
1249
  },
1250
+ ...(isLocalizedGallery
1251
+ ? [
1252
+ {
1253
+ name: 'locale',
1254
+ type: 'text',
1255
+ maxLength: 10,
1256
+ required: true,
1257
+ },
1258
+ ]
1259
+ : []),
1260
+ ];
1261
+ const gallerySchema = generateDrizzleSchema({
1262
+ name: gallery.db.tableName,
1263
+ fields: gallerySchemaFields,
1264
+ ...(isLocalizedGallery
1265
+ ? {
1266
+ compositePrimaryKey: [{ name: gallery.db.photoNameField || 'photo' }, { name: 'locale' }],
1267
+ }
1268
+ : {
1269
+ identifier: {
1270
+ name: gallery.db.photoNameField || 'photo',
1271
+ type: 'text',
1272
+ },
1273
+ }),
935
1274
  }, caseStyleFns);
936
1275
  drizzleTableSchemas.push(gallerySchema.schema);
937
1276
  gallerySchema.drizzleImports.forEach((type) => drizzleImports.add(type));
938
1277
  }
939
1278
  /**
940
- * Check for localized fields — generate a _locales table if localization is enabled
1279
+ * Check for localized content — generate a _locales table if localization is enabled
941
1280
  */
942
- if (cmsConfig.localization?.enabled && s.hasLocalizedFields) {
1281
+ if (cmsConfig.localization?.enabled && s.hasLocalizedContent) {
943
1282
  const localesTableName = s.localesTableName;
944
1283
  const parentIdFieldConfig = s.db.identifier.type === 'number'
945
1284
  ? numberField({
@@ -1053,120 +1392,119 @@ const main = async (s) => {
1053
1392
  console.log(chalk.gray(tablesToRemove.map((table) => table.name).join(', ')));
1054
1393
  console.log(`\n`);
1055
1394
  intro(chalk.inverse(' update-sections '));
1056
- /**
1057
- * Detect localization transition by inspecting 'editor_photos' — the presence of its 'locale'
1058
- * column reflects whether localization was enabled on the previous run. Pair this with the
1059
- * stored `localization.defaultLocale` from `__nextjs_cms_config` so destructive cleanup uses
1060
- * the correct locale (the one actually used to tag existing rows), not a guess from the
1061
- * now-missing config.
1062
- */
1063
1395
  const localizationCurrentlyEnabled = cmsConfig.localization?.enabled === true;
1064
- const configDefaultLocale = cmsConfig.localization?.defaultLocale;
1065
1396
  const editorPhotosColumns = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1066
1397
  const editorPhotosExists = editorPhotosColumns.length > 0;
1067
1398
  const editorPhotosHasLocale = editorPhotosColumns.includes('locale');
1068
- const storedDefaultLocale = await readStoredDefaultLocale();
1069
- // Detect residual `locale` columns on registered tables. Used to resume an interrupted
1070
- // disable flow: if editor_photos is already cleaned but a junction still carries the
1071
- // column, the upfront disable path must fire again so cleanup uses the stored locale.
1072
- let hasResidualLocaleColumn = false;
1073
- if (!localizationCurrentlyEnabled && !editorPhotosHasLocale && storedDefaultLocale) {
1074
- for (const existing of existingTables) {
1075
- const cols = await MysqlTableChecker.getColumns(existing.name).catch(() => []);
1076
- if (cols.includes('locale')) {
1077
- hasResidualLocaleColumn = true;
1078
- break;
1079
- }
1080
- }
1399
+ const storedLocalizationState = await readStoredLocalizationState();
1400
+ const storedEnabledLocalizationState = storedLocalizationState?.enabled ? storedLocalizationState : null;
1401
+ const storedPendingEnableLocalizationState = isStoredPendingEnableLocalizationState(storedLocalizationState)
1402
+ ? storedLocalizationState
1403
+ : null;
1404
+ const storedDisabledLocalizationState = isStoredDisabledLocalizationState(storedLocalizationState)
1405
+ ? storedLocalizationState
1406
+ : null;
1407
+ const registeredLocalizationArtifacts = !localizationCurrentlyEnabled || storedDisabledLocalizationState
1408
+ ? await getRegisteredLocalizationArtifacts(existingTables)
1409
+ : { localeColumnTables: [], localesTables: [] };
1410
+ const hasRegisteredLocalizationArtifacts = registeredLocalizationArtifacts.localeColumnTables.length > 0 ||
1411
+ registeredLocalizationArtifacts.localesTables.length > 0;
1412
+ const hasLocalizationArtifacts = editorPhotosHasLocale || hasRegisteredLocalizationArtifacts;
1413
+ if (storedDisabledLocalizationState && hasLocalizationArtifacts) {
1414
+ throw new Error(`Stored localization state says localization is disabled, but localization artifacts still exist in the database. Re-enable localization in cms.config.ts or fix the schema before re-running update-sections.`);
1415
+ }
1416
+ if (!localizationCurrentlyEnabled && !storedLocalizationState && hasLocalizationArtifacts) {
1417
+ throw new Error(`Cannot disable localization safely: no stored localization state found in '__nextjs_cms_config'. Re-enable localization in cms.config.ts and re-run update-sections before disabling it again.`);
1081
1418
  }
1082
1419
  let localizationTransition = null;
1083
- /**
1084
- * Enforcement: on steady-state enabled runs, disallow silent changes to `defaultLocale`.
1085
- * If a mismatch is detected, abort before any destructive operation.
1086
- */
1087
- if (localizationCurrentlyEnabled && editorPhotosHasLocale) {
1088
- if (storedDefaultLocale && configDefaultLocale && storedDefaultLocale !== configDefaultLocale) {
1089
- s.stop(`Aborting. Changing 'localization.defaultLocale' is not supported (stored='${storedDefaultLocale}', config='${configDefaultLocale}'). Revert cms.config.ts and re-run update-sections.`);
1420
+ let disableCleanupState = null;
1421
+ let enableTargetState = null;
1422
+ let enablePendingState = null;
1423
+ if (localizationCurrentlyEnabled && cmsConfig.localization) {
1424
+ const configLocalizationState = buildStoredEnabledLocalizationState(cmsConfig.localization);
1425
+ enableTargetState = configLocalizationState;
1426
+ if (storedEnabledLocalizationState &&
1427
+ storedEnabledLocalizationState.defaultLocale !== configLocalizationState.defaultLocale) {
1428
+ s.stop(`Aborting. Changing localization defaultLocale is not supported (stored='${storedEnabledLocalizationState.defaultLocale}', config='${configLocalizationState.defaultLocale}'). Revert cms.config.ts and re-run update-sections.`);
1090
1429
  return;
1091
1430
  }
1092
- if (!storedDefaultLocale && configDefaultLocale) {
1093
- // Seed the stored value from current config — one-time backfill for installs that
1094
- // predate `__nextjs_cms_config`.
1095
- await writeStoredDefaultLocale(configDefaultLocale);
1431
+ if (storedPendingEnableLocalizationState &&
1432
+ storedPendingEnableLocalizationState.defaultLocale !== configLocalizationState.defaultLocale) {
1433
+ s.stop(`Aborting. Changing localization defaultLocale while enable is pending is not supported (stored='${storedPendingEnableLocalizationState.defaultLocale}', config='${configLocalizationState.defaultLocale}'). Revert cms.config.ts and re-run update-sections.`);
1434
+ return;
1096
1435
  }
1097
- }
1098
- // Resolve the locale used for cleanup SQL. For newly-disabled, stored takes precedence;
1099
- // for newly-enabled, config is the source of truth.
1100
- const defaultLocaleCode = localizationCurrentlyEnabled
1101
- ? (configDefaultLocale ?? storedDefaultLocale ?? 'en')
1102
- : (storedDefaultLocale ?? configDefaultLocale ?? 'en');
1103
- if (editorPhotosExists && localizationCurrentlyEnabled && !editorPhotosHasLocale) {
1104
1436
  /**
1105
1437
  * Localization is newly enabled: editor_photos lacks the locale column,
1106
1438
  * but the config now has localization.enabled = true. We must backfill
1107
- * editor_photos and any existing junction tables that carry localized data
1439
+ * editor_photos and any existing tables that carry localized data
1108
1440
  * with the configured defaultLocale.
1109
1441
  */
1110
- const affectedJunctions = [];
1442
+ const affectedLocalizedTables = [];
1111
1443
  for (const t of desiredTables) {
1112
- if (t.sectionType !== 'destinationDb')
1444
+ if (t.sectionType !== 'destinationDb' && t.sectionType !== 'gallery')
1113
1445
  continue;
1114
1446
  if (!t.fields.some((f) => f.name === 'locale'))
1115
1447
  continue;
1116
1448
  const cols = await MysqlTableChecker.getColumns(t.name).catch(() => []);
1117
1449
  if (cols.length > 0 && !cols.includes('locale')) {
1118
- affectedJunctions.push(t.name);
1450
+ affectedLocalizedTables.push(t.name);
1119
1451
  }
1120
1452
  }
1121
- const affectedList = ['editor_photos', ...affectedJunctions];
1122
- s.stop();
1123
- const confirm = await select({
1124
- message: `It looks like you enabled localization. Your existing data in these tables will be marked as defaultLocale '${defaultLocaleCode}':\n` +
1125
- affectedList.map((n) => ` - ${n}`).join('\n') +
1126
- `\n\n${chalk.redBright('WARNING:')} Existing data in these tables MUST already be in '${defaultLocaleCode}'. ` +
1127
- `Changing defaultLocale after this point is not supported. If your data is not in '${defaultLocaleCode}', ` +
1128
- `stop now and fix your cms.config.ts before re-running update-sections.\n\nProceed?`,
1129
- options: [
1130
- { value: 'yes', label: `Yes, that's correct` },
1131
- { value: 'no', label: 'No, stop — let me reconfigure localization in cms.config.ts' },
1132
- ],
1133
- initialValue: 'no',
1134
- });
1135
- if (confirm !== 'yes') {
1136
- s.stop('Aborted. Reconfigure cms.config.ts and re-run update-sections.');
1137
- return;
1138
- }
1139
- localizationTransition = 'enable';
1140
- try {
1141
- await db.execute(sql `ALTER TABLE \`editor_photos\` ADD COLUMN \`locale\` VARCHAR(10) DEFAULT NULL`);
1142
- await db.execute(sql `UPDATE \`editor_photos\` SET \`locale\` = ${defaultLocaleCode} WHERE \`locale\` IS NULL`);
1143
- await db.execute(sql `ALTER TABLE \`editor_photos\` MODIFY COLUMN \`locale\` VARCHAR(10) NOT NULL`);
1144
- // Persist the defaultLocale so future disable cleanup uses the right filter even
1145
- // after `localization` is stripped from cms.config.ts.
1146
- await writeStoredDefaultLocale(defaultLocaleCode);
1147
- log.info(chalk.gray(` - Added 'locale' column to 'editor_photos' and backfilled existing rows with '${defaultLocaleCode}'.`));
1148
- }
1149
- catch (error) {
1150
- console.error(chalk.red(` - Error migrating 'editor_photos' for localization enable:`, error));
1453
+ const needsEditorPhotosMigration = editorPhotosExists && !editorPhotosHasLocale;
1454
+ const shouldRunEnableTransition = storedPendingEnableLocalizationState || !storedEnabledLocalizationState;
1455
+ if (shouldRunEnableTransition) {
1456
+ const affectedList = [...(needsEditorPhotosMigration ? ['editor_photos'] : []), ...affectedLocalizedTables];
1457
+ const affectedListText = affectedList.length > 0
1458
+ ? affectedList.map((n) => ` - ${n}`).join('\n')
1459
+ : ` - No existing tables need a locale backfill; update-sections will verify localized tables after sync.`;
1460
+ const isEnableRetry = storedPendingEnableLocalizationState?.transition.status === 'pending';
1461
+ s.stop();
1462
+ const confirm = await select({
1463
+ message: (isEnableRetry
1464
+ ? `A previous localization enable attempt did not complete. update-sections will retry setup now.\n\n`
1465
+ : `It looks like you enabled localization. Your existing data in these tables will be marked as defaultLocale '${configLocalizationState.defaultLocale}':\n`) +
1466
+ affectedListText +
1467
+ `\n\n${chalk.redBright('WARNING:')} Existing data in these tables MUST already be in '${configLocalizationState.defaultLocale}'. ` +
1468
+ `Changing defaultLocale after this point is not supported. If your data is not in '${configLocalizationState.defaultLocale}', ` +
1469
+ `stop now and fix your cms.config.ts before re-running update-sections.\n\nProceed?`,
1470
+ options: [
1471
+ { value: 'yes', label: `Yes, that's correct` },
1472
+ { value: 'no', label: 'No, stop - let me reconfigure localization in cms.config.ts' },
1473
+ ],
1474
+ initialValue: 'no',
1475
+ });
1476
+ if (confirm !== 'yes') {
1477
+ s.stop('Aborted. Reconfigure cms.config.ts and re-run update-sections.');
1478
+ return;
1479
+ }
1480
+ localizationTransition = 'enable';
1481
+ const now = new Date().toISOString();
1482
+ enablePendingState = buildStoredPendingEnableLocalizationState(configLocalizationState, {
1483
+ type: 'enable',
1484
+ status: 'pending',
1485
+ startedAt: storedPendingEnableLocalizationState?.transition.startedAt ?? now,
1486
+ ...(isEnableRetry ? { lastAttemptAt: now } : {}),
1487
+ });
1488
+ await writeStoredLocalizationState(enablePendingState);
1489
+ if (needsEditorPhotosMigration) {
1490
+ try {
1491
+ await db.execute(sql `ALTER TABLE \`editor_photos\` ADD COLUMN \`locale\` VARCHAR(10) DEFAULT NULL`);
1492
+ await db.execute(sql `UPDATE \`editor_photos\` SET \`locale\` = ${configLocalizationState.defaultLocale} WHERE \`locale\` IS NULL`);
1493
+ await db.execute(sql `ALTER TABLE \`editor_photos\` MODIFY COLUMN \`locale\` VARCHAR(10) NOT NULL`);
1494
+ log.info(chalk.gray(` - Added 'locale' column to 'editor_photos' and backfilled existing rows with '${configLocalizationState.defaultLocale}'.`));
1495
+ }
1496
+ catch (error) {
1497
+ console.error(chalk.red(` - Error migrating 'editor_photos' for localization enable:`, error));
1498
+ }
1499
+ }
1500
+ s.start();
1151
1501
  }
1152
- s.start();
1153
1502
  }
1154
- else if (editorPhotosExists &&
1155
- !localizationCurrentlyEnabled &&
1156
- (editorPhotosHasLocale || hasResidualLocaleColumn)) {
1503
+ else if (!localizationCurrentlyEnabled && storedEnabledLocalizationState) {
1157
1504
  /**
1158
1505
  * Localization is newly disabled (or a previous disable run was interrupted):
1159
- * editor_photos or some junction/_locales table still carries the locale column,
1160
- * but the config no longer has localization.enabled = true. We purge non-default rows
1161
- * (and their files), drop the locale column from editor_photos and junction tables,
1162
- * and drop _locales tables. The deletion filter uses the STORED defaultLocale (not
1163
- * config, which may be missing), so rows tagged with the original base locale survive
1164
- * regardless of current config.
1506
+ * the stored localization state is still enabled, but cms.config.ts no longer is.
1165
1507
  */
1166
- if (!storedDefaultLocale) {
1167
- s.stop(`Aborting. Cannot disable localization safely: no stored defaultLocale found in '__nextjs_cms_config'. This indicates a corrupted state.`);
1168
- return;
1169
- }
1170
1508
  const affected = [
1171
1509
  { name: 'editor_photos', type: 'editor_photos' },
1172
1510
  ];
@@ -1180,23 +1518,26 @@ const main = async (s) => {
1180
1518
  affected.push({ name: existing.name, type: 'locales' });
1181
1519
  }
1182
1520
  else {
1183
- affected.push({ name: existing.name, type: 'junction' });
1521
+ affected.push({ name: existing.name, type: 'localized_table' });
1184
1522
  }
1185
1523
  }
1186
1524
  s.stop();
1525
+ const isDisableRetry = storedEnabledLocalizationState.transition?.status === 'pending';
1187
1526
  const confirm = await select({
1188
- message: `It looks like you disabled localization. The 'locale' column in the tables below is no longer needed and will be dropped.\n\n` +
1189
- `Base locale (from '__nextjs_cms_config'): '${storedDefaultLocale}'. Rows tagged with this locale will be kept; all others will be deleted.\n\n` +
1527
+ message: (isDisableRetry
1528
+ ? `A previous localization disable attempt did not complete. update-sections will retry cleanup now.\n\n`
1529
+ : `It looks like you disabled localization. The 'locale' column in the tables below is no longer needed and will be dropped.\n\n`) +
1530
+ `Base locale (from '__nextjs_cms_config'): '${storedEnabledLocalizationState.defaultLocale}'. Rows tagged with this locale will be kept; all others will be deleted.\n\n` +
1190
1531
  `This will:\n` +
1191
- ` 1. Delete all non-base-locale rows (locale != '${storedDefaultLocale}') from editor_photos and junction tables (their files on disk will also be removed)\n` +
1192
- ` 2. Drop the 'locale' column from editor_photos and junction tables\n` +
1532
+ ` 1. Delete all non-base-locale rows (locale != '${storedEnabledLocalizationState.defaultLocale}') from editor_photos and localized tables (their files on disk will also be removed where applicable)\n` +
1533
+ ` 2. Drop the 'locale' column from editor_photos and localized tables\n` +
1193
1534
  ` 3. Drop '_locales' tables entirely\n\n` +
1194
1535
  `Affected tables:\n` +
1195
1536
  affected.map((t) => ` - ${t.name} (${t.type})`).join('\n') +
1196
1537
  `\n\nProceed?`,
1197
1538
  options: [
1198
1539
  { value: 'yes', label: `Yes, that's correct` },
1199
- { value: 'no', label: 'No, stop let me reconfigure localization in cms.config.ts' },
1540
+ { value: 'no', label: 'No, stop - let me reconfigure localization in cms.config.ts' },
1200
1541
  ],
1201
1542
  initialValue: 'no',
1202
1543
  });
@@ -1205,22 +1546,34 @@ const main = async (s) => {
1205
1546
  return;
1206
1547
  }
1207
1548
  localizationTransition = 'disable';
1549
+ const now = new Date().toISOString();
1550
+ disableCleanupState = {
1551
+ ...storedEnabledLocalizationState,
1552
+ transition: {
1553
+ type: 'disable',
1554
+ status: 'pending',
1555
+ startedAt: storedEnabledLocalizationState.transition?.startedAt ?? now,
1556
+ ...(isDisableRetry ? { lastAttemptAt: now } : {}),
1557
+ },
1558
+ };
1559
+ await writeStoredLocalizationState(disableCleanupState);
1208
1560
  if (editorPhotosHasLocale) {
1209
1561
  try {
1210
1562
  const uploadsFolder = cmsConfig.media.upload.path;
1211
- const [nondefaultRows] = await db.execute(sql `SELECT \`photo\`, \`section\` FROM \`editor_photos\` WHERE \`locale\` != ${storedDefaultLocale}`);
1212
- const rows = nondefaultRows ?? [];
1213
- for (const row of rows) {
1214
- try {
1215
- await fs.promises.unlink(path.join(uploadsFolder, '.photos', row.section, row.photo));
1216
- }
1217
- catch {
1218
- // ignore missing files
1563
+ const { deletedFiles, failedFiles } = await deleteEditorPhotosLocaleFiles(storedEnabledLocalizationState.defaultLocale, uploadsFolder);
1564
+ if (deletedFiles > 0) {
1565
+ console.log(chalk.gray(` - Deleted ${deletedFiles} editor photo file(s) for non-default locales`));
1566
+ }
1567
+ if (failedFiles.length > 0) {
1568
+ // TODO: Add the failed-to-delete files to system_issues table (system_issues system not yet implemented)
1569
+ console.error(chalk.red(` - Failed to delete ${failedFiles.length} editor photo file(s). These files were left on disk:`));
1570
+ for (const { filePath, error } of failedFiles) {
1571
+ console.error(chalk.red(` ${filePath}: ${String(error)}`));
1219
1572
  }
1220
1573
  }
1221
- await db.execute(sql `DELETE FROM \`editor_photos\` WHERE \`locale\` != ${storedDefaultLocale}`);
1574
+ await db.execute(sql `DELETE FROM \`editor_photos\` WHERE \`locale\` != ${storedEnabledLocalizationState.defaultLocale}`);
1222
1575
  await db.execute(sql `ALTER TABLE \`editor_photos\` DROP COLUMN \`locale\``);
1223
- log.info(chalk.gray(` - Removed ${rows.length} non-base-locale row(s) and dropped 'locale' column from 'editor_photos'.`));
1576
+ log.info(chalk.gray(` - Removed non-base-locale row(s) and dropped 'locale' column from 'editor_photos'.`));
1224
1577
  }
1225
1578
  catch (error) {
1226
1579
  console.error(chalk.red(` - Error migrating 'editor_photos' for localization disable:`, error));
@@ -1231,7 +1584,10 @@ const main = async (s) => {
1231
1584
  }
1232
1585
  s.start();
1233
1586
  }
1234
- const ctx = { localizationTransition, defaultLocaleCode };
1587
+ const defaultLocaleCode = localizationCurrentlyEnabled
1588
+ ? (cmsConfig.localization?.defaultLocale ?? storedEnabledLocalizationState?.defaultLocale ?? 'en')
1589
+ : (storedEnabledLocalizationState?.defaultLocale ?? cmsConfig.localization?.defaultLocale ?? 'en');
1590
+ const ctx = { localizationTransition, defaultLocaleCode, uploadsFolder: cmsConfig.media.upload.path };
1235
1591
  /**
1236
1592
  * Check if there are tables to update
1237
1593
  */
@@ -1342,7 +1698,7 @@ const main = async (s) => {
1342
1698
  * Loop through the tables to remove
1343
1699
  */
1344
1700
  for (const table of tablesToRemove) {
1345
- // When localization was globally disabled, auto-confirm dropping _locales tables
1701
+ // When localization was globally disabled, auto-confirm dropping _locales tables -
1346
1702
  // the upfront prompt already covered them.
1347
1703
  const autoDropLocales = localizationTransition === 'disable' && table.name.endsWith('_locales') ? 'yes' : null;
1348
1704
  let opType;
@@ -1366,6 +1722,26 @@ const main = async (s) => {
1366
1722
  case 'yes':
1367
1723
  case 'no':
1368
1724
  if (opType === 'yes') {
1725
+ if (table.name.endsWith('_locales')) {
1726
+ try {
1727
+ const { deletedFiles, failedFiles } = await deleteLocalizedAssetFilesFromLocalesTable(table.name, localesTableAssetMetadata.get(table.name), cmsConfig.media.upload.path);
1728
+ if (deletedFiles > 0) {
1729
+ console.log(chalk.gray(` - Deleted ${deletedFiles} localized asset file(s) referenced by '${table.name}'`));
1730
+ }
1731
+ if (failedFiles.length > 0) {
1732
+ // TODO: Add the failed-to-delete files to system_issues table (system_issues system not yet implemented)
1733
+ console.error(chalk.red(` - Failed to delete ${failedFiles.length} localized asset file(s) referenced by '${table.name}'. These files were left on disk:`));
1734
+ for (const { filePath, error } of failedFiles) {
1735
+ console.error(chalk.red(` ${filePath}: ${String(error)}`));
1736
+ }
1737
+ }
1738
+ }
1739
+ catch (error) {
1740
+ // Reached only when the SELECT itself fails (DB-level error).
1741
+ // Per-file unlink failures are collected inside and reported above.
1742
+ console.error(chalk.red(` - Error reading '${table.name}' to clean up localized asset files:`, error));
1743
+ }
1744
+ }
1369
1745
  console.log(chalk.gray(` - Dropping table '${table.name}'`));
1370
1746
  await db.execute(sql `DROP TABLE \`${sql.raw(table.name)}\``);
1371
1747
  }
@@ -1388,19 +1764,45 @@ const main = async (s) => {
1388
1764
  }
1389
1765
  }
1390
1766
  }
1391
- /**
1392
- * Only now after every junction update and every `_locales` drop has been attempted —
1393
- * is it safe to clear the stored defaultLocale. The individual cleanup steps swallow
1394
- * errors to keep the run going, so "transition === 'disable'" is not by itself proof the
1395
- * DB is clean. Post-check the actual state: editor_photos must have no `locale` column,
1396
- * and no registered table (junction or `_locales`) may still carry one. If any trace
1397
- * remains, keep the stored value so the next run can resume with the correct filter.
1398
- */
1767
+ if (localizationTransition === 'enable') {
1768
+ if (!enableTargetState || !enablePendingState) {
1769
+ throw new Error(`Localization enable setup state was not initialized.`);
1770
+ }
1771
+ const missingArtifacts = [];
1772
+ if (editorPhotosExists) {
1773
+ const postEditorPhotosCols = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1774
+ if (!postEditorPhotosCols.includes('locale')) {
1775
+ missingArtifacts.push(`editor_photos.locale`);
1776
+ }
1777
+ }
1778
+ for (const table of desiredTables) {
1779
+ if ((table.sectionType === 'destinationDb' || table.sectionType === 'gallery') &&
1780
+ table.fields.some((field) => field.name === 'locale')) {
1781
+ const cols = await MysqlTableChecker.getColumns(table.name).catch(() => []);
1782
+ if (!cols.includes('locale')) {
1783
+ missingArtifacts.push(`${table.name}.locale`);
1784
+ }
1785
+ }
1786
+ if (table.sectionType === 'locales') {
1787
+ const stillExists = (await MysqlTableChecker.getExistingTableStructure(table.name)) !== null;
1788
+ if (!stillExists) {
1789
+ missingArtifacts.push(table.name);
1790
+ }
1791
+ }
1792
+ }
1793
+ if (missingArtifacts.length > 0) {
1794
+ throw new Error(`Localization enable setup did not complete. Missing artifacts: ${missingArtifacts.join(', ')}. The pending state was kept in '__nextjs_cms_config' and update-sections will retry on the next run.`);
1795
+ }
1796
+ await writeStoredLocalizationState(enableTargetState);
1797
+ }
1399
1798
  if (localizationTransition === 'disable') {
1799
+ if (!disableCleanupState) {
1800
+ throw new Error(`Localization disable cleanup state was not initialized.`);
1801
+ }
1400
1802
  const postEditorPhotosCols = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1401
1803
  let fullyClean = !postEditorPhotosCols.includes('locale');
1402
1804
  if (!fullyClean) {
1403
- log.warn(chalk.yellow(` - 'editor_photos' still has a 'locale' column after cleanup. Keeping stored defaultLocale so the next run can resume.`));
1805
+ log.warn(chalk.yellow(` - 'editor_photos' still has a 'locale' column after cleanup. Keeping localization disable pending so the next run can resume.`));
1404
1806
  }
1405
1807
  const postRegistered = fullyClean
1406
1808
  ? await db.select({ name: NextJsCmsTablesTable.tableName }).from(NextJsCmsTablesTable)
@@ -1410,26 +1812,32 @@ const main = async (s) => {
1410
1812
  const cols = await MysqlTableChecker.getColumns(t.name).catch(() => []);
1411
1813
  if (cols.includes('locale')) {
1412
1814
  fullyClean = false;
1413
- log.warn(chalk.yellow(` - Table '${t.name}' still has a 'locale' column after cleanup. Keeping stored defaultLocale so the next run can resume.`));
1815
+ log.warn(chalk.yellow(` - Table '${t.name}' still has a 'locale' column after cleanup. Keeping localization disable pending so the next run can resume.`));
1414
1816
  break;
1415
1817
  }
1416
1818
  }
1417
1819
  }
1418
1820
  if (fullyClean) {
1419
- // Check registered tables whose name ends with '_locales' scoped to
1821
+ // Check registered tables whose name ends with '_locales' - scoped to
1420
1822
  // __nextjs_cms_tables so unrelated tables sharing the DB are never flagged.
1421
1823
  const registeredLocalesTables = postRegistered.filter((t) => t.name.endsWith('_locales'));
1422
1824
  for (const t of registeredLocalesTables) {
1423
1825
  const stillExists = (await MysqlTableChecker.getExistingTableStructure(t.name)) !== null;
1424
1826
  if (stillExists) {
1425
1827
  fullyClean = false;
1426
- log.warn(chalk.yellow(` - Registered '_locales' table '${t.name}' still exists after cleanup. Keeping stored defaultLocale so the next run can resume.`));
1828
+ log.warn(chalk.yellow(` - Registered '_locales' table '${t.name}' still exists after cleanup. Keeping localization disable pending so the next run can resume.`));
1427
1829
  break;
1428
1830
  }
1429
1831
  }
1430
1832
  }
1431
1833
  if (fullyClean) {
1432
- await deleteStoredDefaultLocale();
1834
+ await writeStoredLocalizationState({
1835
+ version: STORED_LOCALIZATION_VERSION,
1836
+ enabled: false,
1837
+ });
1838
+ }
1839
+ else {
1840
+ throw new Error(`Localization disable cleanup did not complete. The pending state was kept in '__nextjs_cms_config' and update-sections will retry on the next run.`);
1433
1841
  }
1434
1842
  }
1435
1843
  };