nextjs-cms 0.9.20 → 0.9.22

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 (62) 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 +660 -213
  18. package/dist/core/db/table-checker/MysqlTable.d.ts.map +1 -1
  19. package/dist/core/db/table-checker/MysqlTable.js +3 -1
  20. package/dist/core/factories/FieldFactory.d.ts +1 -1
  21. package/dist/core/factories/FieldFactory.d.ts.map +1 -1
  22. package/dist/core/factories/FieldFactory.js +11 -9
  23. package/dist/core/fields/date.d.ts.map +1 -1
  24. package/dist/core/fields/date.js +10 -6
  25. package/dist/core/fields/select.d.ts +23 -11
  26. package/dist/core/fields/select.d.ts.map +1 -1
  27. package/dist/core/fields/select.js +9 -5
  28. package/dist/core/fields/selectMultiple.d.ts +12 -4
  29. package/dist/core/fields/selectMultiple.d.ts.map +1 -1
  30. package/dist/core/fields/selectMultiple.js +10 -6
  31. package/dist/core/sections/category.d.ts +44 -42
  32. package/dist/core/sections/category.d.ts.map +1 -1
  33. package/dist/core/sections/hasItems.d.ts +44 -42
  34. package/dist/core/sections/hasItems.d.ts.map +1 -1
  35. package/dist/core/sections/section.d.ts +64 -43
  36. package/dist/core/sections/section.d.ts.map +1 -1
  37. package/dist/core/sections/section.js +18 -3
  38. package/dist/core/sections/simple.d.ts +8 -6
  39. package/dist/core/sections/simple.d.ts.map +1 -1
  40. package/dist/core/submit/ItemEditSubmit.d.ts.map +1 -1
  41. package/dist/core/submit/ItemEditSubmit.js +7 -1
  42. package/dist/core/submit/LocaleSubmit.d.ts +3 -3
  43. package/dist/core/submit/LocaleSubmit.d.ts.map +1 -1
  44. package/dist/core/submit/LocaleSubmit.js +9 -6
  45. package/dist/core/submit/submit.d.ts +4 -4
  46. package/dist/core/submit/submit.d.ts.map +1 -1
  47. package/dist/core/submit/submit.js +13 -9
  48. package/dist/core/types/index.d.ts +2 -2
  49. package/dist/core/types/index.d.ts.map +1 -1
  50. package/dist/db/cms-system-tables.d.ts.map +1 -1
  51. package/dist/db/cms-system-tables.js +2 -1
  52. package/dist/db/schema.d.ts +61 -0
  53. package/dist/db/schema.d.ts.map +1 -1
  54. package/dist/db/schema.js +8 -0
  55. package/dist/translations/base/en.d.ts +4 -0
  56. package/dist/translations/base/en.d.ts.map +1 -1
  57. package/dist/translations/base/en.js +4 -0
  58. package/dist/translations/client.d.ts +52 -4
  59. package/dist/translations/client.d.ts.map +1 -1
  60. package/dist/translations/server.d.ts +52 -4
  61. package/dist/translations/server.d.ts.map +1 -1
  62. package/package.json +1 -1
@@ -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,191 @@ 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}`);
316
+ }
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
+ };
239
328
  }
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}`);
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
+ };
242
337
  }
243
- async function deleteStoredDefaultLocale() {
244
- await db.execute(sql `DELETE FROM \`__nextjs_cms_config\` WHERE \`key\` = ${STORED_DEFAULT_LOCALE_KEY}`);
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 };
354
+ }
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
+ });
385
+ }
386
+ async function createEditorPhotosTable(localized) {
387
+ await db.execute(sql `
388
+ CREATE TABLE IF NOT EXISTS \`editor_photos\` (
389
+ \`photo\` VARCHAR(100) NOT NULL PRIMARY KEY,
390
+ \`section\` VARCHAR(100) NOT NULL,
391
+ \`item_id\` VARCHAR(50) NOT NULL,
392
+ \`field\` VARCHAR(100) NOT NULL,
393
+ \`meta\` LONGTEXT,
394
+ ${localized ? sql `\`locale\` VARCHAR(10) NOT NULL,` : sql ``}
395
+ \`linked\` BOOLEAN DEFAULT false,
396
+ \`created_at\` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
397
+ )
398
+ `);
399
+ }
400
+ async function ensureEditorPhotosLocaleColumn(defaultLocaleCode, knownColumns) {
401
+ let columns = knownColumns ?? (await MysqlTableChecker.getColumns('editor_photos').catch(() => []));
402
+ if (columns.includes('locale')) {
403
+ return false;
404
+ }
405
+ if (columns.length === 0) {
406
+ await createEditorPhotosTable(true);
407
+ columns = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
408
+ if (columns.includes('locale')) {
409
+ log.info(chalk.gray(` - Created 'editor_photos' table with 'locale' column.`));
410
+ return true;
411
+ }
412
+ throw new Error(`Failed to create 'editor_photos' table with a 'locale' column.`);
413
+ }
414
+ await db.execute(sql `ALTER TABLE \`editor_photos\` ADD COLUMN \`locale\` VARCHAR(10) DEFAULT NULL`);
415
+ await db.execute(sql `UPDATE \`editor_photos\` SET \`locale\` = ${defaultLocaleCode} WHERE \`locale\` IS NULL`);
416
+ await db.execute(sql `ALTER TABLE \`editor_photos\` MODIFY COLUMN \`locale\` VARCHAR(10) NOT NULL`);
417
+ log.info(chalk.gray(` - Added 'locale' column to 'editor_photos' and backfilled existing rows with '${defaultLocaleCode}'.`));
418
+ return true;
245
419
  }
246
420
  async function ensureTableRegistryEntry(tableName, sectionName) {
247
421
  await db
@@ -370,6 +544,108 @@ async function renameTable(oldName, newName) {
370
544
  console.error(`Error renaming table \`${oldName}\` to \`${newName}\`:`, error);
371
545
  }
372
546
  }
