@hed-hog/cli 0.0.94 → 0.0.96

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 (28) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/app.module.js +2 -0
  3. package/dist/src/app.module.js.map +1 -1
  4. package/dist/src/commands/new.command.d.ts +6 -0
  5. package/dist/src/commands/new.command.js +198 -93
  6. package/dist/src/commands/new.command.js.map +1 -1
  7. package/dist/src/commands/update.command.d.ts +14 -0
  8. package/dist/src/commands/update.command.js +58 -0
  9. package/dist/src/commands/update.command.js.map +1 -0
  10. package/dist/src/modules/database/database.service.d.ts +8 -0
  11. package/dist/src/modules/database/database.service.js +120 -0
  12. package/dist/src/modules/database/database.service.js.map +1 -1
  13. package/dist/src/modules/hedhog/hedhog.module.js +5 -2
  14. package/dist/src/modules/hedhog/hedhog.module.js.map +1 -1
  15. package/dist/src/modules/hedhog/hedhog.service.d.ts +12 -0
  16. package/dist/src/modules/hedhog/hedhog.service.js +178 -9
  17. package/dist/src/modules/hedhog/hedhog.service.js.map +1 -1
  18. package/dist/src/modules/hedhog/services/diff.service.d.ts +107 -0
  19. package/dist/src/modules/hedhog/services/diff.service.js +573 -0
  20. package/dist/src/modules/hedhog/services/diff.service.js.map +1 -0
  21. package/dist/src/modules/hedhog/services/migration.service.d.ts +36 -1
  22. package/dist/src/modules/hedhog/services/migration.service.js +619 -11
  23. package/dist/src/modules/hedhog/services/migration.service.js.map +1 -1
  24. package/dist/src/modules/hedhog/services/table.service.d.ts +7 -0
  25. package/dist/src/modules/hedhog/services/table.service.js +93 -13
  26. package/dist/src/modules/hedhog/services/table.service.js.map +1 -1
  27. package/dist/tsconfig.build.tsbuildinfo +1 -1
  28. package/package.json +1 -1
@@ -17,6 +17,7 @@ const path_1 = require("path");
17
17
  const sql_formatter_1 = require("sql-formatter");
18
18
  const database_service_1 = require("../../database/database.service");
19
19
  const developer_service_1 = require("../../developer/developer.service");
20
+ const diff_service_1 = require("./diff.service");
20
21
  const file_system_service_1 = require("./file-system.service");
21
22
  const table_service_1 = require("./table.service");
