nextjs-cms 0.9.16 → 0.9.18

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.
@@ -1 +1 @@
1
- {"version":3,"file":"update-sections.d.ts","sourceRoot":"","sources":["../../../src/cli/lib/update-sections.ts"],"names":[],"mappings":"AA4wCA,wBAAsB,cAAc,CAAC,SAAS,UAAQ,iBAoBrD"}
1
+ {"version":3,"file":"update-sections.d.ts","sourceRoot":"","sources":["../../../src/cli/lib/update-sections.ts"],"names":[],"mappings":"AAirDA,wBAAsB,cAAc,CAAC,SAAS,UAAQ,iBAoBrD"}
@@ -51,6 +51,9 @@ function generateFieldSQL(input) {
51
51
  if (input.optionsType === 'static' && input.options) {
52
52
  fieldSQL += `ENUM(${input.options.map((option) => `'${option.value}'`).join(', ')}) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`;
53
53
  }
54
+ else if (input.db?.identifierType === 'number') {
55
+ fieldSQL += 'INT(11)';
56
+ }
54
57
  else {
55
58
  fieldSQL += 'VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
56
59
  }
@@ -228,6 +231,18 @@ function resolveCreateTableOptions(sectionType) {
228
231
  };
229
232
  }
230
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`);
237
+ const row = rows?.[0];
238
+ return row?.value ?? null;
239
+ }
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}`);
242
+ }
243
+ async function deleteStoredDefaultLocale() {
244
+ await db.execute(sql `DELETE FROM \`__nextjs_cms_config\` WHERE \`key\` = ${STORED_DEFAULT_LOCALE_KEY}`);
245
+ }
231
246
  async function ensureTableRegistryEntry(tableName, sectionName) {
232
247
  await db
233
248
  .insert(NextJsCmsTablesTable)
@@ -355,7 +370,7 @@ async function renameTable(oldName, newName) {
355
370
  console.error(`Error renaming table \`${oldName}\` to \`${newName}\`:`, error);
356
371
  }
357
372
  }
358
- async function updateTable(table, s) {
373
+ async function updateTable(table, s, ctx) {
359
374
  console.log();
360
375
  console.log(chalk.blueBright(`Updating table '${table.name}' for section '${table.sectionName}'`));
361
376
  s.start();
@@ -412,6 +427,30 @@ async function updateTable(table, s) {
412
427
  }
413
428
  }
414
429
  }
430
+ /**
431
+ * Special handling: if this is a destinationDb table and 'locale' is being added
432
+ * (either because localization was newly enabled OR a field was flipped to localized:true),
433
+ * run the 3-step migration inline so rows are backfilled with defaultLocale and the
434
+ * column ends up NOT NULL (required for the primary key to include it).
435
+ */
436
+ const defaultLocaleCode = ctx?.defaultLocaleCode ?? 'en';
437
+ const localeAddIndex = fieldsToAdd.findIndex((f) => f.name === 'locale');
438
+ if (table.sectionType === 'destinationDb' && localeAddIndex !== -1) {
439
+ const [localeField] = fieldsToAdd.splice(localeAddIndex, 1);
440
+ try {
441
+ await db.execute(sql `ALTER TABLE \`${sql.raw(table.name)}\` ADD COLUMN \`locale\` VARCHAR(10) DEFAULT NULL`);
442
+ await db.execute(sql `UPDATE \`${sql.raw(table.name)}\` SET \`locale\` = ${defaultLocaleCode} WHERE \`locale\` IS NULL`);
443
+ await db.execute(sql `ALTER TABLE \`${sql.raw(table.name)}\` MODIFY COLUMN \`locale\` VARCHAR(10) NOT NULL`);
444
+ log.info(chalk.gray(` - Added 'locale' column to '${table.name}' and backfilled existing rows with '${defaultLocaleCode}'.`));
445
+ }
446
+ catch (error) {
447
+ sqlErrors++;
448
+ console.error(chalk.red(` - Error migrating 'locale' column on '${table.name}':`, error));
449
+ // Restore to add-list so the generic ADD path still attempts if backfill failed
450
+ if (localeField)
451
+ fieldsToAdd.push(localeField);
452
+ }
453
+ }
415
454
  /**
416
455
  * Check if there are fields to add
417
456
  */