547
+ const LOCALIZED_ASSET_DIRECTORIES = {
548
+ photo: ['.photos', '.thumbs'],
549
+ document: ['.documents'],
550
+ video: ['.videos'],
551
+ };
552
+ function getLocalizedAssetFields(fieldConfigs) {
553
+ return fieldConfigs
554
+ .filter((field) => {
555
+ return ('localized' in field &&
556
+ field.localized === true &&
557
+ (field.type === 'photo' || field.type === 'document' || field.type === 'video'));
558
+ })
559
+ .map((field) => ({
560
+ name: field.name,
561
+ type: field.type,
562
+ }));
563
+ }
564
+ async function unlinkIfExists(filePath) {
565
+ try {
566
+ await fs.promises.unlink(filePath);
567
+ return true;
568
+ }
569
+ catch (error) {
570
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
571
+ return false;
572
+ throw error;
573
+ }
574
+ }
575
+ async function deleteLocalizedAssetFilesFromLocalesTable(tableName, metadata, uploadsFolder) {
576
+ const result = { deletedFiles: 0, failedFiles: [] };
577
+ if (!metadata || metadata.fields.length === 0)
578
+ return result;
579
+ const columns = metadata.fields.map((field) => `\`${field.name}\``).join(', ');
580
+ const [rows] = await db.execute(sql `SELECT ${sql.raw(columns)} FROM ${sql.raw(`\`${tableName}\``)}`);
581
+ for (const row of rows ?? []) {
582
+ for (const field of metadata.fields) {
583
+ const fileName = row[field.name];
584
+ if (typeof fileName !== 'string' || fileName.trim() === '')
585
+ continue;
586
+ for (const directory of LOCALIZED_ASSET_DIRECTORIES[field.type]) {
587
+ const filePath = path.join(uploadsFolder, directory, metadata.sectionName, fileName);
588
+ try {
589
+ const unlinked = await unlinkIfExists(filePath);
590
+ if (unlinked)
591
+ result.deletedFiles++;
592
+ }
593
+ catch (error) {
594
+ // Non-ENOENT failure (e.g. EPERM, EACCES, EBUSY). Record and keep going so
595
+ // remaining files still get a chance to be cleaned before the table drops.
596
+ // TODO: report failed unlinks to the system_issues subsystem once it lands,
597
+ // so admins can retry/resolve them from the UI instead of digging through logs.
598
+ result.failedFiles.push({ filePath, error });
599
+ }
600
+ }
601
+ }
602
+ }
603
+ return result;
604
+ }
605
+ async function deleteGalleryLocaleFiles(tableName, photoFieldName, sectionName, defaultLocaleCode, uploadsFolder) {
606
+ const result = { deletedFiles: 0, failedFiles: [] };
607
+ const [rows] = await db.execute(sql `SELECT \`${sql.raw(photoFieldName)}\` AS photo FROM ${sql.raw(`\`${tableName}\``)} WHERE \`locale\` != ${defaultLocaleCode}`);
608
+ for (const row of rows ?? []) {
609
+ if (typeof row.photo !== 'string' || row.photo.trim() === '')
610
+ continue;
611
+ for (const directory of ['.photos', '.thumbs']) {
612
+ const filePath = path.join(uploadsFolder, directory, sectionName, row.photo);
613
+ try {
614
+ const unlinked = await unlinkIfExists(filePath);
615
+ if (unlinked)
616
+ result.deletedFiles++;
617
+ }
618
+ catch (error) {
619
+ // Non-ENOENT failure (e.g. EPERM, EACCES, EBUSY). Record and keep going so
620
+ // remaining files still get a chance to be cleaned before the rows are deleted.
621
+ // TODO: report failed unlinks to the system_issues subsystem once it lands,
622
+ // so admins can retry/resolve them from the UI instead of digging through logs.
623
+ result.failedFiles.push({ filePath, error });
624
+ }
625
+ }
626
+ }
627
+ return result;
628
+ }
629
+ async function deleteEditorPhotosLocaleFiles(defaultLocaleCode, uploadsFolder) {
630
+ const result = { deletedFiles: 0, failedFiles: [] };
631
+ const [nondefaultRows] = await db.execute(sql `SELECT \`photo\`, \`section\` FROM \`editor_photos\` WHERE \`locale\` != ${defaultLocaleCode}`);
632
+ for (const row of nondefaultRows ?? []) {
633
+ const filePath = path.join(uploadsFolder, '.photos', row.section, row.photo);
634
+ try {
635
+ const unlinked = await unlinkIfExists(filePath);
636
+ if (unlinked)
637
+ result.deletedFiles++;
638
+ }
639
+ catch (error) {
640
+ // Non-ENOENT failure (e.g. EPERM, EACCES, EBUSY). Record and keep going so
641
+ // remaining files still get a chance to be cleaned before the rows are deleted.
642
+ // TODO: report failed unlinks to the system_issues subsystem once it lands,
643
+ // so admins can retry/resolve them from the UI instead of digging through logs.
644
+ result.failedFiles.push({ filePath, error });
645
+ }
646
+ }
647
+ return result;
648
+ }
373
649
  async function updateTable(table, s, ctx) {
374
650
  console.log();
375
651
  console.log(chalk.blueBright(`Updating table '${table.name}' for section '${table.sectionName}'`));
@@ -428,14 +704,14 @@ async function updateTable(table, s, ctx) {
428
704
  }
429
705
  }
430
706
  /**
431
- * Special handling: if this is a destinationDb table and 'locale' is being added
707
+ * Special handling: if this is a localization-owned table and 'locale' is being added
432
708
  * (either because localization was newly enabled OR a field was flipped to localized:true),
433
709
  * run the 3-step migration inline so rows are backfilled with defaultLocale and the
434
710
  * column ends up NOT NULL (required for the primary key to include it).
435
711
  */
436
712
  const defaultLocaleCode = ctx?.defaultLocaleCode ?? 'en';
437
713
  const localeAddIndex = fieldsToAdd.findIndex((f) => f.name === 'locale');