22
23
  let MigrationService = class MigrationService {
@@ -24,14 +25,16 @@ let MigrationService = class MigrationService {
24
25
  developer;
25
26
  fileSystem;
26
27
  tableService;
28
+ diffService;
27
29
  indexWithClauseLocale = 0;
28
30
  touchUpdatedAtCreated = false;
29
31
  verbose = false;
30
- constructor(database, developer, fileSystem, tableService) {
32
+ constructor(database, developer, fileSystem, tableService, diffService) {
31
33
  this.database = database;
32
34
  this.developer = developer;
33
35
  this.fileSystem = fileSystem;
34
36
  this.tableService = tableService;
37
+ this.diffService = diffService;
35
38
  }
36
39
  log(message) {
37
40
  if (this.verbose) {
@@ -171,6 +174,13 @@ let MigrationService = class MigrationService {
171
174
  getEnumOrigin(table, column) {
172
175
  return `${table.name}.${column.name}`;
173
176
  }
177
+ getOwnedTablesForMigration(tables, dependencyTables) {
178
+ if (!dependencyTables.length) {
179
+ return tables;
180
+ }
181
+ const dependencyTableNames = new Set(dependencyTables.map((table) => table.name));
182
+ return tables.filter((table) => !dependencyTableNames.has(table.name));
183
+ }
174
184
  getEnumName(tableName, columnName) {
175
185
  const hash = (0, crypto_1.createHash)('sha1')
176
186
  .update(`${tableName}:${columnName}`)
@@ -304,6 +314,64 @@ let MigrationService = class MigrationService {
304
314
  }
305
315
  return transformedContent;
306
316
  }
317
+ /**
318
+ * Makes SQL statements idempotent using standard DDL (no DO blocks, which
319
+ * the sql-formatter breaks apart).
320
+ *
321
+ * Handles three cases for after-query SQL files:
322
+ *
323
+ * 1. ALTER COLUMN ... TYPE blocked by views:
324
+ * If the file contains ALTER COLUMN ... TYPE AND defines views with
325
+ * CREATE [OR REPLACE] VIEW, prepend DROP VIEW IF EXISTS for each view so
326
+ * the ALTER succeeds. The views are recreated by the CREATE OR REPLACE
327
+ * VIEW statements already in the same file.
328
+ *
329
+ * 2. ALTER TABLE … ADD CONSTRAINT <name> (bare, not inside a DO block):
330
+ * → prepend DROP CONSTRAINT IF EXISTS <name>
331
+ *
332
+ * 3. CREATE [UNIQUE] INDEX <name> without IF NOT EXISTS:
333
+ * → insert IF NOT EXISTS
334
+ */
335
+ wrapIdempotentSql(sql) {
336
+ const stripped = sql.replace(/--[^\n]*/g, '').trim();
337
+ let result = sql;
338
+ // ── Case 1: ALTER COLUMN ... TYPE / view dependency ──────────────────────
339
+ // If the file alters a column's type AND defines views, those views likely
340
+ // depend on the column. Drop them first; CREATE OR REPLACE VIEW later in
341
+ // the file will recreate them.
342
+ if (/\bALTER\s+COLUMN\b.+?\bTYPE\s+\w/is.test(stripped)) {
343
+ const viewNames = [];
344
+ const viewRegex = /\bCREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+"?(\w+)"?/gi;
345
+ let m;
346
+ while ((m = viewRegex.exec(stripped)) !== null) {
347
+ viewNames.push(m[1]);
348
+ }
349
+ if (viewNames.length > 0) {
350
+ const drops = viewNames
351
+ .map((v) => `DROP VIEW IF EXISTS "${v}";`)
352
+ .join('\n');
353
+ result = `${drops}\n\n${result}`;
354
+ }
355
+ }
356
+ // ── Case 2: Bare ADD CONSTRAINT (not already inside a DO $$ block) ───────
357
+ // Files that have DO $$ guards around constraints are already idempotent.
358
+ const hasDOBlocks = /\bDO\s*\$\$/.test(stripped);
359
+ if (!hasDOBlocks) {
360
+ const constraintMatch = /ALTER\s+TABLE\s+"?(\w+)"?\s+ADD\s+CONSTRAINT\s+"?(\w+)"?/is.exec(stripped);
361
+ if (constraintMatch) {
362
+ const table = constraintMatch[1];
363
+ const constraint = constraintMatch[2];
364
+ const drop = `ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS "${constraint}";`;
365
+ result = `${drop}\n${result.trim()}`;
366
+ }
367
+ }
368
+ // ── Case 3: CREATE [UNIQUE] INDEX without IF NOT EXISTS ───────────────────
369
+ if (/\bCREATE\b.*\bINDEX\b/is.test(stripped) &&
370
+ !/\bIF\s+NOT\s+EXISTS\b/is.test(stripped)) {
371
+ result = result.replace(/\bCREATE(\s+UNIQUE)?\s+INDEX\b/gi, (_m, unique) => `CREATE${unique ?? ''} INDEX IF NOT EXISTS`);
372
+ }
373
+ return result;
374
+ }
307
375
  normalizeCustomSqlQuery(file, sqlContent, enumRegistry, columnRegistry) {
308
376
  const normalizedDeclarations = this.normalizeCustomSqlEnumDeclarations(file, sqlContent, enumRegistry, columnRegistry);
309
377
  return this.normalizeCustomSqlAgainstSchema(file, normalizedDeclarations, columnRegistry);
@@ -567,12 +635,12 @@ let MigrationService = class MigrationService {
567
635
  this.log(`Creating unique constraints for table: ${table.name}`);
568
636
  const uniqueColumns = table.columns.filter((c) => c.isUnique);
569
637
  if (uniqueColumns.length) {
570
- migrationContent.push(`CREATE UNIQUE INDEX "${table.name}_${uniqueColumns.map((c) => c.name).join('_')}_unique" ON "${table.name}" (${uniqueColumns.map((c) => `"${c.name}"`).join(', ')});`, ``);
638
+ migrationContent.push(`CREATE UNIQUE INDEX IF NOT EXISTS "${table.name}_${uniqueColumns.map((c) => c.name).join('_')}_unique" ON "${table.name}" (${uniqueColumns.map((c) => `"${c.name}"`).join(', ')});`, ``);
571
639
  }
572
640
  for (const column of table.columns) {
573
641
  if (column.type === 'slug') {
574
642
  this.log(`Adding unique constraint for column: ${column.name}`);
575
- migrationContent.push(`CREATE UNIQUE INDEX "${table.name}_${column.name}_unique" ON "${table.name}" ("${column.name}");`, ``);
643
+ migrationContent.push(`CREATE UNIQUE INDEX IF NOT EXISTS "${table.name}_${column.name}_unique" ON "${table.name}" ("${column.name}");`, ``);
576
644
  }
577
645
  }
578
646
  }
@@ -837,9 +905,9 @@ let MigrationService = class MigrationService {
837
905
  }
838
906
  return [];
839
907
  }
840
- async afterQueries(name, cwd, migrationContent, enumRegistry, columnRegistry) {
908
+ async afterQueries(name, cwd, migrationContent, enumRegistry, columnRegistry, customQueriesPath) {
841
909
  this.log(`Running after queries for ${name}...`);
842
- const queriesPath = (0, path_1.resolve)(cwd, `./libraries/${name}/hedhog/query`);
910
+ const queriesPath = customQueriesPath ?? (0, path_1.resolve)(cwd, `./libraries/${name}/hedhog/query`);
843
911
  try {
844
912
  if (await this.fileSystem.exists(queriesPath)) {
845
913
  const allFiles = await this.fileSystem.listFiles(queriesPath, () => true);
@@ -847,7 +915,7 @@ let MigrationService = class MigrationService {
847
915
  if (file.endsWith('.sql')) {
848
916
  const sqlContent = await this.fileSystem.readFileContent((0, path_1.join)(queriesPath, file));
849
917
  const normalizedSql = this.normalizeCustomSqlQuery(file, sqlContent, enumRegistry, columnRegistry);
850
- migrationContent.push(`-- AfterQuery: ${file}`, normalizedSql, '');
918
+ migrationContent.push(`-- AfterQuery: ${file}`, this.wrapIdempotentSql(normalizedSql), '');
851
919
  }
852
920
  else {
853
921
  console.warn(chalk.yellow(`Arquivo ignorado no diretório de queries: ${file}`));
@@ -936,6 +1004,7 @@ let MigrationService = class MigrationService {
936
1004
  const allTables = [];
937
1005
  const tables = await this.tableService.loadTablesFromYaml(name, cwd, verbose);
938
1006
  const tablesDep = await this.tableService.getTablesFromDependencies(name, cwd);
1007
+ const ownedTables = this.getOwnedTablesForMigration(tables, tablesDep);
939
1008
  allTables.push(...tablesDep.filter((t) => !tables.find((tbl) => tbl.name === t.name)));
940
1009
  const uniqueTablesMap = new Map();
941
1010
  [...allTables, ...tables].forEach((table) => {
@@ -981,17 +1050,23 @@ let MigrationService = class MigrationService {
981
1050
  const enumRegistry = this.buildEnumRegistry(allTables);
982
1051
  const columnRegistry = this.buildColumnTypeRegistry(allTables);
983
1052
  this.log(`Database type detected: ${dbType}`);
1053
+ if (ownedTables.length !== tables.length) {
1054
+ const skippedTables = tables
1055
+ .filter((table) => !ownedTables.some((owned) => owned.name === table.name))
1056
+ .map((table) => table.name);
1057
+ this.log(`Skipping schema creation for dependency-owned tables in ${name}: ${skippedTables.join(', ')}`);
1058
+ }
984
1059
  this.log(`Processing enums for ${name}...`);
985
- this.processEnums(tables, dbType, migrationContent, enumRegistry);
1060
+ this.processEnums(ownedTables, dbType, migrationContent, enumRegistry);
986
1061
  this.log(`Processed enums for ${name}!`);
987
1062
  this.log(`Creating tables for ${name}...`);
988
- await this.createTableMigrations(tables, dbType, migrationContent, createdTables, cwd);
1063
+ await this.createTableMigrations(ownedTables, dbType, migrationContent, createdTables, cwd);
989
1064
  this.log(`Created tables for ${name}!`);
990
1065
  this.log(`Adding unique constraints for ${name}...`);
991
- this.addUniqueConstraints(tables, dbType, migrationContent);
1066
+ this.addUniqueConstraints(ownedTables, dbType, migrationContent);
992
1067
  this.log(`Added unique constraints for ${name}!`);
993
1068
  this.log(`Adding foreign keys for ${name}...`);
994
- this.addForeignKeys(tables, dbType, migrationContent);
1069
+ this.addForeignKeys(ownedTables, dbType, migrationContent);
995
1070
  this.log(`Added foreign keys for ${name}!`);
996
1071
  this.log(`Inserting initial data for ${name}...`);
997
1072
  await this.insertInitialData(!allTables.length && tables.length ? tables : allTables, name, cwd, migrationContent);
@@ -1038,6 +1113,538 @@ let MigrationService = class MigrationService {
1038
1113
  await this.fileSystem.writeJsonFile(hedhogFilePath, hedhogFile);
1039
1114
  this.log(`Migration file ${migrationFileName} created successfully.`);
1040
1115
  }
1116
+ // ── Update migration ─────────────────────────────────────────────────────────
1117
+ /**
1118
+ * Generate a differential migration (ALTER TABLE + upserts) for an existing
1119
+ * library being updated. Reads old schema/data from `libraries/<name>/` and
1120
+ * new schema/data from `libraries/<name>/.hedhog-update/`.
1121
+ */
1122
+ async createUpdateMigrations(name, cwd = process.cwd(), verbose = false) {
1123
+ this.verbose = verbose;
1124
+ const oldLibraryPath = (0, path_1.resolve)(cwd, `./libraries/${name}`);
1125
+ const newLibraryPath = (0, path_1.join)(oldLibraryPath, '.hedhog-update');
1126
+ this.log(`Loading old table definitions from ${oldLibraryPath}...`);
1127
+ const oldTables = await this.tableService.loadLibraryOwnedTables(oldLibraryPath, verbose);
1128
+ this.log(`Loading new table definitions from ${newLibraryPath}...`);
1129
+ const newTables = await this.tableService.loadLibraryOwnedTables(newLibraryPath, verbose);
1130
+ // Build full allTables for FK/locale resolution during SQL generation
1131
+ // Use new version's dependencies since it may have updated deps
1132
+ const tablesDep = await this.tableService.getTablesFromDependencies(name, cwd);
1133
+ const allTablesMap = new Map();
1134
+ for (const t of [...tablesDep, ...newTables]) {
1135
+ if (!allTablesMap.has(t.name))
1136
+ allTablesMap.set(t.name, t);
1137
+ }
1138
+ const allTables = [...allTablesMap.values()];
1139
+ // Diffs
1140
+ const schemaDiff = this.diffService.diffSchema(oldTables, newTables);
1141
+ const dataDiff = await this.diffService.diffData(oldLibraryPath, newLibraryPath, allTablesMap);
1142
+ // ── Interactive prompts ────────────────────────────────────────────────────
1143
+ // Resolve renames for changed tables
1144
+ for (const tableChanges of schemaDiff.changedTables) {
1145
+ const candidates = this.diffService.detectRenameCandidates(tableChanges.removedColumns, tableChanges.addedColumns);
1146
+ if (candidates.length > 0) {
1147
+ const decisions = await this.diffService.promptRenames(tableChanges.tableName, candidates);
1148
+ tableChanges.renamedColumns = decisions.filter((d) => d.isRename);
1149
+ // Remove confirmed renames from added/removed lists
1150
+ const renamedOldNames = new Set(tableChanges.renamedColumns.map((r) => r.oldName));
1151
+ const renamedNewNames = new Set(tableChanges.renamedColumns.map((r) => r.newName));
1152
+ tableChanges.removedColumns = tableChanges.removedColumns.filter((c) => !renamedOldNames.has(c.name));
1153
+ tableChanges.addedColumns = tableChanges.addedColumns.filter((c) => !renamedNewNames.has(c.name));
1154
+ }
1155
+ }
1156
+ // Prompt for dropped columns
1157
+ const approvedDropsMap = new Map();
1158
+ for (const tableChanges of schemaDiff.changedTables) {
1159
+ if (tableChanges.removedColumns.length > 0) {
1160
+ const approved = await this.diffService.promptDropColumns(tableChanges.tableName, tableChanges.removedColumns);
1161
+ approvedDropsMap.set(tableChanges.tableName, approved);
1162
+ }
1163
+ }
1164
+ // Prompt for removed data rows
1165
+ const approvedDeletesMap = new Map();
1166
+ for (const change of dataDiff.changes) {
1167
+ if (change.removedRows.length > 0) {
1168
+ const approved = await this.diffService.promptDeleteRows(change.tableName, change.removedRows, change.keyColumn);
1169
+ approvedDeletesMap.set(change.tableName, approved);
1170
+ }
1171
+ }
1172
+ // Prompt for entirely removed data files
1173
+ const approvedFileDeletesMap = new Map();
1174
+ for (const fc of dataDiff.fileChanges) {
1175
+ if (fc.isRemoved) {
1176
+ const decision = await this.diffService.promptDeleteEntireDataFile(fc.tableName, fc.allRows);
1177
+ approvedFileDeletesMap.set(fc.tableName, decision);
1178
+ }
1179
+ }
1180
+ // Prompt for removed relations
1181
+ const approvedRelationDeletesMap = new Map();
1182
+ for (const change of dataDiff.changes) {
1183
+ for (const relChange of change.relationChanges) {
1184
+ if (relChange.removedRelations.length > 0) {
1185
+ const pairs = relChange.removedRelations.map((rel) => ({
1186
+ parentKeyValue: relChange.parentKeyValue,
1187
+ relation: rel,
1188
+ }));
1189
+ const mapKey = `${change.tableName}::${relChange.targetTable}`;
1190
+ const approved = await this.diffService.promptDeleteRelations(change.tableName, relChange.targetTable, pairs);
1191
+ approvedRelationDeletesMap.set(mapKey, approved);
1192
+ }
1193
+ }
1194
+ }
1195
+ // ── SQL generation ─────────────────────────────────────────────────────────
1196
+ const dbType = await this.database.getDatabaseType(cwd);
1197
+ if (!dbType)
1198
+ throw new Error('Database type could not be determined');
1199
+ const enumRegistry = this.buildEnumRegistry(allTables);
1200
+ const migrationContent = [];
1201
+ // 1. New tables (full CREATE TABLE pipeline)
1202
+ if (schemaDiff.newTables.length > 0) {
1203
+ const newEnumRegistry = this.buildEnumRegistry(schemaDiff.newTables);
1204
+ this.processEnums(schemaDiff.newTables, dbType, migrationContent, newEnumRegistry);
1205
+ const createdTables = [];
1206
+ await this.createTableMigrations(schemaDiff.newTables, dbType, migrationContent, createdTables, cwd);
1207
+ this.addUniqueConstraints(schemaDiff.newTables, dbType, migrationContent);
1208
+ this.addForeignKeys(schemaDiff.newTables, dbType, migrationContent);
1209
+ }
1210
+ // 2. Removed tables (warning comment only)
1211
+ for (const removed of schemaDiff.removedTables) {
1212
+ migrationContent.push(`-- WARNING: Table "${removed.name}" was removed from the library YAML but was NOT dropped from the database. Manual action may be required.`, '');
1213
+ }
1214
+ // 3. ALTER TABLE statements for changed tables
1215
+ for (const tableChanges of schemaDiff.changedTables) {
1216
+ const approvedDrops = approvedDropsMap.get(tableChanges.tableName) ?? [];
1217
+ const alterSql = this.generateAlterTableSQL(tableChanges, approvedDrops, enumRegistry, dbType, cwd);
1218
+ migrationContent.push(...alterSql);
1219
+ }
1220
+ // 4. Data upserts / deletes
1221
+ const dataSql = await this.generateDataUpdateSQL(dataDiff, allTables, approvedDeletesMap, approvedFileDeletesMap, approvedRelationDeletesMap, cwd);
1222
+ migrationContent.push(...dataSql);
1223
+ // 5. After queries (new version's hedhog/query/*.sql)
1224
+ const columnRegistry = this.buildColumnTypeRegistry(allTables);
1225
+ await this.afterQueries(name, cwd, migrationContent, enumRegistry, columnRegistry, (0, path_1.join)(newLibraryPath, 'hedhog', 'query'));
1226
+ // ── Write migration file ──────────────────────────────────────────────────
1227
+ const hasMeaningfulContent = migrationContent.some((line) => line.trim() && !line.startsWith('--'));
1228
+ if (!hasMeaningfulContent) {
1229
+ this.log(`No schema or data changes detected for ${name}. Skipping migration file.`);
1230
+ console.info(chalk.blue(`[update] No changes detected for "${name}". Migration file not created.`));
1231
+ return;
1232
+ }
1233
+ const timestamp = new Date()
1234
+ .toISOString()
1235
+ .replace(/[-:.T]/g, '')
1236
+ .slice(0, 14);
1237
+ const migrationFileName = `${timestamp}_update_${this.toSnackCase(name)}`;
1238
+ const migrationFilePath = (0, path_1.resolve)(cwd, `./apps/api/prisma/migrations/${migrationFileName}`);
1239
+ await this.fileSystem.createDirectory(migrationFilePath);
1240
+ const formattedSQL = (0, sql_formatter_1.format)(migrationContent.join('\n') + '\n', {
1241
+ language: 'postgresql',
1242
+ newlineBeforeSemicolon: false,
1243
+ });
1244
+ await this.fileSystem.writeFileContent((0, path_1.resolve)(migrationFilePath, 'migration.sql'), formattedSQL);
1245
+ // Update hedhog.json hash/migration entry
1246
+ const hedhogFilePath = (0, path_1.join)(cwd, 'hedhog.json');
1247
+ let hedhogFile = {};
1248
+ if (await this.fileSystem.exists(hedhogFilePath)) {
1249
+ hedhogFile = await this.fileSystem.readJsonFile(hedhogFilePath);
1250
+ }
1251
+ if (!hedhogFile.libraries)
1252
+ hedhogFile.libraries = {};
1253
+ // Hash the NEW version (still in .hedhog-update/) — hedhog.service.ts will
1254
+ // replace the library folder AFTER this method returns.
1255
+ const hashLib = await this.developer.hashDirectory(newLibraryPath);
1256
+ hedhogFile.libraries[name] = {
1257
+ hash: hashLib,
1258
+ migration: migrationFileName,
1259
+ };
1260
+ await this.fileSystem.writeJsonFile(hedhogFilePath, hedhogFile);
1261
+ this.log(`Update migration file ${migrationFileName} created successfully.`);
1262
+ }
1263
+ // ── ALTER TABLE SQL generation ────────────────────────────────────────────
1264
+ generateAlterTableSQL(changes, approvedDrops, enumRegistry, dbType, cwd) {
1265
+ if (dbType !== 'postgres')
1266
+ return [];
1267
+ const sql = [];
1268
+ const t = changes.tableName;
1269
+ // RENAMES (before any other operation on those columns)
1270
+ for (const rename of changes.renamedColumns.filter((r) => r.isRename)) {
1271
+ sql.push(`-- RenameColumn`, `ALTER TABLE "${t}" RENAME COLUMN "${rename.oldName}" TO "${rename.newName}";`, '');
1272
+ // If type/nullable/default also changed, apply those after rename
1273
+ const change = changes.changedColumns.find((c) => c.column.name === rename.newName);
1274
+ if (change) {
1275
+ sql.push(...this.generateColumnAlterSQL(t, change, enumRegistry));
1276
+ }
1277
+ }
1278
+ const renamedNewNames = new Set(changes.renamedColumns.filter((r) => r.isRename).map((r) => r.newName));
1279
+ // ADD COLUMNS
1280
+ for (const col of changes.addedColumns) {
1281
+ const typeDef = this.getColumnTypeDef(col, t, enumRegistry);
1282
+ const nullable = col.isNullable ? 'NULL' : 'NOT NULL';
1283
+ const defaultClause = col.default !== undefined
1284
+ ? ` DEFAULT ${this.getDefaultValue(col)}`
1285
+ : '';
1286
+ sql.push(`-- AddColumn`, `ALTER TABLE "${t}" ADD COLUMN "${col.name}" ${typeDef} ${nullable}${defaultClause};`, '');
1287
+ }
1288
+ // DROP COLUMNS (approved only)
1289
+ for (const col of approvedDrops) {
1290
+ sql.push(`-- DropColumn`, `ALTER TABLE "${t}" DROP COLUMN "${col.name}";`, '');
1291
+ }
1292
+ // CHANGED COLUMNS (skip ones that were part of a rename — already handled)
1293
+ for (const change of changes.changedColumns) {
1294
+ if (renamedNewNames.has(change.column.name))
1295
+ continue;
1296
+ sql.push(...this.generateColumnAlterSQL(t, change, enumRegistry));
1297
+ }
1298
+ // NEW UNIQUE CONSTRAINTS
1299
+ for (const colName of changes.addedUniqueConstraints) {
1300
+ sql.push(`-- CreateUniqueIndex`, `CREATE UNIQUE INDEX IF NOT EXISTS "${t}_${colName}_unique" ON "${t}" ("${colName}");`, '');
1301
+ }
1302
+ // DROPPED UNIQUE CONSTRAINTS
1303
+ for (const colName of changes.removedUniqueConstraints) {
1304
+ sql.push(`-- DropUniqueIndex`, `DROP INDEX IF EXISTS "${t}_${colName}_unique";`, '');
1305
+ }
1306
+ // NEW FOREIGN KEYS
1307
+ for (const col of changes.addedForeignKeys) {
1308
+ const { table: fkTable, column: fkCol, onDelete, onUpdate, } = col.references;
1309
+ sql.push(`-- AddForeignKey`, `ALTER TABLE "${t}" ADD CONSTRAINT "${fkTable}_${col.name}_fkey" FOREIGN KEY ("${col.name}") REFERENCES "${fkTable}" ("${fkCol}") ON DELETE ${onDelete || 'NO ACTION'} ON UPDATE ${onUpdate || 'NO ACTION'};`, '');
1310
+ }
1311
+ // DROPPED FOREIGN KEYS
1312
+ for (const col of changes.removedForeignKeys) {
1313
+ const { table: fkTable } = col.references;
1314
+ sql.push(`-- DropForeignKey`, `ALTER TABLE "${t}" DROP CONSTRAINT "${fkTable}_${col.name}_fkey";`, '');
1315
+ }
1316
+ // ENUM VALUE CHANGES
1317
+ for (const enumChange of changes.enumValueChanges) {
1318
+ const enumName = this.getEnumName(t, enumChange.columnName);
1319
+ for (const val of enumChange.addedValues) {
1320
+ sql.push(`-- AddEnumValue`, `ALTER TYPE "${enumName}" ADD VALUE IF NOT EXISTS '${this.escapeSqlLiteral(val)}';`, '');
1321
+ }
1322
+ for (const val of enumChange.removedValues) {
1323
+ sql.push(`-- WARNING: Cannot remove enum value '${val}' from type "${enumName}". PostgreSQL does not support removing enum values directly. Manual migration required (recreate type, update column, drop old type).`, '');
1324
+ }
1325
+ }
1326
+ return sql;
1327
+ }
1328
+ generateColumnAlterSQL(tableName, change, enumRegistry) {
1329
+ const sql = [];
1330
+ const col = change.column;
1331
+ const colName = col.name;
1332
+ if (change.oldType !== change.newType ||
1333
+ change.oldLength !== change.newLength) {
1334
+ const newTypeDef = this.getColumnTypeDef(col, tableName, enumRegistry);
1335
+ sql.push(`-- AlterColumnType`, `ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" TYPE ${newTypeDef} USING "${colName}"::TEXT::${newTypeDef};`, '');
1336
+ }
1337
+ if (change.oldNullable !== change.newNullable) {
1338
+ if (change.newNullable) {
1339
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" DROP NOT NULL;`, '');
1340
+ }
1341
+ else {
1342
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" SET NOT NULL;`, '');
1343
+ }
1344
+ }
1345
+ const oldDef = String(change.oldDefault ?? '');
1346
+ const newDef = String(change.newDefault ?? '');
1347
+ if (oldDef !== newDef) {
1348
+ if (change.newDefault === undefined || change.newDefault === null) {
1349
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" DROP DEFAULT;`, '');
1350
+ }
1351
+ else {
1352
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" SET DEFAULT ${this.getDefaultValue(col)};`, '');
1353
+ }
1354
+ }
1355
+ return sql;
1356
+ }
1357
+ getColumnTypeDef(col, tableName, enumRegistry) {
1358
+ switch (col.type.toLowerCase()) {
1359
+ case 'pk':
1360
+ return 'SERIAL';
1361
+ case 'fk':
1362
+ case 'int':
1363
+ case 'order':
1364
+ return 'INTEGER';
1365
+ case 'slug':
1366
+ case 'varchar':
1367
+ return `VARCHAR(${col.length || 255})`;
1368
+ case 'created_at':
1369
+ case 'updated_at':
1370
+ case 'datetime':
1371
+ return 'TIMESTAMPTZ';
1372
+ case 'locale_varchar':
1373
+ return `VARCHAR(${col.length || 255})`;
1374
+ case 'locale_text':
1375
+ return 'TEXT';
1376
+ case 'char':
1377
+ return `CHAR(${col.length || 1})`;
1378
+ case 'enum': {
1379
+ const enumName = this.getEnumName(tableName, col.name);
1380
+ return `"${enumName}"`;
1381
+ }
1382
+ default:
1383
+ return col.type.toUpperCase();
1384
+ }
1385
+ }
1386
+ // ── Data upsert SQL generation ────────────────────────────────────────────
1387
+ async generateDataUpdateSQL(dataDiff, allTables, approvedDeletesMap, approvedFileDeletesMap, approvedRelationDeletesMap, cwd) {
1388
+ const sql = [];
1389
+ // Entirely new data files → all rows as upserts
1390
+ for (const fc of dataDiff.fileChanges) {
1391
+ if (fc.isNew) {
1392
+ const tableDef = allTables.find((t) => t.name === fc.tableName);
1393
+ if (!tableDef)
1394
+ continue;
1395
+ const keyCol = this.diffService.findIdentityKey(tableDef);
1396
+ if (!keyCol)
1397
+ continue;
1398
+ sql.push(`-- UpsertData (new data file): ${fc.tableName}`);
1399
+ for (const row of fc.allRows) {
1400
+ const rowSql = this.generateRowUpsertSQL(row, tableDef, keyCol, allTables);
1401
+ sql.push(...rowSql);
1402
+ }
1403
+ sql.push('');
1404
+ }
1405
+ }
1406
+ // Entirely removed data files → DELETE if approved
1407
+ for (const fc of dataDiff.fileChanges) {
1408
+ if (fc.isRemoved) {
1409
+ const decision = approvedFileDeletesMap.get(fc.tableName);
1410
+ if (decision === 'delete') {
1411
+ const tableDef = allTables.find((t) => t.name === fc.tableName);
1412
+ const keyCol = tableDef
1413
+ ? this.diffService.findIdentityKey(tableDef)
1414
+ : null;
1415
+ sql.push(`-- DeleteData (data file removed): ${fc.tableName}`);
1416
+ if (Array.isArray(keyCol)) {
1417
+ sql.push(`-- WARNING: composite key — DELETE skipped for "${fc.tableName}". Manual action may be required.`);
1418
+ }
1419
+ else if (keyCol) {
1420
+ for (const row of fc.allRows) {
1421
+ const keyVal = row[keyCol];
1422
+ if (keyVal !== null && keyVal !== undefined) {
1423
+ sql.push(`DELETE FROM "${fc.tableName}" WHERE "${keyCol}" = $$${String(keyVal).replace(/\$\$/g, "''")}$$;`);
1424
+ }
1425
+ }
1426
+ }
1427
+ else {
1428
+ sql.push(`-- WARNING: Could not determine identity key for "${fc.tableName}". DELETE skipped.`);
1429
+ }
1430
+ sql.push('');
1431
+ }
1432
+ else {
1433
+ sql.push(`-- INFO: Data file for "${fc.tableName}" was removed in the new version. Existing rows preserved.`, '');
1434
+ }
1435
+ }
1436
+ }
1437
+ // Row-level changes
1438
+ for (const change of dataDiff.changes) {
1439
+ const tableDef = allTables.find((t) => t.name === change.tableName);
1440
+ if (!tableDef)
1441
+ continue;
1442
+ // Inserted rows
1443
+ if (change.insertedRows.length > 0) {
1444
+ sql.push(`-- InsertData: ${change.tableName}`);
1445
+ for (const row of change.insertedRows) {
1446
+ sql.push(...this.generateRowUpsertSQL(row, tableDef, change.keyColumn, allTables));
1447
+ }
1448
+ sql.push('');
1449
+ }
1450
+ // Updated rows
1451
+ if (change.updatedRows.length > 0) {
1452
+ sql.push(`-- UpdateData: ${change.tableName}`);
1453
+ for (const row of change.updatedRows) {
1454
+ sql.push(...this.generateRowUpsertSQL(row, tableDef, change.keyColumn, allTables));
1455
+ }
1456
+ sql.push('');
1457
+ }
1458
+ // Deleted rows (approved)
1459
+ const approvedDeletes = approvedDeletesMap.get(change.tableName) ?? [];
1460
+ if (approvedDeletes.length > 0) {
1461
+ sql.push(`-- DeleteData: ${change.tableName}`);
1462
+ if (Array.isArray(change.keyColumn)) {
1463
+ sql.push(`-- WARNING: composite key — DELETE skipped for "${change.tableName}". Manual action may be required.`);
1464
+ }
1465
+ else {
1466
+ for (const row of approvedDeletes) {
1467
+ const keyVal = row[change.keyColumn];
1468
+ sql.push(`DELETE FROM "${change.tableName}" WHERE "${change.keyColumn}" = $$${String(keyVal).replace(/\$\$/g, "''")}$$;`);
1469
+ }
1470
+ }
1471
+ sql.push('');
1472
+ }
1473
+ // Relation changes
1474
+ for (const relChange of change.relationChanges) {
1475
+ if (Array.isArray(change.keyColumn))
1476
+ continue; // composite-key tables don't have nested relations
1477
+ const mapKey = `${change.tableName}::${relChange.targetTable}`;
1478
+ const approvedRelDeletes = approvedRelationDeletesMap.get(mapKey) ?? [];
1479
+ const relSql = await this.generateRelationUpdateSQL(change.tableName, change.keyColumn, relChange.parentKeyValue, relChange.targetTable, relChange.addedRelations, relChange.removedRelations, approvedRelDeletes, allTables, cwd);
1480
+ sql.push(...relSql);
1481
+ }
1482
+ }
1483
+ return sql;
1484
+ }
1485
+ generateRowUpsertSQL(row, tableDef, keyColumn, allTables) {
1486
+ const sql = [];
1487
+ const keyCols = Array.isArray(keyColumn) ? keyColumn : [keyColumn];
1488
+ const keyColsQuoted = new Set(keyCols.map((k) => `"${k}"`));
1489
+ const scalarCols = [];
1490
+ const scalarVals = [];
1491
+ const localeEntries = [];
1492
+ for (const [key, value] of Object.entries(row)) {
1493
+ if (key === 'relations')
1494
+ continue;
1495
+ if (this.checkIsObjectLocale(value)) {
1496
+ localeEntries.push({ name: key, values: value });
1497
+ }
1498
+ else if (tableDef.columns.some((c) => c.name === key)) {
1499
+ scalarCols.push(`"${key}"`);
1500
+ scalarVals.push(this.resolveRawValue(value, key, tableDef, allTables));
1501
+ }
1502
+ }
1503
+ if (scalarCols.length > 0) {
1504
+ if (keyCols.length > 1) {
1505
+ // Use WHERE NOT EXISTS — safe even without a DB-level unique constraint
1506
+ const whereConditions = keyCols
1507
+ .map((k) => {
1508
+ const idx = scalarCols.indexOf(`"${k}"`);
1509
+ const val = idx >= 0 ? scalarVals[idx] : 'NULL';
1510
+ return `"${k}" = ${val}`;
1511
+ })
1512
+ .join(' AND ');
1513
+ sql.push(`INSERT INTO "${tableDef.name}" (${scalarCols.join(', ')}) SELECT ${scalarVals.join(', ')} WHERE NOT EXISTS (SELECT 1 FROM "${tableDef.name}" WHERE ${whereConditions});`);
1514
+ }
1515
+ else {
1516
+ const conflictTarget = `"${keyCols[0]}"`;
1517
+ const setClauses = scalarCols
1518
+ .filter((c) => !keyColsQuoted.has(c))
1519
+ .map((c) => `${c} = EXCLUDED.${c}`)
1520
+ .join(', ');
1521
+ const doConflict = setClauses
1522
+ ? `ON CONFLICT (${conflictTarget}) DO UPDATE SET ${setClauses}`
1523
+ : `ON CONFLICT (${conflictTarget}) DO NOTHING`;
1524
+ sql.push(`INSERT INTO "${tableDef.name}" (${scalarCols.join(', ')}) VALUES (${scalarVals.join(', ')}) ${doConflict};`);
1525
+ }
1526
+ }
1527
+ // Locale upserts — only applicable when key is a single column
1528
+ const localeTableDef = allTables.find((t) => t.name === `${tableDef.name}_locale`);
1529
+ if (localeTableDef &&
1530
+ localeEntries.length > 0 &&
1531
+ !Array.isArray(keyColumn)) {
1532
+ const fkColName = localeTableDef.columns.find((c) => c.type === 'fk' && c.references?.table === tableDef.name)?.name;
1533
+ const fkLocaleColName = localeTableDef.columns.find((c) => c.type === 'fk' && c.references?.table === 'locale')?.name;
1534
+ if (fkColName && fkLocaleColName) {
1535
+ const keyVal = row[keyColumn];
1536
+ const parentKeySQL = this.resolveRawValue(keyVal, keyColumn, tableDef, allTables);
1537
+ const parentSelect = `(SELECT "${tableDef.pk}" FROM "${tableDef.name}" WHERE "${keyColumn}" = ${parentKeySQL})`;
1538
+ const localeCodes = new Set();
1539
+ for (const entry of localeEntries) {
1540
+ for (const code of Object.keys(entry.values))
1541
+ localeCodes.add(code);
1542
+ }
1543
+ for (const code of localeCodes) {
1544
+ const localeCols2 = [];
1545
+ const localeVals = [];
1546
+ for (const entry of localeEntries) {
1547
+ if (entry.values[code] !== undefined) {
1548
+ localeCols2.push(`"${entry.name}"`);
1549
+ localeVals.push(`$$${String(entry.values[code]).replace(/\$\$/g, "''")}$$`);
1550
+ }
1551
+ }
1552
+ if (localeCols2.length > 0) {
1553
+ const insertCols = [
1554
+ `"${fkLocaleColName}"`,
1555
+ `"${fkColName}"`,
1556
+ ...localeCols2,
1557
+ ];
1558
+ const insertVals = [
1559
+ `(SELECT "id" FROM "locale" WHERE "code" = '${code}')`,
1560
+ parentSelect,
1561
+ ...localeVals,
1562
+ ];
1563
+ const setClauses2 = localeCols2
1564
+ .map((c) => `${c} = EXCLUDED.${c}`)
1565
+ .join(', ');
1566
+ sql.push(`INSERT INTO "${tableDef.name}_locale" (${insertCols.join(', ')}) VALUES (${insertVals.join(', ')}) ON CONFLICT ("${fkLocaleColName}", "${fkColName}") DO UPDATE SET ${setClauses2};`);
1567
+ }
1568
+ }
1569
+ }
1570
+ }
1571
+ return sql;
1572
+ }
1573
+ resolveRawValue(value, colName, tableDef, allTables) {
1574
+ if (value === null || value === undefined)
1575
+ return 'NULL';
1576
+ if (typeof value === 'boolean')
1577
+ return value ? 'TRUE' : 'FALSE';
1578
+ if (typeof value === 'number')
1579
+ return String(value);
1580
+ if (typeof value === 'object' && 'where' in value) {
1581
+ const colDef = tableDef.columns.find((c) => c.name === colName);
1582
+ const refTableName = colDef?.references?.table;
1583
+ if (!refTableName) {
1584
+ console.warn(chalk.yellow(`[update] resolveRawValue: could not resolve FK table for column "${colName}" in "${tableDef.name}". Substituting NULL.`));
1585
+ return 'NULL';
1586
+ }
1587
+ const refTableDef = allTables.find((t) => t.name === refTableName);
1588
+ const refPk = refTableDef?.pk ?? 'id';
1589
+ const whereClause = this.getWhereString(value);
1590
+ return `(SELECT "${refPk}" FROM "${refTableName}" ${whereClause})`;
1591
+ }
1592
+ return `$$${String(value).replace(/\$\$/g, "''")}$$`;
1593
+ }
1594
+ async generateRelationUpdateSQL(parentTable, parentKeyColumn, parentKeyValue, targetTable, addedRelations, removedRelations, approvedRemovals, allTables, cwd) {
1595
+ const sql = [];
1596
+ const junctionResult = await this.tableService.getTableIntermediate(parentTable, targetTable, cwd);
1597
+ if (!junctionResult) {
1598
+ sql.push(`-- WARNING: Could not resolve junction table for relation "${parentTable}" ↔ "${targetTable}". Relation changes skipped.`, '');
1599
+ return sql;
1600
+ }
1601
+ const targetTableDef = allTables.find((t) => t.name === targetTable);
1602
+ const targetPk = targetTableDef?.pk ?? 'id';
1603
+ const parentTableDef = allTables.find((t) => t.name === parentTable);
1604
+ const parentPk = parentTableDef?.pk ?? 'id';
1605
+ const parentKeySQL = `(SELECT "${parentPk}" FROM "${parentTable}" WHERE "${parentKeyColumn}" = $$${String(parentKeyValue).replace(/\$\$/g, "''")}$$)`;
1606
+ if (junctionResult.intermediateTable) {
1607
+ const junctionName = junctionResult.name;
1608
+ const fkForParent = junctionResult.table.columns.find((c) => c.type === 'fk' && c.references?.table === parentTable)?.name;
1609
+ const fkForTarget = junctionResult.table.columns.find((c) => c.type === 'fk' && c.references?.table === targetTable)?.name;
1610
+ if (!fkForParent || !fkForTarget)
1611
+ return sql;
1612
+ for (const rel of addedRelations) {
1613
+ const whereClause = this.getWhereString(rel);
1614
+ const targetSQL = `(SELECT "${targetPk}" FROM "${targetTable}" ${whereClause})`;
1615
+ sql.push(`-- AddRelation: ${parentTable} ↔ ${targetTable}`, `INSERT INTO "${junctionName}" ("${fkForParent}", "${fkForTarget}") VALUES (${parentKeySQL}, ${targetSQL}) ON CONFLICT DO NOTHING;`, '');
1616
+ }
1617
+ for (const rel of removedRelations) {
1618
+ const isApproved = approvedRemovals.some((a) => a.parentKeyValue === parentKeyValue &&
1619
+ JSON.stringify(a.relation) === JSON.stringify(rel));
1620
+ if (!isApproved)
1621
+ continue;
1622
+ const whereClause = this.getWhereString(rel);
1623
+ const targetSQL = `(SELECT "${targetPk}" FROM "${targetTable}" ${whereClause})`;
1624
+ sql.push(`-- RemoveRelation: ${parentTable} ↔ ${targetTable}`, `DELETE FROM "${junctionName}" WHERE "${fkForParent}" = ${parentKeySQL} AND "${fkForTarget}" = ${targetSQL};`, '');
1625
+ }
1626
+ }
1627
+ else {
1628
+ // Direct child relation
1629
+ const childTable = junctionResult.name;
1630
+ const fkForParent = junctionResult.table.columns.find((c) => c.type === 'fk' && c.references?.table === parentTable)?.name;
1631
+ if (!fkForParent)
1632
+ return sql;
1633
+ for (const rel of addedRelations) {
1634
+ const whereClause = this.getWhereString(rel);
1635
+ sql.push(`-- AddRelation (child): ${childTable}`, `INSERT INTO "${childTable}" ("${fkForParent}") SELECT ${parentKeySQL} WHERE EXISTS (SELECT 1 FROM "${targetTable}" ${whereClause}) ON CONFLICT DO NOTHING;`, '');
1636
+ }
1637
+ for (const rel of removedRelations) {
1638
+ const isApproved = approvedRemovals.some((a) => a.parentKeyValue === parentKeyValue &&
1639
+ JSON.stringify(a.relation) === JSON.stringify(rel));
1640
+ if (!isApproved)
1641
+ continue;
1642
+ const whereClause = this.getWhereString(rel);
1643
+ sql.push(`-- RemoveRelation (child): ${childTable}`, `DELETE FROM "${childTable}" WHERE "${fkForParent}" = ${parentKeySQL} AND EXISTS (SELECT 1 FROM "${targetTable}" ${whereClause});`, '');
1644
+ }
1645
+ }
1646
+ return sql;
1647
+ }
1041
1648
  };
1042
1649
  exports.MigrationService = MigrationService;
1043
1650
  exports.MigrationService = MigrationService = __decorate([
@@ -1045,6 +1652,7 @@ exports.MigrationService = MigrationService = __decorate([
1045
1652
  __metadata("design:paramtypes", [database_service_1.DatabaseService,
1046
1653
  developer_service_1.DeveloperService,
1047
1654
  file_system_service_1.FileSystemService,
1048
- table_service_1.TableService])
1655
+ table_service_1.TableService,
1656
+ diff_service_1.DiffService])
1049
1657
  ], MigrationService);
1050
1658
  //# sourceMappingURL=migration.service.js.map