@@ -507,21 +546,24 @@ async function updateTable(table, s) {
507
546
  */
508
547
  if (table.sectionType === 'destinationDb' && fieldsToRemove.includes('locale')) {
509
548
  fieldsToRemove = fieldsToRemove.filter((f) => f !== 'locale');
510
- s.stop();
511
- const cmsConfig = await getCMSConfig();
512
- const defaultLocaleCode = cmsConfig.localization?.defaultLocale ?? 'en';
513
- const shouldDelocalize = await select({
514
- message: `The 'locale' column in junction table '${table.name}' is no longer needed (field is no longer localized). This will:\n` +
515
- ` 1. Delete all non-default-locale rows (locale != '${defaultLocaleCode}')\n` +
516
- ` 2. Drop the 'locale' column from the table\n` +
517
- ` 3. Update the primary key to exclude 'locale'\n` +
518
- `Proceed?`,
519
- options: [
520
- { value: 'no', label: 'No, keep the locale column' },
521
- { value: 'yes', label: 'Yes, remove locale data and column' },
522
- ],
523
- initialValue: 'no',
524
- });
549
+ // When localization was globally disabled, we already asked upfront — auto-confirm here.
550
+ let shouldDelocalize = ctx?.localizationTransition === 'disable' ? 'yes' : 'no';
551
+ if (ctx?.localizationTransition !== 'disable') {
552
+ s.stop();
553
+ 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` +
555
+ ` 1. Delete all non-default-locale rows (locale != '${defaultLocaleCode}')\n` +
556
+ ` 2. Drop the 'locale' column from the table\n` +
557
+ ` 3. Update the primary key to exclude 'locale'\n` +
558
+ `Proceed?`,
559
+ options: [
560
+ { value: 'no', label: 'No, keep the locale column' },
561
+ { value: 'yes', label: 'Yes, remove locale data and column' },
562
+ ],
563
+ initialValue: 'no',
564
+ });
565
+ s.start();
566
+ }
525
567
  if (shouldDelocalize === 'yes') {
526
568
  try {
527
569
  // Delete all non-default-locale rows
@@ -542,7 +584,6 @@ async function updateTable(table, s) {
542
584
  else {
543
585
  log.info(chalk.yellow(`- 'locale' column kept in '${table.name}'.`));
544
586
  }
545
- s.start();
546
587
  }
547
588
  /**
548
589
  * Finally, check if there are fields to remove
@@ -637,12 +678,24 @@ const main = async (s) => {
637
678
  * Let's see if the table `__nextjs_cms_tables` exists in the database.
638
679
  * If it doesn't, we'll create it using the same schema as `NextJsCmsTablesTable`.
639
680
  */
640
- await db.execute(sql `
641
- CREATE TABLE IF NOT EXISTS __nextjs_cms_tables (
642
- name VARCHAR(100) NOT NULL PRIMARY KEY,
643
- section VARCHAR(200),
644
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
645
- )
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
+ )
687
+ `);
688
+ /**
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.
692
+ */
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
+ )
646
699
  `);
647
700
  /**
648
701
  * Get the existing tables from the database
@@ -652,12 +705,41 @@ const main = async (s) => {
652
705
  name: NextJsCmsTablesTable.tableName,
653
706
  })
654
707
  .from(NextJsCmsTablesTable);
708
+ /**
709
+ * Reserved-name validation. Collected across all sections so the dev sees every
710
+ * conflict at once instead of fixing them one by one.
711
+ * Reserved: table suffix `_locales`, column name `locale`.
712
+ */
713
+ const RESERVED_TABLE_SUFFIX = '_locales';
714
+ const RESERVED_COLUMN_NAME = 'locale';
715
+ const reservedNameErrors = [];
655
716
  /**
656
717
  * Insert the sections into the database
657
718
  */
658
719
  for (const _s of sections) {
659
720
  const s = _s.build();
660
721
  s.buildFields();
722
+ 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}'.`);
724
+ }
725
+ for (const field of s.fields) {
726
+ if (field.name === RESERVED_COLUMN_NAME) {
727
+ reservedNameErrors.push(`Section '${s.name}', field '${field.name}': field name '${RESERVED_COLUMN_NAME}' is reserved.`);
728
+ }
729
+ if (field.destinationDb) {
730
+ 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}'.`);
732
+ }
733
+ if (field.destinationDb.itemIdentifier === RESERVED_COLUMN_NAME ||
734
+ field.destinationDb.selectIdentifier === RESERVED_COLUMN_NAME) {
735
+ reservedNameErrors.push(`Section '${s.name}', field '${field.name}': destinationDb identifier column name '${RESERVED_COLUMN_NAME}' is reserved.`);
736
+ }
737
+ }
738
+ }
739
+ const galleryForValidation = await s.getGallery();
740
+ 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}'.`);
742
+ }
661
743
  /**
662
744
  * Generate the Drizzle schema for the table
663
745
  */