438
- if (table.sectionType === 'destinationDb' && localeAddIndex !== -1) {
714
+ if ((table.sectionType === 'destinationDb' || table.sectionType === 'gallery') && localeAddIndex !== -1) {
439
715
  const [localeField] = fieldsToAdd.splice(localeAddIndex, 1);
440
716
  try {
441
717
  await db.execute(sql `ALTER TABLE \`${sql.raw(table.name)}\` ADD COLUMN \`locale\` VARCHAR(10) DEFAULT NULL`);
@@ -540,18 +816,21 @@ async function updateTable(table, s, ctx) {
540
816
  }
541
817
  }
542
818
  /**
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.
819
+ * Special handling: if this is a localization-owned table and 'locale' is being removed.
820
+ * For destinationDb tables, this means a select/tags field was delocalized.
821
+ * For gallery tables, this means gallery.localized was disabled or global localization was disabled.
545
822
  * Handle this with a detailed prompt and proper data cleanup.
546
823
  */
547
- if (table.sectionType === 'destinationDb' && fieldsToRemove.includes('locale')) {
824
+ if ((table.sectionType === 'destinationDb' || table.sectionType === 'gallery') &&
825
+ fieldsToRemove.includes('locale')) {
548
826
  fieldsToRemove = fieldsToRemove.filter((f) => f !== 'locale');
549
- // When localization was globally disabled, we already asked upfront auto-confirm here.
827
+ // When localization was globally disabled, we already asked upfront - auto-confirm here.
550
828
  let shouldDelocalize = ctx?.localizationTransition === 'disable' ? 'yes' : 'no';
551
829
  if (ctx?.localizationTransition !== 'disable') {
552
830
  s.stop();
831
+ const tableDescription = table.sectionType === 'gallery' ? 'gallery table' : 'junction table';
553
832
  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` +
833
+ message: `The 'locale' column in ${tableDescription} '${table.name}' is no longer needed. This will:\n` +
555
834
  ` 1. Delete all non-default-locale rows (locale != '${defaultLocaleCode}')\n` +
556
835
  ` 2. Drop the 'locale' column from the table\n` +
557
836
  ` 3. Update the primary key to exclude 'locale'\n` +
@@ -566,6 +845,26 @@ async function updateTable(table, s, ctx) {
566
845
  }
567
846
  if (shouldDelocalize === 'yes') {
568
847
  try {
848
+ if (table.sectionType === 'gallery') {
849
+ const photoFieldName = table.gallery?.photoNameField ?? 'photo';
850
+ try {
851
+ const { deletedFiles, failedFiles } = await deleteGalleryLocaleFiles(table.name, photoFieldName, table.sectionName, defaultLocaleCode, ctx?.uploadsFolder ?? '');
852
+ if (deletedFiles > 0) {
853
+ console.log(chalk.gray(` - Deleted ${deletedFiles} gallery file(s) for non-default locales from '${table.sectionName}'`));
854
+ }
855
+ if (failedFiles.length > 0) {
856
+ console.error(chalk.red(` - Failed to delete ${failedFiles.length} gallery file(s) from '${table.sectionName}'. These files were left on disk:`));
857
+ for (const { filePath, error } of failedFiles) {
858
+ console.error(chalk.red(` ${filePath}: ${String(error)}`));
859
+ }
860
+ }
861
+ }
862
+ catch (error) {
863
+ // Reached only when the SELECT itself fails (DB-level error).
864
+ // Per-file unlink failures are collected inside and reported above.
865
+ console.error(chalk.red(` - Error reading '${table.name}' to clean up gallery locale files:`, error));
866
+ }
867
+ }
569
868
  // Delete all non-default-locale rows
570
869
  await db.execute(sql `DELETE FROM ${sql.raw(`\`${table.name}\``)} WHERE \`locale\` != ${defaultLocaleCode}`);
571
870
  log.info(chalk.gray(` - Deleted non-default-locale rows from '${table.name}'`));
@@ -573,6 +872,14 @@ async function updateTable(table, s, ctx) {
573
872
  catch (error) {
574
873
  console.error(chalk.red(` - Error deleting locale rows from '${table.name}':`, error));
575
874
  }
875
+ if (existingKeys?.primaryKeys.includes('locale')) {
876
+ alterTableSQLs.push({
877
+ field: 'primary key',
878
+ table: table.name,
879
+ action: 'remove',
880
+ sql: `DROP PRIMARY KEY`,
881
+ });
882
+ }
576
883
  alterTableSQLs.push({
577
884
  field: 'locale',
578
885
  table: table.name,
@@ -644,7 +951,8 @@ async function updateTable(table, s, ctx) {
644
951
  /**
645
952
  * Add the keys to the table
646
953
  */
647
- const keyErrors = await addTableKeys(table, existingKeys);
954
+ const updatedKeys = await MysqlTableChecker.getExistingKeys(table.name);
955
+ const keyErrors = await addTableKeys(table, updatedKeys);
648
956
  sqlErrors += keyErrors;
649
957
  s.stop(chalk.italic.hex(`${sqlErrors > 0 ? `#FFA500` : `#fafafa`}`)(`- Table \`${table.name}\` modified successfully ${sqlErrors > 0 ? `with ${sqlErrors} error(s).` : ''}`));
650
958
  }
@@ -671,6 +979,8 @@ const main = async (s) => {
671
979
  let sections = [];
672
980
  const desiredTables = [];
673
981
  let existingTables = [];
982
+ const externalSelectDbTables = new Set();
983
+ const localesTableAssetMetadata = new Map();
674
984
  sections = await SectionFactory.getSectionsSilently();
675
985
  console.log(`Found ${sections.length} section(s) to insert: `);
676
986
  console.log(chalk.gray(sections.map((s) => s.name).join(', ')));
@@ -678,25 +988,24 @@ const main = async (s) => {
678
988
  * Let's see if the table `__nextjs_cms_tables` exists in the database.
679
989
  * If it doesn't, we'll create it using the same schema as `NextJsCmsTablesTable`.
680
990
  */
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
- )
991
+ await db.execute(sql `
992
+ CREATE TABLE IF NOT EXISTS __nextjs_cms_tables (
993
+ name VARCHAR(100) NOT NULL PRIMARY KEY,
994
+ section VARCHAR(200),
995
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
996
+ )
687
997
  `);
688
998
  /**
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.
999
+ * Persistent key/value store for CMS-level state that must survive config removal.
692
1000
  */
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
- )
1001
+ await db.execute(sql `
1002
+ CREATE TABLE IF NOT EXISTS __nextjs_cms_config (
1003
+ \`key\` VARCHAR(100) NOT NULL PRIMARY KEY,
1004
+ \`value\` LONGTEXT,
1005
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
1006
+ )
699
1007
  `);
1008
+ await db.execute(sql `ALTER TABLE \`__nextjs_cms_config\` MODIFY COLUMN \`value\` LONGTEXT`);
700
1009
  /**
701
1010
  * Get the existing tables from the database
702
1011
  */
@@ -712,33 +1021,54 @@ const main = async (s) => {
712
1021
  */
713
1022
  const RESERVED_TABLE_SUFFIX = '_locales';
714
1023
  const RESERVED_COLUMN_NAME = 'locale';
715
- const reservedNameErrors = [];
1024
+ const configurationErrors = [];
1025
+ const builtSections = sections.map((_s) => {
1026
+ const section = _s.build();
1027
+ section.buildFields();
1028
+ return section;
1029
+ });
1030
+ const managedSectionTables = new Map(builtSections.map((section) => [section.db.table, section.name]));
716
1031
  /**
717
1032
  * Insert the sections into the database
718
1033
  */
719
- for (const _s of sections) {
720
- const s = _s.build();
721
- s.buildFields();
1034
+ for (const s of builtSections) {
1035
+ localesTableAssetMetadata.set(s.localesTableName, {
1036
+ sectionName: s.name,
1037
+ fields: getLocalizedAssetFields(s.fieldConfigs),
1038
+ });
722
1039
  if (s.db.table.endsWith(RESERVED_TABLE_SUFFIX)) {
723
- reservedNameErrors.push(`Section '${s.name}': table name '${s.db.table}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
1040
+ configurationErrors.push(`Section '${s.name}': table name '${s.db.table}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
724
1041
  }
725
1042
  for (const field of s.fields) {
726
1043
  if (field.name === RESERVED_COLUMN_NAME) {
727
- reservedNameErrors.push(`Section '${s.name}', field '${field.name}': field name '${RESERVED_COLUMN_NAME}' is reserved.`);
1044
+ configurationErrors.push(`Section '${s.name}', field '${field.name}': field name '${RESERVED_COLUMN_NAME}' is reserved.`);
728
1045
  }
729
1046
  if (field.destinationDb) {
730
1047
  if (field.destinationDb.table.endsWith(RESERVED_TABLE_SUFFIX)) {
731
- reservedNameErrors.push(`Section '${s.name}', field '${field.name}': destinationDb.table '${field.destinationDb.table}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
1048
+ configurationErrors.push(`Section '${s.name}', field '${field.name}': destinationDb.table '${field.destinationDb.table}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
732
1049
  }
733
1050
  if (field.destinationDb.itemIdentifier === RESERVED_COLUMN_NAME ||
734
1051
  field.destinationDb.selectIdentifier === RESERVED_COLUMN_NAME) {
735
- reservedNameErrors.push(`Section '${s.name}', field '${field.name}': destinationDb identifier column name '${RESERVED_COLUMN_NAME}' is reserved.`);
1052
+ configurationErrors.push(`Section '${s.name}', field '${field.name}': destinationDb identifier column name '${RESERVED_COLUMN_NAME}' is reserved.`);
1053
+ }
1054
+ }
1055
+ if (isExternalSelectDbField(field)) {
1056
+ const ownerSectionName = managedSectionTables.get(field.db.table);
1057
+ if (ownerSectionName) {
1058
+ configurationErrors.push(`Section '${s.name}', field '${field.name}': db.table '${field.db.table}' belongs to managed section '${ownerSectionName}'. Use the 'section' prop instead of 'db' so update-sections does not treat the section table as an external lookup table.`);
1059
+ }
1060
+ if (field.db.table.endsWith(RESERVED_TABLE_SUFFIX)) {
1061
+ configurationErrors.push(`Section '${s.name}', field '${field.name}': db.table '${field.db.table}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
1062
+ }
1063
+ const reservedColumns = [field.db.identifier, field.db.label, field.db.orderBy].filter((column) => column === RESERVED_COLUMN_NAME);
1064
+ if (reservedColumns.length > 0) {
1065
+ configurationErrors.push(`Section '${s.name}', field '${field.name}': db column name '${RESERVED_COLUMN_NAME}' is reserved.`);
736
1066
  }
737
1067
  }
738
1068
  }
739
1069
  const galleryForValidation = await s.getGallery();
740
1070
  if (galleryForValidation?.db.tableName && galleryForValidation.db.tableName.endsWith(RESERVED_TABLE_SUFFIX)) {
741
- reservedNameErrors.push(`Section '${s.name}': gallery table '${galleryForValidation.db.tableName}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
1071
+ configurationErrors.push(`Section '${s.name}': gallery table '${galleryForValidation.db.tableName}' ends with reserved suffix '${RESERVED_TABLE_SUFFIX}'.`);
742
1072
  }
743
1073
  /**
744
1074
  * Generate the Drizzle schema for the table
@@ -776,9 +1106,32 @@ const main = async (s) => {
776
1106
  * Check for `destinationDb`
777
1107
  */
778
1108
  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
- */
1109
+ if (isExternalSelectDbField(field) && !externalSelectDbTables.has(field.db.table)) {
1110
+ externalSelectDbTables.add(field.db.table);
1111
+ const identifierFieldConfig = buildSelectDbFieldConfig(field.db.identifier, 'Option Id', field.db.identifierType ?? 'text');
1112
+ const labelFieldConfig = buildSelectDbFieldConfig(field.db.label, 'Option Label');
1113
+ const selectDbFieldConfigs = [identifierFieldConfig, labelFieldConfig];
1114
+ if (field.db.orderBy &&
1115
+ field.db.orderBy !== field.db.identifier &&
1116
+ field.db.orderBy !== field.db.label) {
1117
+ selectDbFieldConfigs.push(buildSelectDbFieldConfig(field.db.orderBy, 'Option Order', 'text', false));
1118
+ }
1119
+ desiredTables.push({
1120
+ name: field.db.table,
1121
+ fields: selectDbFieldConfigs.map((fieldConfig) => fieldConfig.build()),
1122
+ sectionName: s.name,
1123
+ sectionType: 'selectDb',
1124
+ identifier: identifierFieldConfig,
1125
+ primaryKey: [identifierFieldConfig],
1126
+ });
1127
+ const selectDbSchema = generateDrizzleSchema({
1128
+ name: field.db.table,
1129
+ fields: selectDbFieldConfigs,
1130
+ identifier: identifierFieldConfig,
1131
+ }, caseStyleFns);
1132
+ drizzleTableSchemas.push(selectDbSchema.schema);
1133
+ selectDbSchema.drizzleImports.forEach((type) => drizzleImports.add(type));
1134
+ }
782
1135
  if (field.destinationDb) {
783
1136
  console.log('Destination DB found for input:', field.name, 'with table:', field.destinationDb.table);
784
1137
  const parentIdentifierType = s.db.identifier.type;
@@ -869,6 +1222,7 @@ const main = async (s) => {
869
1222
  const gallery = await s.getGallery();
870
1223
  if (gallery?.db.tableName) {
871
1224
  console.log('Gallery found for section:', s.name, 'with table:', gallery.db.tableName);
1225
+ const isLocalizedGallery = gallery.localized === true && cmsConfig.localization?.enabled === true;
872
1226
  const photoField = textField({
873
1227
  name: gallery.db.photoNameField || 'photo',
874
1228
  label: 'Photo Name',
@@ -888,58 +1242,85 @@ const main = async (s) => {
888
1242
  required: true,
889
1243
  order: 0,
890
1244
  });
1245
+ const localeFieldConfig = isLocalizedGallery ? buildLocaleFieldConfig() : undefined;
1246
+ const galleryFields = [
1247
+ galleryRefField.build(),
1248
+ photoField.build(),
1249
+ textAreaField({
1250
+ name: gallery.db.metaField || 'meta',
1251
+ label: 'Photo Information',
1252
+ required: false,
1253
+ order: 0,
1254
+ }).build(),
1255
+ ...(localeFieldConfig ? [localeFieldConfig.build()] : []),
1256
+ ];
1257
+ const galleryPrimaryKey = localeFieldConfig ? [photoField, localeFieldConfig] : [photoField];
891
1258
  desiredTables.push({
892
1259
  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
- ],
1260
+ fields: galleryFields,
903
1261
  sectionName: s.name,
904
1262
  sectionType: 'gallery',
905
1263
  /**
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
1264
+ * Non-localized galleries keep the photo field as the identifier.
1265
+ * Localized galleries use a composite primary key: photo + locale.
908
1266
  */
909
- identifier: photoField,
910
- primaryKey: [photoField],
1267
+ identifier: isLocalizedGallery ? undefined : photoField,
1268
+ primaryKey: galleryPrimaryKey,
1269
+ gallery: {
1270
+ referenceIdentifierField: gallery.db.referenceIdentifierField || 'reference_id',
1271
+ photoNameField: gallery.db.photoNameField || 'photo',
1272
+ metaField: gallery.db.metaField || 'meta',
1273
+ localized: isLocalizedGallery,
1274
+ },
911
1275
  });
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: {
1276
+ const gallerySchemaFields = [
1277
+ {
1278
+ name: gallery.db.referenceIdentifierField || 'reference_id',
1279
+ type: s.db.identifier.type,
1280
+ required: true,
1281
+ },
1282
+ {
932
1283
  name: gallery.db.photoNameField || 'photo',
933
1284
  type: 'text',
1285
+ required: true,
934
1286
  },
1287
+ {
1288
+ name: gallery.db.metaField || 'meta',
1289
+ type: 'textarea',
1290
+ required: false,
1291
+ },
1292
+ ...(isLocalizedGallery
1293
+ ? [
1294
+ {
1295
+ name: 'locale',
1296
+ type: 'text',
1297
+ maxLength: 10,
1298
+ required: true,
1299
+ },
1300
+ ]
1301
+ : []),
1302
+ ];
1303
+ const gallerySchema = generateDrizzleSchema({
1304
+ name: gallery.db.tableName,
1305
+ fields: gallerySchemaFields,
1306
+ ...(isLocalizedGallery
1307
+ ? {
1308
+ compositePrimaryKey: [{ name: gallery.db.photoNameField || 'photo' }, { name: 'locale' }],
1309
+ }
1310
+ : {
1311
+ identifier: {
1312
+ name: gallery.db.photoNameField || 'photo',
1313
+ type: 'text',
1314
+ },
1315
+ }),
935
1316
  }, caseStyleFns);
936
1317
  drizzleTableSchemas.push(gallerySchema.schema);
937
1318
  gallerySchema.drizzleImports.forEach((type) => drizzleImports.add(type));
938
1319
  }
939
1320
  /**
940
- * Check for localized fields — generate a _locales table if localization is enabled
1321
+ * Check for localized content — generate a _locales table if localization is enabled
941
1322
  */
942
- if (cmsConfig.localization?.enabled && s.hasLocalizedFields) {
1323
+ if (cmsConfig.localization?.enabled && s.hasLocalizedContent) {
943
1324
  const localesTableName = s.localesTableName;
944
1325
  const parentIdFieldConfig = s.db.identifier.type === 'number'
945
1326
  ? numberField({
@@ -996,13 +1377,13 @@ const main = async (s) => {
996
1377
  }
997
1378
  }
998
1379
  /**
999
- * Reject the run before any DB write if any section used a reserved name.
1380
+ * Reject the run before schema writes if any section configuration is unsafe.
1000
1381
  */
1001
- if (reservedNameErrors.length > 0) {
1002
- s.stop(`update-sections aborted: ${reservedNameErrors.length} reserved-name conflict(s) detected.\n` +
1003
- reservedNameErrors.map((e) => ` - ${e}`).join('\n') +
1004
- `\n\nThe suffix '${RESERVED_TABLE_SUFFIX}' and the column name '${RESERVED_COLUMN_NAME}' are reserved for the localization system. Please rename and re-run.`);
1005
- throw new Error('update-sections: reserved name conflicts detected');
1382
+ if (configurationErrors.length > 0) {
1383
+ s.stop(`update-sections aborted: ${configurationErrors.length} configuration conflict(s) detected.\n` +
1384
+ configurationErrors.map((e) => ` - ${e}`).join('\n') +
1385
+ `\n\nThe suffix '${RESERVED_TABLE_SUFFIX}' and the column name '${RESERVED_COLUMN_NAME}' are reserved for the localization system. Section-managed tables must be referenced with the 'section' prop. Please update cms.config.ts and re-run.`);
1386
+ throw new Error('update-sections: configuration conflicts detected');
1006
1387
  }
1007
1388
  /**
1008
1389
  * Write schema file if schema generation is enabled
@@ -1053,120 +1434,115 @@ const main = async (s) => {
1053
1434
  console.log(chalk.gray(tablesToRemove.map((table) => table.name).join(', ')));
1054
1435
  console.log(`\n`);
1055
1436
  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
1437
  const localizationCurrentlyEnabled = cmsConfig.localization?.enabled === true;
1064
- const configDefaultLocale = cmsConfig.localization?.defaultLocale;
1065
1438
  const editorPhotosColumns = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1066
1439
  const editorPhotosExists = editorPhotosColumns.length > 0;
1067
1440
  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
- }
1441
+ const storedLocalizationState = await readStoredLocalizationState();
1442
+ const storedEnabledLocalizationState = storedLocalizationState?.enabled ? storedLocalizationState : null;
1443
+ const storedPendingEnableLocalizationState = isStoredPendingEnableLocalizationState(storedLocalizationState)
1444
+ ? storedLocalizationState
1445
+ : null;
1446
+ const storedDisabledLocalizationState = isStoredDisabledLocalizationState(storedLocalizationState)
1447
+ ? storedLocalizationState
1448
+ : null;
1449
+ const registeredLocalizationArtifacts = !localizationCurrentlyEnabled || storedDisabledLocalizationState
1450
+ ? await getRegisteredLocalizationArtifacts(existingTables)
1451
+ : { localeColumnTables: [], localesTables: [] };
1452
+ const hasRegisteredLocalizationArtifacts = registeredLocalizationArtifacts.localeColumnTables.length > 0 ||
1453
+ registeredLocalizationArtifacts.localesTables.length > 0;
1454
+ const hasLocalizationArtifacts = editorPhotosHasLocale || hasRegisteredLocalizationArtifacts;
1455
+ if (storedDisabledLocalizationState && hasLocalizationArtifacts) {
1456
+ 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.`);
1457
+ }
1458
+ if (!localizationCurrentlyEnabled && !storedLocalizationState && hasLocalizationArtifacts) {
1459
+ 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
1460
  }
1082
1461
  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.`);
1462
+ let disableCleanupState = null;
1463
+ let enableTargetState = null;
1464
+ let enablePendingState = null;
1465
+ if (localizationCurrentlyEnabled && cmsConfig.localization) {
1466
+ const configLocalizationState = buildStoredEnabledLocalizationState(cmsConfig.localization);
1467
+ enableTargetState = configLocalizationState;
1468
+ const storedPendingDisableLocalizationState = storedEnabledLocalizationState?.transition?.type === 'disable' ? storedEnabledLocalizationState : null;
1469
+ if (storedEnabledLocalizationState &&
1470
+ storedEnabledLocalizationState.defaultLocale !== configLocalizationState.defaultLocale) {
1471
+ 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
1472
  return;
1091
1473
  }
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);
1474
+ if (storedPendingEnableLocalizationState &&
1475
+ storedPendingEnableLocalizationState.defaultLocale !== configLocalizationState.defaultLocale) {
1476
+ 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.`);
1477
+ return;
1096
1478
  }
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
1479
  /**
1105
1480
  * Localization is newly enabled: editor_photos lacks the locale column,
1106
1481
  * but the config now has localization.enabled = true. We must backfill
1107
- * editor_photos and any existing junction tables that carry localized data
1482
+ * editor_photos and any existing tables that carry localized data
1108
1483
  * with the configured defaultLocale.
1109
1484
  */
1110
- const affectedJunctions = [];
1485
+ const affectedLocalizedTables = [];
1111
1486
  for (const t of desiredTables) {
1112
- if (t.sectionType !== 'destinationDb')
1487
+ if (t.sectionType !== 'destinationDb' && t.sectionType !== 'gallery')
1113
1488
  continue;
1114
1489
  if (!t.fields.some((f) => f.name === 'locale'))
1115
1490
  continue;
1116
1491
  const cols = await MysqlTableChecker.getColumns(t.name).catch(() => []);
1117
1492
  if (cols.length > 0 && !cols.includes('locale')) {
1118
- affectedJunctions.push(t.name);
1493
+ affectedLocalizedTables.push(t.name);
1119
1494
  }
1120
1495
  }
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));
1496
+ const needsEditorPhotosMigration = editorPhotosExists && !editorPhotosHasLocale;
1497
+ const shouldRunEnableTransition = storedPendingEnableLocalizationState ||
1498
+ !storedEnabledLocalizationState ||
1499
+ storedPendingDisableLocalizationState;
1500
+ if (shouldRunEnableTransition) {
1501
+ const affectedList = [...(needsEditorPhotosMigration ? ['editor_photos'] : []), ...affectedLocalizedTables];
1502
+ const affectedListText = affectedList.length > 0
1503
+ ? affectedList.map((n) => ` - ${n}`).join('\n')
1504
+ : ` - No existing tables need a locale backfill; update-sections will verify localized tables after sync.`;
1505
+ const isEnableRetry = storedPendingEnableLocalizationState?.transition.status === 'pending';
1506
+ const isDisableRecovery = storedPendingDisableLocalizationState?.transition?.status === 'pending';
1507
+ s.stop();
1508
+ const confirm = await select({
1509
+ message: (isDisableRecovery
1510
+ ? `A previous localization disable attempt did not complete, but cms.config.ts has localization enabled again. update-sections will repair localization artifacts and clear the pending disable state after verification.\n\n`
1511
+ : isEnableRetry
1512
+ ? `A previous localization enable attempt did not complete. update-sections will retry setup now.\n\n`
1513
+ : `It looks like you enabled localization. Your existing data in these tables will be marked as defaultLocale '${configLocalizationState.defaultLocale}':\n`) +
1514
+ affectedListText +
1515
+ `\n\n${chalk.redBright('WARNING:')} Existing data in these tables MUST already be in '${configLocalizationState.defaultLocale}'. ` +
1516
+ `Changing defaultLocale after this point is not supported. If your data is not in '${configLocalizationState.defaultLocale}', ` +
1517
+ `stop now and fix your cms.config.ts before re-running update-sections.\n\nProceed?`,
1518
+ options: [
1519
+ { value: 'yes', label: `Yes, that's correct` },
1520
+ { value: 'no', label: 'No, stop - let me reconfigure localization in cms.config.ts' },
1521
+ ],
1522
+ initialValue: 'no',
1523
+ });
1524
+ if (confirm !== 'yes') {
1525
+ s.stop('Aborted. Reconfigure cms.config.ts and re-run update-sections.');
1526
+ return;
1527
+ }
1528
+ localizationTransition = 'enable';
1529
+ const now = new Date().toISOString();
1530
+ enablePendingState = buildStoredPendingEnableLocalizationState(configLocalizationState, {
1531
+ type: 'enable',
1532
+ status: 'pending',
1533
+ startedAt: storedPendingEnableLocalizationState?.transition.startedAt ?? now,
1534
+ ...(isEnableRetry || isDisableRecovery ? { lastAttemptAt: now } : {}),
1535
+ });
1536
+ await writeStoredLocalizationState(enablePendingState);
1537
+ s.start();
1151
1538
  }
1152
- s.start();
1539
+ await ensureEditorPhotosLocaleColumn(configLocalizationState.defaultLocale, editorPhotosColumns);
1153
1540
  }
1154
- else if (editorPhotosExists &&
1155
- !localizationCurrentlyEnabled &&
1156
- (editorPhotosHasLocale || hasResidualLocaleColumn)) {
1541
+ else if (!localizationCurrentlyEnabled && storedEnabledLocalizationState) {
1157
1542
  /**
1158
1543
  * 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.
1544
+ * the stored localization state is still enabled, but cms.config.ts no longer is.
1165
1545
  */
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
1546
  const affected = [
1171
1547
  { name: 'editor_photos', type: 'editor_photos' },
1172
1548
  ];
@@ -1180,23 +1556,27 @@ const main = async (s) => {
1180
1556
  affected.push({ name: existing.name, type: 'locales' });
1181
1557
  }
1182
1558
  else {
1183
- affected.push({ name: existing.name, type: 'junction' });
1559
+ affected.push({ name: existing.name, type: 'localized_table' });
1184
1560
  }
1185
1561
  }
1186
1562
  s.stop();
1563
+ const isDisableRetry = storedEnabledLocalizationState.transition?.status === 'pending';
1187
1564
  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` +
1565
+ message: (isDisableRetry
1566
+ ? `A previous localization disable attempt did not complete. update-sections will retry cleanup now.\n\n`
1567
+ : `It looks like you disabled localization. The 'locale' column in the tables below is no longer needed and will be dropped.\n\n`) +
1568
+ `Base locale (from '__nextjs_cms_config'): '${storedEnabledLocalizationState.defaultLocale}'. Rows tagged with this locale will be kept; all others will be deleted.\n\n` +
1190
1569
  `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` +
1570
+ ` 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` +
1571
+ ` 2. Drop the 'locale' column from editor_photos and localized tables\n` +
1193
1572
  ` 3. Drop '_locales' tables entirely\n\n` +
1194
1573
  `Affected tables:\n` +
1195
1574
  affected.map((t) => ` - ${t.name} (${t.type})`).join('\n') +
1575
+ `\n\n${chalk.redBright('WARNING: This action is irreversible and will permanently delete data and affected tables. It is recommended to make a backup of your data before proceeding.')}` +
1196
1576
  `\n\nProceed?`,
1197
1577
  options: [
1198
1578
  { value: 'yes', label: `Yes, that's correct` },
1199
- { value: 'no', label: 'No, stop let me reconfigure localization in cms.config.ts' },
1579
+ { value: 'no', label: 'No, stop - let me reconfigure localization in cms.config.ts' },
1200
1580
  ],
1201
1581
  initialValue: 'no',
1202
1582
  });
@@ -1205,22 +1585,34 @@ const main = async (s) => {
1205
1585
  return;
1206
1586
  }
1207
1587
  localizationTransition = 'disable';
1588
+ const now = new Date().toISOString();
1589
+ disableCleanupState = {
1590
+ ...storedEnabledLocalizationState,
1591
+ transition: {
1592
+ type: 'disable',
1593
+ status: 'pending',
1594
+ startedAt: storedEnabledLocalizationState.transition?.startedAt ?? now,
1595
+ ...(isDisableRetry ? { lastAttemptAt: now } : {}),
1596
+ },
1597
+ };
1598
+ await writeStoredLocalizationState(disableCleanupState);
1208
1599
  if (editorPhotosHasLocale) {
1209
1600
  try {
1210
1601
  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
1602
+ const { deletedFiles, failedFiles } = await deleteEditorPhotosLocaleFiles(storedEnabledLocalizationState.defaultLocale, uploadsFolder);
1603
+ if (deletedFiles > 0) {
1604
+ console.log(chalk.gray(` - Deleted ${deletedFiles} editor photo file(s) for non-default locales`));
1605
+ }
1606
+ if (failedFiles.length > 0) {
1607
+ // TODO: Add the failed-to-delete files to system_issues table (system_issues system not yet implemented)
1608
+ console.error(chalk.red(` - Failed to delete ${failedFiles.length} editor photo file(s). These files were left on disk:`));
1609
+ for (const { filePath, error } of failedFiles) {
1610
+ console.error(chalk.red(` ${filePath}: ${String(error)}`));
1219
1611
  }
1220
1612
  }
1221
- await db.execute(sql `DELETE FROM \`editor_photos\` WHERE \`locale\` != ${storedDefaultLocale}`);
1613
+ await db.execute(sql `DELETE FROM \`editor_photos\` WHERE \`locale\` != ${storedEnabledLocalizationState.defaultLocale}`);
1222
1614
  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'.`));
1615
+ log.info(chalk.gray(` - Removed non-base-locale row(s) and dropped 'locale' column from 'editor_photos'.`));
1224
1616
  }
1225
1617
  catch (error) {
1226
1618
  console.error(chalk.red(` - Error migrating 'editor_photos' for localization disable:`, error));
@@ -1231,7 +1623,10 @@ const main = async (s) => {
1231
1623
  }
1232
1624
  s.start();
1233
1625
  }
1234
- const ctx = { localizationTransition, defaultLocaleCode };
1626
+ const defaultLocaleCode = localizationCurrentlyEnabled
1627
+ ? (cmsConfig.localization?.defaultLocale ?? storedEnabledLocalizationState?.defaultLocale ?? 'en')
1628
+ : (storedEnabledLocalizationState?.defaultLocale ?? cmsConfig.localization?.defaultLocale ?? 'en');
1629
+ const ctx = { localizationTransition, defaultLocaleCode, uploadsFolder: cmsConfig.media.upload.path };
1235
1630
  /**
1236
1631
  * Check if there are tables to update
1237
1632
  */
@@ -1342,7 +1737,7 @@ const main = async (s) => {
1342
1737
  * Loop through the tables to remove
1343
1738
  */
1344
1739
  for (const table of tablesToRemove) {
1345
- // When localization was globally disabled, auto-confirm dropping _locales tables
1740
+ // When localization was globally disabled, auto-confirm dropping _locales tables -
1346
1741
  // the upfront prompt already covered them.
1347
1742
  const autoDropLocales = localizationTransition === 'disable' && table.name.endsWith('_locales') ? 'yes' : null;
1348
1743
  let opType;
@@ -1366,6 +1761,26 @@ const main = async (s) => {
1366
1761
  case 'yes':
1367
1762
  case 'no':
1368
1763
  if (opType === 'yes') {
1764
+ if (table.name.endsWith('_locales')) {
1765
+ try {
1766
+ const { deletedFiles, failedFiles } = await deleteLocalizedAssetFilesFromLocalesTable(table.name, localesTableAssetMetadata.get(table.name), cmsConfig.media.upload.path);
1767
+ if (deletedFiles > 0) {
1768
+ console.log(chalk.gray(` - Deleted ${deletedFiles} localized asset file(s) referenced by '${table.name}'`));
1769
+ }
1770
+ if (failedFiles.length > 0) {
1771
+ // TODO: Add the failed-to-delete files to system_issues table (system_issues system not yet implemented)
1772
+ console.error(chalk.red(` - Failed to delete ${failedFiles.length} localized asset file(s) referenced by '${table.name}'. These files were left on disk:`));
1773
+ for (const { filePath, error } of failedFiles) {
1774
+ console.error(chalk.red(` ${filePath}: ${String(error)}`));
1775
+ }
1776
+ }
1777
+ }
1778
+ catch (error) {
1779
+ // Reached only when the SELECT itself fails (DB-level error).
1780
+ // Per-file unlink failures are collected inside and reported above.
1781
+ console.error(chalk.red(` - Error reading '${table.name}' to clean up localized asset files:`, error));
1782
+ }
1783
+ }
1369
1784
  console.log(chalk.gray(` - Dropping table '${table.name}'`));
1370
1785
  await db.execute(sql `DROP TABLE \`${sql.raw(table.name)}\``);
1371
1786
  }
@@ -1388,19 +1803,45 @@ const main = async (s) => {
1388
1803
  }
1389
1804
  }
1390
1805
  }
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
- */
1806
+ if (localizationTransition === 'enable') {
1807
+ if (!enableTargetState || !enablePendingState) {
1808
+ throw new Error(`Localization enable setup state was not initialized.`);
1809
+ }
1810
+ const missingArtifacts = [];
1811
+ if (editorPhotosExists) {
1812
+ const postEditorPhotosCols = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1813
+ if (!postEditorPhotosCols.includes('locale')) {
1814
+ missingArtifacts.push(`editor_photos.locale`);
1815
+ }
1816
+ }
1817
+ for (const table of desiredTables) {
1818
+ if ((table.sectionType === 'destinationDb' || table.sectionType === 'gallery') &&
1819
+ table.fields.some((field) => field.name === 'locale')) {
1820
+ const cols = await MysqlTableChecker.getColumns(table.name).catch(() => []);
1821
+ if (!cols.includes('locale')) {
1822
+ missingArtifacts.push(`${table.name}.locale`);
1823
+ }
1824
+ }
1825
+ if (table.sectionType === 'locales') {
1826
+ const stillExists = (await MysqlTableChecker.getExistingTableStructure(table.name)) !== null;
1827
+ if (!stillExists) {
1828
+ missingArtifacts.push(table.name);
1829
+ }
1830
+ }
1831
+ }
1832
+ if (missingArtifacts.length > 0) {
1833
+ 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.`);
1834
+ }
1835
+ await writeStoredLocalizationState(enableTargetState);
1836
+ }
1399
1837
  if (localizationTransition === 'disable') {
1838
+ if (!disableCleanupState) {
1839
+ throw new Error(`Localization disable cleanup state was not initialized.`);
1840
+ }
1400
1841
  const postEditorPhotosCols = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1401
1842
  let fullyClean = !postEditorPhotosCols.includes('locale');
1402
1843
  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.`));
1844
+ log.warn(chalk.yellow(` - 'editor_photos' still has a 'locale' column after cleanup. Keeping localization disable pending so the next run can resume.`));
1404
1845
  }
1405
1846
  const postRegistered = fullyClean
1406
1847
  ? await db.select({ name: NextJsCmsTablesTable.tableName }).from(NextJsCmsTablesTable)
@@ -1410,26 +1851,32 @@ const main = async (s) => {
1410
1851
  const cols = await MysqlTableChecker.getColumns(t.name).catch(() => []);
1411
1852
  if (cols.includes('locale')) {
1412
1853
  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.`));
1854
+ 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
1855
  break;
1415
1856
  }
1416
1857
  }
1417
1858
  }
1418
1859
  if (fullyClean) {
1419
- // Check registered tables whose name ends with '_locales' scoped to
1860
+ // Check registered tables whose name ends with '_locales' - scoped to
1420
1861
  // __nextjs_cms_tables so unrelated tables sharing the DB are never flagged.
1421
1862
  const registeredLocalesTables = postRegistered.filter((t) => t.name.endsWith('_locales'));
1422
1863
  for (const t of registeredLocalesTables) {
1423
1864
  const stillExists = (await MysqlTableChecker.getExistingTableStructure(t.name)) !== null;
1424
1865
  if (stillExists) {
1425
1866
  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.`));
1867
+ log.warn(chalk.yellow(` - Registered '_locales' table '${t.name}' still exists after cleanup. Keeping localization disable pending so the next run can resume.`));
1427
1868
  break;
1428
1869
  }
1429
1870
  }
1430
1871
  }
1431
1872
  if (fullyClean) {
1432
- await deleteStoredDefaultLocale();
1873
+ await writeStoredLocalizationState({
1874
+ version: STORED_LOCALIZATION_VERSION,
1875
+ enabled: false,
1876
+ });
1877
+ }
1878
+ else {
1879
+ 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
1880
  }
1434
1881
  }
1435
1882
  };