@@ -698,21 +780,36 @@ const main = async (s) => {
698
780
  */
699
781
  if (field.destinationDb) {
700
782
  console.log('Destination DB found for input:', field.name, 'with table:', field.destinationDb.table);
701
- /**
702
- * TODO: We should get the type of the identifier and the Select fields to match the types
703
- */
704
- const referenceIdFieldConfig = textField({
705
- name: field.destinationDb.itemIdentifier,
706
- label: 'Reference Id',
707
- required: true,
708
- order: 0,
709
- });
710
- const selectIdFieldConfig = textField({
711
- name: field.destinationDb.selectIdentifier,
712
- label: 'Select Id',
713
- required: true,
714
- order: 0,
715
- });
783
+ const parentIdentifierType = s.db.identifier.type;
784
+ const referenceIdFieldConfig = parentIdentifierType === 'number'
785
+ ? numberField({
786
+ name: field.destinationDb.itemIdentifier,
787
+ label: 'Reference Id',
788
+ required: true,
789
+ order: 0,
790
+ })
791
+ : textField({
792
+ name: field.destinationDb.itemIdentifier,
793
+ label: 'Reference Id',
794
+ required: true,
795
+ order: 0,
796
+ });
797
+ const selectIdentifierType = is(field, SelectField) || is(field, SelectMultipleField)
798
+ ? (field.db?.identifierType ?? 'text')
799
+ : 'text';
800
+ const selectIdFieldConfig = selectIdentifierType === 'number'
801
+ ? numberField({
802
+ name: field.destinationDb.selectIdentifier,
803
+ label: 'Select Id',
804
+ required: true,
805
+ order: 0,
806
+ })
807
+ : textField({
808
+ name: field.destinationDb.selectIdentifier,
809
+ label: 'Select Id',
810
+ required: true,
811
+ order: 0,
812
+ });
716
813
  /**
717
814
  * If the field is localized, add a locale column to the destination table
718
815
  * so that junction table values can be scoped per locale
@@ -722,7 +819,8 @@ const main = async (s) => {
722
819
  ? textField({
723
820
  name: 'locale',
724
821
  label: 'Locale',
725
- required: false,
822
+ required: true,
823
+ maxLength: 10,
726
824
  order: 0,
727
825
  })
728
826
  : undefined;
@@ -741,9 +839,17 @@ const main = async (s) => {
741
839
  primaryKey: primaryKeyFields,
742
840
  });
743
841
  const destDbFields = [
744
- { name: field.destinationDb.itemIdentifier, type: 'text', required: true },
745
- { name: field.destinationDb.selectIdentifier, type: 'text', required: true },
746
- ...(isLocalized ? [{ name: 'locale', type: 'text', required: false }] : []),
842
+ {
843
+ name: field.destinationDb.itemIdentifier,
844
+ type: parentIdentifierType,
845
+ required: true,
846
+ },
847
+ {
848
+ name: field.destinationDb.selectIdentifier,
849
+ type: selectIdentifierType,
850
+ required: true,
851
+ },
852
+ ...(isLocalized ? [{ name: 'locale', type: 'text', required: true }] : []),
747
853
  ];
748
854
  const destDbSchema = generateDrizzleSchema({
749
855
  name: field.destinationDb.table,
@@ -768,15 +874,23 @@ const main = async (s) => {
768
874
  required: true,
769
875
  order: 0,
770
876
  });
877
+ const galleryRefField = s.db.identifier.type === 'number'
878
+ ? numberField({
879
+ name: gallery.db.referenceIdentifierField || 'reference_id',
880
+ label: 'Reference Id',
881
+ required: true,
882
+ order: 0,
883
+ })
884
+ : textField({
885
+ name: gallery.db.referenceIdentifierField || 'reference_id',
886
+ label: 'Reference Id',
887
+ required: true,
888
+ order: 0,
889
+ });
771
890
  desiredTables.push({
772
891
  name: gallery.db.tableName,
773
892
  fields: [
774
- textField({
775
- name: gallery.db.referenceIdentifierField || 'reference_id',
776
- label: 'Reference Id',
777
- required: true,
778
- order: 0,
779
- }).build(),
893
+ galleryRefField.build(),
780
894
  photoField.build(),
781
895
  textAreaField({
782
896
  name: gallery.db.metaField || 'meta',
@@ -799,7 +913,7 @@ const main = async (s) => {
799
913
  fields: [
800
914
  {
801
915
  name: gallery.db.referenceIdentifierField || 'reference_id',
802
- type: 'text',
916
+ type: s.db.identifier.type,
803
917
  required: true,
804
918
  },
805
919
  {
@@ -880,6 +994,15 @@ const main = async (s) => {
880
994
  localesSchema.drizzleImports.forEach((type) => drizzleImports.add(type));
881
995
  }
882
996
  }
997
+ /**
998
+ * Reject the run before any DB write if any section used a reserved name.
999
+ */
1000
+ if (reservedNameErrors.length > 0) {
1001
+ s.stop(`update-sections aborted: ${reservedNameErrors.length} reserved-name conflict(s) detected.\n` +
1002
+ reservedNameErrors.map((e) => ` - ${e}`).join('\n') +
1003
+ `\n\nThe suffix '${RESERVED_TABLE_SUFFIX}' and the column name '${RESERVED_COLUMN_NAME}' are reserved for the localization system. Please rename and re-run.`);
1004
+ throw new Error('update-sections: reserved name conflicts detected');
1005
+ }
883
1006
  /**
884
1007
  * Write schema file if schema generation is enabled
885
1008
  */
@@ -929,6 +1052,185 @@ const main = async (s) => {
929
1052
  console.log(chalk.gray(tablesToRemove.map((table) => table.name).join(', ')));
930
1053
  console.log(`\n`);
931
1054
  intro(chalk.inverse(' update-sections '));
1055
+ /**
1056
+ * Detect localization transition by inspecting 'editor_photos' — the presence of its 'locale'
1057
+ * column reflects whether localization was enabled on the previous run. Pair this with the
1058
+ * stored `localization.defaultLocale` from `__nextjs_cms_config` so destructive cleanup uses
1059
+ * the correct locale (the one actually used to tag existing rows), not a guess from the
1060
+ * now-missing config.
1061
+ */
1062
+ const localizationCurrentlyEnabled = cmsConfig.localization?.enabled === true;
1063
+ const configDefaultLocale = cmsConfig.localization?.defaultLocale;
1064
+ const editorPhotosColumns = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1065
+ const editorPhotosExists = editorPhotosColumns.length > 0;
1066
+ const editorPhotosHasLocale = editorPhotosColumns.includes('locale');
1067
+ const storedDefaultLocale = await readStoredDefaultLocale();
1068
+ // Detect residual `locale` columns on registered tables. Used to resume an interrupted
1069
+ // disable flow: if editor_photos is already cleaned but a junction still carries the
1070
+ // column, the upfront disable path must fire again so cleanup uses the stored locale.
1071
+ let hasResidualLocaleColumn = false;
1072
+ if (!localizationCurrentlyEnabled && !editorPhotosHasLocale && storedDefaultLocale) {
1073
+ for (const existing of existingTables) {
1074
+ const cols = await MysqlTableChecker.getColumns(existing.name).catch(() => []);
1075
+ if (cols.includes('locale')) {
1076
+ hasResidualLocaleColumn = true;
1077
+ break;
1078
+ }
1079
+ }
1080
+ }
1081
+ let localizationTransition = null;
1082
+ /**
1083
+ * Enforcement: on steady-state enabled runs, disallow silent changes to `defaultLocale`.
1084
+ * If a mismatch is detected, abort before any destructive operation.
1085
+ */
1086
+ if (localizationCurrentlyEnabled && editorPhotosHasLocale) {
1087
+ if (storedDefaultLocale && configDefaultLocale && storedDefaultLocale !== configDefaultLocale) {
1088
+ s.stop(`Aborting. Changing 'localization.defaultLocale' is not supported (stored='${storedDefaultLocale}', config='${configDefaultLocale}'). Revert cms.config.ts and re-run update-sections.`);
1089
+ return;
1090
+ }
1091
+ if (!storedDefaultLocale && configDefaultLocale) {
1092
+ // Seed the stored value from current config — one-time backfill for installs that
1093
+ // predate `__nextjs_cms_config`.
1094
+ await writeStoredDefaultLocale(configDefaultLocale);
1095
+ }
1096
+ }
1097
+ // Resolve the locale used for cleanup SQL. For newly-disabled, stored takes precedence;
1098
+ // for newly-enabled, config is the source of truth.
1099
+ const defaultLocaleCode = localizationCurrentlyEnabled
1100
+ ? (configDefaultLocale ?? storedDefaultLocale ?? 'en')
1101
+ : (storedDefaultLocale ?? configDefaultLocale ?? 'en');
1102
+ if (editorPhotosExists && localizationCurrentlyEnabled && !editorPhotosHasLocale) {
1103
+ /**
1104
+ * Localization is newly enabled: editor_photos lacks the locale column,
1105
+ * but the config now has localization.enabled = true. We must backfill
1106
+ * editor_photos and any existing junction tables that carry localized data
1107
+ * with the configured defaultLocale.
1108
+ */
1109
+ const affectedJunctions = [];
1110
+ for (const t of desiredTables) {
1111
+ if (t.sectionType !== 'destinationDb')
1112
+ continue;
1113
+ if (!t.fields.some((f) => f.name === 'locale'))
1114
+ continue;
1115
+ const cols = await MysqlTableChecker.getColumns(t.name).catch(() => []);
1116
+ if (cols.length > 0 && !cols.includes('locale')) {
1117
+ affectedJunctions.push(t.name);
1118
+ }
1119
+ }
1120
+ const affectedList = ['editor_photos', ...affectedJunctions];
1121
+ s.stop();
1122
+ const confirm = await select({
1123
+ message: `It looks like you enabled localization. Your existing data in these tables will be marked as defaultLocale '${defaultLocaleCode}':\n` +
1124
+ affectedList.map((n) => ` - ${n}`).join('\n') +
1125
+ `\n\n${chalk.redBright('WARNING:')} Existing data in these tables MUST already be in '${defaultLocaleCode}'. ` +
1126
+ `Changing defaultLocale after this point is not supported. If your data is not in '${defaultLocaleCode}', ` +
1127
+ `stop now and fix your cms.config.ts before re-running update-sections.\n\nProceed?`,
1128
+ options: [
1129
+ { value: 'yes', label: `Yes, that's correct` },
1130
+ { value: 'no', label: 'No, stop — let me reconfigure localization in cms.config.ts' },
1131
+ ],
1132
+ initialValue: 'no',
1133
+ });
1134
+ if (confirm !== 'yes') {
1135
+ s.stop('Aborted. Reconfigure cms.config.ts and re-run update-sections.');
1136
+ return;
1137
+ }
1138
+ localizationTransition = 'enable';
1139
+ try {
1140
+ await db.execute(sql `ALTER TABLE \`editor_photos\` ADD COLUMN \`locale\` VARCHAR(10) DEFAULT NULL`);
1141
+ await db.execute(sql `UPDATE \`editor_photos\` SET \`locale\` = ${defaultLocaleCode} WHERE \`locale\` IS NULL`);
1142
+ await db.execute(sql `ALTER TABLE \`editor_photos\` MODIFY COLUMN \`locale\` VARCHAR(10) NOT NULL`);
1143
+ // Persist the defaultLocale so future disable cleanup uses the right filter even
1144
+ // after `localization` is stripped from cms.config.ts.
1145
+ await writeStoredDefaultLocale(defaultLocaleCode);
1146
+ log.info(chalk.gray(` - Added 'locale' column to 'editor_photos' and backfilled existing rows with '${defaultLocaleCode}'.`));
1147
+ }
1148
+ catch (error) {
1149
+ console.error(chalk.red(` - Error migrating 'editor_photos' for localization enable:`, error));
1150
+ }
1151
+ s.start();
1152
+ }
1153
+ else if (editorPhotosExists &&
1154
+ !localizationCurrentlyEnabled &&
1155
+ (editorPhotosHasLocale || hasResidualLocaleColumn)) {
1156
+ /**
1157
+ * Localization is newly disabled (or a previous disable run was interrupted):
1158
+ * editor_photos or some junction/_locales table still carries the locale column,
1159
+ * but the config no longer has localization.enabled = true. We purge non-default rows
1160
+ * (and their files), drop the locale column from editor_photos and junction tables,
1161
+ * and drop _locales tables. The deletion filter uses the STORED defaultLocale (not
1162
+ * config, which may be missing), so rows tagged with the original base locale survive
1163
+ * regardless of current config.
1164
+ */
1165
+ if (!storedDefaultLocale) {
1166
+ s.stop(`Aborting. Cannot disable localization safely: no stored defaultLocale found in '__nextjs_cms_config'. This indicates a corrupted state.`);
1167
+ return;
1168
+ }
1169
+ const affected = [
1170
+ { name: 'editor_photos', type: 'editor_photos' },
1171
+ ];
1172
+ for (const existing of existingTables) {
1173
+ if (existing.name === 'editor_photos')
1174
+ continue;
1175
+ const cols = await MysqlTableChecker.getColumns(existing.name).catch(() => []);
1176
+ if (!cols.includes('locale'))
1177
+ continue;
1178
+ if (existing.name.endsWith('_locales')) {
1179
+ affected.push({ name: existing.name, type: 'locales' });
1180
+ }
1181
+ else {
1182
+ affected.push({ name: existing.name, type: 'junction' });
1183
+ }
1184
+ }
1185
+ s.stop();
1186
+ const confirm = await select({
1187
+ message: `It looks like you disabled localization. The 'locale' column in the tables below is no longer needed and will be dropped.\n\n` +
1188
+ `Base locale (from '__nextjs_cms_config'): '${storedDefaultLocale}'. Rows tagged with this locale will be kept; all others will be deleted.\n\n` +
1189
+ `This will:\n` +
1190
+ ` 1. Delete all non-base-locale rows (locale != '${storedDefaultLocale}') from editor_photos and junction tables (their files on disk will also be removed)\n` +
1191
+ ` 2. Drop the 'locale' column from editor_photos and junction tables\n` +
1192
+ ` 3. Drop '_locales' tables entirely\n\n` +
1193
+ `Affected tables:\n` +
1194
+ affected.map((t) => ` - ${t.name} (${t.type})`).join('\n') +
1195
+ `\n\nProceed?`,
1196
+ options: [
1197
+ { value: 'yes', label: `Yes, that's correct` },
1198
+ { value: 'no', label: 'No, stop — let me reconfigure localization in cms.config.ts' },
1199
+ ],
1200
+ initialValue: 'no',
1201
+ });
1202
+ if (confirm !== 'yes') {
1203
+ s.stop('Aborted. Reconfigure cms.config.ts and re-run update-sections.');
1204
+ return;
1205
+ }
1206
+ localizationTransition = 'disable';
1207
+ if (editorPhotosHasLocale) {
1208
+ try {
1209
+ const uploadsFolder = cmsConfig.media.upload.path;
1210
+ const [nondefaultRows] = await db.execute(sql `SELECT \`photo\`, \`section\` FROM \`editor_photos\` WHERE \`locale\` != ${storedDefaultLocale}`);
1211
+ const rows = nondefaultRows ?? [];
1212
+ for (const row of rows) {
1213
+ try {
1214
+ await fs.promises.unlink(path.join(uploadsFolder, '.photos', row.section, row.photo));
1215
+ }
1216
+ catch {
1217
+ // ignore missing files
1218
+ }
1219
+ }
1220
+ await db.execute(sql `DELETE FROM \`editor_photos\` WHERE \`locale\` != ${storedDefaultLocale}`);
1221
+ await db.execute(sql `ALTER TABLE \`editor_photos\` DROP COLUMN \`locale\``);
1222
+ log.info(chalk.gray(` - Removed ${rows.length} non-base-locale row(s) and dropped 'locale' column from 'editor_photos'.`));
1223
+ }
1224
+ catch (error) {
1225
+ console.error(chalk.red(` - Error migrating 'editor_photos' for localization disable:`, error));
1226
+ }
1227
+ }
1228
+ else {
1229
+ log.info(chalk.gray(` - 'editor_photos' already delocalized; resuming junction/_locales cleanup.`));
1230
+ }
1231
+ s.start();
1232
+ }
1233
+ const ctx = { localizationTransition, defaultLocaleCode };
932
1234
  /**
933
1235
  * Check if there are tables to update
934
1236
  */
@@ -937,7 +1239,7 @@ const main = async (s) => {
937
1239
  * Loop through the tables to update
938
1240
  */
939
1241
  for (const table of tablesToUpdate) {
940
- await updateTable(table, s);
1242
+ await updateTable(table, s, ctx);
941
1243
  }
942
1244
  }
943
1245
  /**
@@ -962,7 +1264,7 @@ const main = async (s) => {
962
1264
  });
963
1265
  if (overwriteExistingTable === 'yes') {
964
1266
  await ensureTableRegistryEntry(table.name, table.sectionName);
965
- await updateTable(table, s);
1267
+ await updateTable(table, s, ctx);
966
1268
  }
967
1269
  else {
968
1270
  console.log(chalk.yellow(`Skipping existing table '${table.name}'.`));
@@ -1001,7 +1303,7 @@ const main = async (s) => {
1001
1303
  if (tableToRename && typeof tableToRename === 'string') {
1002
1304
  console.log(`Renaming table '${tableToRename}' to '${table.name}'`);
1003
1305
  await renameTable(tableToRename, table.name);
1004
- await updateTable(table, s);
1306
+ await updateTable(table, s, ctx);
1005
1307
  /**
1006
1308
  * Remove the table from the tablesToRemove array
1007
1309
  */
@@ -1039,17 +1341,26 @@ const main = async (s) => {
1039
1341
  * Loop through the tables to remove
1040
1342
  */
1041
1343
  for (const table of tablesToRemove) {
1042
- s.stop();
1043
- const opType = await select({
1044
- message: `You are about to remove table '${table.name}'. Proceed?`,
1045
- options: [
1046
- { value: 'yes', label: 'Yes, drop it' },
1047
- { value: 'no', label: 'No, keep it' },
1048
- { value: 'later', label: 'Ask me later' },
1049
- ],
1050
- initialValue: 'later',
1051
- });
1052
- s.start();
1344
+ // When localization was globally disabled, auto-confirm dropping _locales tables —
1345
+ // the upfront prompt already covered them.
1346
+ const autoDropLocales = localizationTransition === 'disable' && table.name.endsWith('_locales') ? 'yes' : null;
1347
+ let opType;
1348
+ if (autoDropLocales) {
1349
+ opType = autoDropLocales;
1350
+ }
1351
+ else {
1352
+ s.stop();
1353
+ opType = await select({
1354
+ message: `You are about to remove table '${table.name}'. Proceed?`,
1355
+ options: [
1356
+ { value: 'yes', label: 'Yes, drop it' },
1357
+ { value: 'no', label: 'No, keep it' },
1358
+ { value: 'later', label: 'Ask me later' },
1359
+ ],
1360
+ initialValue: 'later',
1361
+ });
1362
+ s.start();
1363
+ }
1053
1364
  switch (opType) {
1054
1365
  case 'yes':
1055
1366
  case 'no':
@@ -1076,6 +1387,50 @@ const main = async (s) => {
1076
1387
  }
1077
1388
  }
1078
1389
  }
1390
+ /**
1391
+ * Only now — after every junction update and every `_locales` drop has been attempted —
1392
+ * is it safe to clear the stored defaultLocale. The individual cleanup steps swallow
1393
+ * errors to keep the run going, so "transition === 'disable'" is not by itself proof the
1394
+ * DB is clean. Post-check the actual state: editor_photos must have no `locale` column,
1395
+ * and no registered table (junction or `_locales`) may still carry one. If any trace
1396
+ * remains, keep the stored value so the next run can resume with the correct filter.
1397
+ */
1398
+ if (localizationTransition === 'disable') {
1399
+ const postEditorPhotosCols = await MysqlTableChecker.getColumns('editor_photos').catch(() => []);
1400
+ let fullyClean = !postEditorPhotosCols.includes('locale');
1401
+ if (!fullyClean) {
1402
+ log.warn(chalk.yellow(` - 'editor_photos' still has a 'locale' column after cleanup. Keeping stored defaultLocale so the next run can resume.`));
1403
+ }
1404
+ const postRegistered = fullyClean
1405
+ ? await db.select({ name: NextJsCmsTablesTable.tableName }).from(NextJsCmsTablesTable)
1406
+ : [];
1407
+ if (fullyClean) {
1408
+ for (const t of postRegistered) {
1409
+ const cols = await MysqlTableChecker.getColumns(t.name).catch(() => []);
1410
+ if (cols.includes('locale')) {
1411
+ fullyClean = false;
1412
+ log.warn(chalk.yellow(` - Table '${t.name}' still has a 'locale' column after cleanup. Keeping stored defaultLocale so the next run can resume.`));
1413
+ break;
1414
+ }
1415
+ }
1416
+ }
1417
+ if (fullyClean) {
1418
+ // Check registered tables whose name ends with '_locales' — scoped to
1419
+ // __nextjs_cms_tables so unrelated tables sharing the DB are never flagged.
1420
+ const registeredLocalesTables = postRegistered.filter((t) => t.name.endsWith('_locales'));
1421
+ for (const t of registeredLocalesTables) {
1422
+ const stillExists = (await MysqlTableChecker.getExistingTableStructure(t.name)) !== null;
1423
+ if (stillExists) {
1424
+ fullyClean = false;
1425
+ log.warn(chalk.yellow(` - Registered '_locales' table '${t.name}' still exists after cleanup. Keeping stored defaultLocale so the next run can resume.`));
1426
+ break;
1427
+ }
1428
+ }
1429
+ }
1430
+ if (fullyClean) {
1431
+ await deleteStoredDefaultLocale();
1432
+ }
1433
+ }
1079
1434
  };
1080
1435
  export async function updateSections(useDevEnv = false) {
1081
1436
  const s = spinner();
@@ -1 +1 @@
1
- {"version":3,"file":"schema-generator.d.ts","sourceRoot":"","sources":["../../../src/cli/utils/schema-generator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAoB7D,MAAM,WAAW,YAAY;IACzB,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACzC,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;CAC7C;AAED,MAAM,WAAW,YAAY;IACzB,MAAM,EAAE,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;IAChD,OAAO,EAAE,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;CACpD;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA8BtE;AAED,wBAAgB,qBAAqB,CACjC,KAAK,EAAE;IACH,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,UAAU,CAAC,EAAE,WAAW,CAAA;IACxB,mBAAmB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACxC,KAAK,CAAC,EAAE;QAAE,OAAO,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACxD,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACzD,QAAQ,CAAC,EAAE;QAAE,OAAO,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC9D,EACD,YAAY,EAAE,YAAY,GAC3B;IACC,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,MAAM,EAAE,CAAA;CAC3B,CA4LA;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,0BAA0B,CACtC,MAAM,EAAE;IACJ,eAAe,EAAE,MAAM,CAAA;IACvB,gBAAgB,EAAE,WAAW,CAAA;IAC7B,eAAe,EAAE,WAAW,EAAE,CAAA;IAC9B,YAAY,EAAE,YAAY,CAAA;CAC7B,GACF;IACC,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,MAAM,EAAE,CAAA;CAC3B,CAoCA"}
1
+ {"version":3,"file":"schema-generator.d.ts","sourceRoot":"","sources":["../../../src/cli/utils/schema-generator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAoB7D,MAAM,WAAW,YAAY;IACzB,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IACzC,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;CAC7C;AAED,MAAM,WAAW,YAAY;IACzB,MAAM,EAAE,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;IAChD,OAAO,EAAE,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;CACpD;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA8BtE;AAED,wBAAgB,qBAAqB,CACjC,KAAK,EAAE;IACH,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,UAAU,CAAC,EAAE,WAAW,CAAA;IACxB,mBAAmB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACxC,KAAK,CAAC,EAAE;QAAE,OAAO,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACxD,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACzD,QAAQ,CAAC,EAAE;QAAE,OAAO,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC9D,EACD,YAAY,EAAE,YAAY,GAC3B;IACC,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,MAAM,EAAE,CAAA;CAC3B,CA4MA;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE;IAC/C,eAAe,EAAE,MAAM,CAAA;IACvB,gBAAgB,EAAE,WAAW,CAAA;IAC7B,eAAe,EAAE,WAAW,EAAE,CAAA;IAC9B,YAAY,EAAE,YAAY,CAAA;CAC7B,GAAG;IACA,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,MAAM,EAAE,CAAA;CAC3B,CA+CA"}