@hed-hog/cli 0.0.95 → 0.0.97

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.
@@ -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) {
@@ -311,6 +314,64 @@ let MigrationService = class MigrationService {
311
314
  }
312
315
  return transformedContent;
313
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
+ }
314
375
  normalizeCustomSqlQuery(file, sqlContent, enumRegistry, columnRegistry) {
315
376
  const normalizedDeclarations = this.normalizeCustomSqlEnumDeclarations(file, sqlContent, enumRegistry, columnRegistry);
316
377
  return this.normalizeCustomSqlAgainstSchema(file, normalizedDeclarations, columnRegistry);
@@ -574,12 +635,12 @@ let MigrationService = class MigrationService {
574
635
  this.log(`Creating unique constraints for table: ${table.name}`);
575
636
  const uniqueColumns = table.columns.filter((c) => c.isUnique);
576
637
  if (uniqueColumns.length) {
577
- 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(', ')});`, ``);
578
639
  }
579
640
  for (const column of table.columns) {
580
641
  if (column.type === 'slug') {
581
642
  this.log(`Adding unique constraint for column: ${column.name}`);
582
- 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}");`, ``);
583
644
  }
584
645
  }
585
646
  }
@@ -844,9 +905,9 @@ let MigrationService = class MigrationService {
844
905
  }
845
906
  return [];
846
907
  }
847
- async afterQueries(name, cwd, migrationContent, enumRegistry, columnRegistry) {
908
+ async afterQueries(name, cwd, migrationContent, enumRegistry, columnRegistry, customQueriesPath) {
848
909
  this.log(`Running after queries for ${name}...`);
849
- const queriesPath = (0, path_1.resolve)(cwd, `./libraries/${name}/hedhog/query`);
910
+ const queriesPath = customQueriesPath ?? (0, path_1.resolve)(cwd, `./libraries/${name}/hedhog/query`);
850
911
  try {
851
912
  if (await this.fileSystem.exists(queriesPath)) {
852
913
  const allFiles = await this.fileSystem.listFiles(queriesPath, () => true);
@@ -854,7 +915,7 @@ let MigrationService = class MigrationService {
854
915
  if (file.endsWith('.sql')) {
855
916
  const sqlContent = await this.fileSystem.readFileContent((0, path_1.join)(queriesPath, file));
856
917
  const normalizedSql = this.normalizeCustomSqlQuery(file, sqlContent, enumRegistry, columnRegistry);
857
- migrationContent.push(`-- AfterQuery: ${file}`, normalizedSql, '');
918
+ migrationContent.push(`-- AfterQuery: ${file}`, this.wrapIdempotentSql(normalizedSql), '');
858
919
  }
859
920
  else {
860
921
  console.warn(chalk.yellow(`Arquivo ignorado no diretório de queries: ${file}`));
@@ -979,6 +1040,10 @@ let MigrationService = class MigrationService {
979
1040
  }
980
1041
  }
981
1042
  }
1043
+ if (allTables.length === 0 && dataFiles.length === 0) {
1044
+ this.log(`No tables or data found for ${name}. Skipping migration creation.`);
1045
+ return false;
1046
+ }
982
1047
  this.log(`Creating migrations for ${name}...`);
983
1048
  const dbType = await this.database.getDatabaseType(cwd);
984
1049
  if (!dbType) {
@@ -1051,6 +1116,539 @@ let MigrationService = class MigrationService {
1051
1116
  };
1052
1117
  await this.fileSystem.writeJsonFile(hedhogFilePath, hedhogFile);
1053
1118
  this.log(`Migration file ${migrationFileName} created successfully.`);
1119
+ return true;
1120
+ }
1121
+ // ── Update migration ─────────────────────────────────────────────────────────
1122
+ /**
1123
+ * Generate a differential migration (ALTER TABLE + upserts) for an existing
1124
+ * library being updated. Reads old schema/data from `libraries/<name>/` and
1125
+ * new schema/data from `libraries/<name>/.hedhog-update/`.
1126
+ */
1127
+ async createUpdateMigrations(name, cwd = process.cwd(), verbose = false) {
1128
+ this.verbose = verbose;
1129
+ const oldLibraryPath = (0, path_1.resolve)(cwd, `./libraries/${name}`);
1130
+ const newLibraryPath = (0, path_1.join)(oldLibraryPath, '.hedhog-update');
1131
+ this.log(`Loading old table definitions from ${oldLibraryPath}...`);
1132
+ const oldTables = await this.tableService.loadLibraryOwnedTables(oldLibraryPath, verbose);
1133
+ this.log(`Loading new table definitions from ${newLibraryPath}...`);
1134
+ const newTables = await this.tableService.loadLibraryOwnedTables(newLibraryPath, verbose);
1135
+ // Build full allTables for FK/locale resolution during SQL generation
1136
+ // Use new version's dependencies since it may have updated deps
1137
+ const tablesDep = await this.tableService.getTablesFromDependencies(name, cwd);
1138
+ const allTablesMap = new Map();
1139
+ for (const t of [...tablesDep, ...newTables]) {
1140
+ if (!allTablesMap.has(t.name))
1141
+ allTablesMap.set(t.name, t);
1142
+ }
1143
+ const allTables = [...allTablesMap.values()];
1144
+ // Diffs
1145
+ const schemaDiff = this.diffService.diffSchema(oldTables, newTables);
1146
+ const dataDiff = await this.diffService.diffData(oldLibraryPath, newLibraryPath, allTablesMap);
1147
+ // ── Interactive prompts ────────────────────────────────────────────────────
1148
+ // Resolve renames for changed tables
1149
+ for (const tableChanges of schemaDiff.changedTables) {
1150
+ const candidates = this.diffService.detectRenameCandidates(tableChanges.removedColumns, tableChanges.addedColumns);
1151
+ if (candidates.length > 0) {
1152
+ const decisions = await this.diffService.promptRenames(tableChanges.tableName, candidates);
1153
+ tableChanges.renamedColumns = decisions.filter((d) => d.isRename);
1154
+ // Remove confirmed renames from added/removed lists
1155
+ const renamedOldNames = new Set(tableChanges.renamedColumns.map((r) => r.oldName));
1156
+ const renamedNewNames = new Set(tableChanges.renamedColumns.map((r) => r.newName));
1157
+ tableChanges.removedColumns = tableChanges.removedColumns.filter((c) => !renamedOldNames.has(c.name));
1158
+ tableChanges.addedColumns = tableChanges.addedColumns.filter((c) => !renamedNewNames.has(c.name));
1159
+ }
1160
+ }
1161
+ // Prompt for dropped columns
1162
+ const approvedDropsMap = new Map();
1163
+ for (const tableChanges of schemaDiff.changedTables) {
1164
+ if (tableChanges.removedColumns.length > 0) {
1165
+ const approved = await this.diffService.promptDropColumns(tableChanges.tableName, tableChanges.removedColumns);
1166
+ approvedDropsMap.set(tableChanges.tableName, approved);
1167
+ }
1168
+ }
1169
+ // Prompt for removed data rows
1170
+ const approvedDeletesMap = new Map();
1171
+ for (const change of dataDiff.changes) {
1172
+ if (change.removedRows.length > 0) {
1173
+ const approved = await this.diffService.promptDeleteRows(change.tableName, change.removedRows, change.keyColumn);
1174
+ approvedDeletesMap.set(change.tableName, approved);
1175
+ }
1176
+ }
1177
+ // Prompt for entirely removed data files
1178
+ const approvedFileDeletesMap = new Map();
1179
+ for (const fc of dataDiff.fileChanges) {
1180
+ if (fc.isRemoved) {
1181
+ const decision = await this.diffService.promptDeleteEntireDataFile(fc.tableName, fc.allRows);
1182
+ approvedFileDeletesMap.set(fc.tableName, decision);
1183
+ }
1184
+ }
1185
+ // Prompt for removed relations
1186
+ const approvedRelationDeletesMap = new Map();
1187
+ for (const change of dataDiff.changes) {
1188
+ for (const relChange of change.relationChanges) {
1189
+ if (relChange.removedRelations.length > 0) {
1190
+ const pairs = relChange.removedRelations.map((rel) => ({
1191
+ parentKeyValue: relChange.parentKeyValue,
1192
+ relation: rel,
1193
+ }));
1194
+ const mapKey = `${change.tableName}::${relChange.targetTable}`;
1195
+ const approved = await this.diffService.promptDeleteRelations(change.tableName, relChange.targetTable, pairs);
1196
+ approvedRelationDeletesMap.set(mapKey, approved);
1197
+ }
1198
+ }
1199
+ }
1200
+ // ── SQL generation ─────────────────────────────────────────────────────────
1201
+ const dbType = await this.database.getDatabaseType(cwd);
1202
+ if (!dbType)
1203
+ throw new Error('Database type could not be determined');
1204
+ const enumRegistry = this.buildEnumRegistry(allTables);
1205
+ const migrationContent = [];
1206
+ // 1. New tables (full CREATE TABLE pipeline)
1207
+ if (schemaDiff.newTables.length > 0) {
1208
+ const newEnumRegistry = this.buildEnumRegistry(schemaDiff.newTables);
1209
+ this.processEnums(schemaDiff.newTables, dbType, migrationContent, newEnumRegistry);
1210
+ const createdTables = [];
1211
+ await this.createTableMigrations(schemaDiff.newTables, dbType, migrationContent, createdTables, cwd);
1212
+ this.addUniqueConstraints(schemaDiff.newTables, dbType, migrationContent);
1213
+ this.addForeignKeys(schemaDiff.newTables, dbType, migrationContent);
1214
+ }
1215
+ // 2. Removed tables (warning comment only)
1216
+ for (const removed of schemaDiff.removedTables) {
1217
+ migrationContent.push(`-- WARNING: Table "${removed.name}" was removed from the library YAML but was NOT dropped from the database. Manual action may be required.`, '');
1218
+ }
1219
+ // 3. ALTER TABLE statements for changed tables
1220
+ for (const tableChanges of schemaDiff.changedTables) {
1221
+ const approvedDrops = approvedDropsMap.get(tableChanges.tableName) ?? [];
1222
+ const alterSql = this.generateAlterTableSQL(tableChanges, approvedDrops, enumRegistry, dbType, cwd);
1223
+ migrationContent.push(...alterSql);
1224
+ }
1225
+ // 4. Data upserts / deletes
1226
+ const dataSql = await this.generateDataUpdateSQL(dataDiff, allTables, approvedDeletesMap, approvedFileDeletesMap, approvedRelationDeletesMap, cwd);
1227
+ migrationContent.push(...dataSql);
1228
+ // 5. After queries (new version's hedhog/query/*.sql)
1229
+ const columnRegistry = this.buildColumnTypeRegistry(allTables);
1230
+ await this.afterQueries(name, cwd, migrationContent, enumRegistry, columnRegistry, (0, path_1.join)(newLibraryPath, 'hedhog', 'query'));
1231
+ // ── Write migration file ──────────────────────────────────────────────────
1232
+ const hasMeaningfulContent = migrationContent.some((line) => line.trim() && !line.startsWith('--'));
1233
+ if (!hasMeaningfulContent) {
1234
+ this.log(`No schema or data changes detected for ${name}. Skipping migration file.`);
1235
+ console.info(chalk.blue(`[update] No changes detected for "${name}". Migration file not created.`));
1236
+ return;
1237
+ }
1238
+ const timestamp = new Date()
1239
+ .toISOString()
1240
+ .replace(/[-:.T]/g, '')
1241
+ .slice(0, 14);
1242
+ const migrationFileName = `${timestamp}_update_${this.toSnackCase(name)}`;
1243
+ const migrationFilePath = (0, path_1.resolve)(cwd, `./apps/api/prisma/migrations/${migrationFileName}`);
1244
+ await this.fileSystem.createDirectory(migrationFilePath);
1245
+ const formattedSQL = (0, sql_formatter_1.format)(migrationContent.join('\n') + '\n', {
1246
+ language: 'postgresql',
1247
+ newlineBeforeSemicolon: false,
1248
+ });
1249
+ await this.fileSystem.writeFileContent((0, path_1.resolve)(migrationFilePath, 'migration.sql'), formattedSQL);
1250
+ // Update hedhog.json hash/migration entry
1251
+ const hedhogFilePath = (0, path_1.join)(cwd, 'hedhog.json');
1252
+ let hedhogFile = {};
1253
+ if (await this.fileSystem.exists(hedhogFilePath)) {
1254
+ hedhogFile = await this.fileSystem.readJsonFile(hedhogFilePath);
1255
+ }
1256
+ if (!hedhogFile.libraries)
1257
+ hedhogFile.libraries = {};
1258
+ // Hash the NEW version (still in .hedhog-update/) — hedhog.service.ts will
1259
+ // replace the library folder AFTER this method returns.
1260
+ const hashLib = await this.developer.hashDirectory(newLibraryPath);
1261
+ hedhogFile.libraries[name] = {
1262
+ hash: hashLib,
1263
+ migration: migrationFileName,
1264
+ };
1265
+ await this.fileSystem.writeJsonFile(hedhogFilePath, hedhogFile);
1266
+ this.log(`Update migration file ${migrationFileName} created successfully.`);
1267
+ }
1268
+ // ── ALTER TABLE SQL generation ────────────────────────────────────────────
1269
+ generateAlterTableSQL(changes, approvedDrops, enumRegistry, dbType, cwd) {
1270
+ if (dbType !== 'postgres')
1271
+ return [];
1272
+ const sql = [];
1273
+ const t = changes.tableName;
1274
+ // RENAMES (before any other operation on those columns)
1275
+ for (const rename of changes.renamedColumns.filter((r) => r.isRename)) {
1276
+ sql.push(`-- RenameColumn`, `ALTER TABLE "${t}" RENAME COLUMN "${rename.oldName}" TO "${rename.newName}";`, '');
1277
+ // If type/nullable/default also changed, apply those after rename
1278
+ const change = changes.changedColumns.find((c) => c.column.name === rename.newName);
1279
+ if (change) {
1280
+ sql.push(...this.generateColumnAlterSQL(t, change, enumRegistry));
1281
+ }
1282
+ }
1283
+ const renamedNewNames = new Set(changes.renamedColumns.filter((r) => r.isRename).map((r) => r.newName));
1284
+ // ADD COLUMNS
1285
+ for (const col of changes.addedColumns) {
1286
+ const typeDef = this.getColumnTypeDef(col, t, enumRegistry);
1287
+ const nullable = col.isNullable ? 'NULL' : 'NOT NULL';
1288
+ const defaultClause = col.default !== undefined
1289
+ ? ` DEFAULT ${this.getDefaultValue(col)}`
1290
+ : '';
1291
+ sql.push(`-- AddColumn`, `ALTER TABLE "${t}" ADD COLUMN "${col.name}" ${typeDef} ${nullable}${defaultClause};`, '');
1292
+ }
1293
+ // DROP COLUMNS (approved only)
1294
+ for (const col of approvedDrops) {
1295
+ sql.push(`-- DropColumn`, `ALTER TABLE "${t}" DROP COLUMN "${col.name}";`, '');
1296
+ }
1297
+ // CHANGED COLUMNS (skip ones that were part of a rename — already handled)
1298
+ for (const change of changes.changedColumns) {
1299
+ if (renamedNewNames.has(change.column.name))
1300
+ continue;
1301
+ sql.push(...this.generateColumnAlterSQL(t, change, enumRegistry));
1302
+ }
1303
+ // NEW UNIQUE CONSTRAINTS
1304
+ for (const colName of changes.addedUniqueConstraints) {
1305
+ sql.push(`-- CreateUniqueIndex`, `CREATE UNIQUE INDEX IF NOT EXISTS "${t}_${colName}_unique" ON "${t}" ("${colName}");`, '');
1306
+ }
1307
+ // DROPPED UNIQUE CONSTRAINTS
1308
+ for (const colName of changes.removedUniqueConstraints) {
1309
+ sql.push(`-- DropUniqueIndex`, `DROP INDEX IF EXISTS "${t}_${colName}_unique";`, '');
1310
+ }
1311
+ // NEW FOREIGN KEYS
1312
+ for (const col of changes.addedForeignKeys) {
1313
+ const { table: fkTable, column: fkCol, onDelete, onUpdate, } = col.references;
1314
+ 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'};`, '');
1315
+ }
1316
+ // DROPPED FOREIGN KEYS
1317
+ for (const col of changes.removedForeignKeys) {
1318
+ const { table: fkTable } = col.references;
1319
+ sql.push(`-- DropForeignKey`, `ALTER TABLE "${t}" DROP CONSTRAINT "${fkTable}_${col.name}_fkey";`, '');
1320
+ }
1321
+ // ENUM VALUE CHANGES
1322
+ for (const enumChange of changes.enumValueChanges) {
1323
+ const enumName = this.getEnumName(t, enumChange.columnName);
1324
+ for (const val of enumChange.addedValues) {
1325
+ sql.push(`-- AddEnumValue`, `ALTER TYPE "${enumName}" ADD VALUE IF NOT EXISTS '${this.escapeSqlLiteral(val)}';`, '');
1326
+ }
1327
+ for (const val of enumChange.removedValues) {
1328
+ 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).`, '');
1329
+ }
1330
+ }
1331
+ return sql;
1332
+ }
1333
+ generateColumnAlterSQL(tableName, change, enumRegistry) {
1334
+ const sql = [];
1335
+ const col = change.column;
1336
+ const colName = col.name;
1337
+ if (change.oldType !== change.newType ||
1338
+ change.oldLength !== change.newLength) {
1339
+ const newTypeDef = this.getColumnTypeDef(col, tableName, enumRegistry);
1340
+ sql.push(`-- AlterColumnType`, `ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" TYPE ${newTypeDef} USING "${colName}"::TEXT::${newTypeDef};`, '');
1341
+ }
1342
+ if (change.oldNullable !== change.newNullable) {
1343
+ if (change.newNullable) {
1344
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" DROP NOT NULL;`, '');
1345
+ }
1346
+ else {
1347
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" SET NOT NULL;`, '');
1348
+ }
1349
+ }
1350
+ const oldDef = String(change.oldDefault ?? '');
1351
+ const newDef = String(change.newDefault ?? '');
1352
+ if (oldDef !== newDef) {
1353
+ if (change.newDefault === undefined || change.newDefault === null) {
1354
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" DROP DEFAULT;`, '');
1355
+ }
1356
+ else {
1357
+ sql.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${colName}" SET DEFAULT ${this.getDefaultValue(col)};`, '');
1358
+ }
1359
+ }
1360
+ return sql;
1361
+ }
1362
+ getColumnTypeDef(col, tableName, enumRegistry) {
1363
+ switch (col.type.toLowerCase()) {
1364
+ case 'pk':
1365
+ return 'SERIAL';
1366
+ case 'fk':
1367
+ case 'int':
1368
+ case 'order':
1369
+ return 'INTEGER';
1370
+ case 'slug':
1371
+ case 'varchar':
1372
+ return `VARCHAR(${col.length || 255})`;
1373
+ case 'created_at':
1374
+ case 'updated_at':
1375
+ case 'datetime':
1376
+ return 'TIMESTAMPTZ';
1377
+ case 'locale_varchar':
1378
+ return `VARCHAR(${col.length || 255})`;
1379
+ case 'locale_text':
1380
+ return 'TEXT';
1381
+ case 'char':
1382
+ return `CHAR(${col.length || 1})`;
1383
+ case 'enum': {
1384
+ const enumName = this.getEnumName(tableName, col.name);
1385
+ return `"${enumName}"`;
1386
+ }
1387
+ default:
1388
+ return col.type.toUpperCase();
1389
+ }
1390
+ }
1391
+ // ── Data upsert SQL generation ────────────────────────────────────────────
1392
+ async generateDataUpdateSQL(dataDiff, allTables, approvedDeletesMap, approvedFileDeletesMap, approvedRelationDeletesMap, cwd) {
1393
+ const sql = [];
1394
+ // Entirely new data files → all rows as upserts
1395
+ for (const fc of dataDiff.fileChanges) {
1396
+ if (fc.isNew) {
1397
+ const tableDef = allTables.find((t) => t.name === fc.tableName);
1398
+ if (!tableDef)
1399
+ continue;
1400
+ const keyCol = this.diffService.findIdentityKey(tableDef);
1401
+ if (!keyCol)
1402
+ continue;
1403
+ sql.push(`-- UpsertData (new data file): ${fc.tableName}`);
1404
+ for (const row of fc.allRows) {
1405
+ const rowSql = this.generateRowUpsertSQL(row, tableDef, keyCol, allTables);
1406
+ sql.push(...rowSql);
1407
+ }
1408
+ sql.push('');
1409
+ }
1410
+ }
1411
+ // Entirely removed data files → DELETE if approved
1412
+ for (const fc of dataDiff.fileChanges) {
1413
+ if (fc.isRemoved) {
1414
+ const decision = approvedFileDeletesMap.get(fc.tableName);
1415
+ if (decision === 'delete') {
1416
+ const tableDef = allTables.find((t) => t.name === fc.tableName);
1417
+ const keyCol = tableDef
1418
+ ? this.diffService.findIdentityKey(tableDef)
1419
+ : null;
1420
+ sql.push(`-- DeleteData (data file removed): ${fc.tableName}`);
1421
+ if (Array.isArray(keyCol)) {
1422
+ sql.push(`-- WARNING: composite key — DELETE skipped for "${fc.tableName}". Manual action may be required.`);
1423
+ }
1424
+ else if (keyCol) {
1425
+ for (const row of fc.allRows) {
1426
+ const keyVal = row[keyCol];
1427
+ if (keyVal !== null && keyVal !== undefined) {
1428
+ sql.push(`DELETE FROM "${fc.tableName}" WHERE "${keyCol}" = $$${String(keyVal).replace(/\$\$/g, "''")}$$;`);
1429
+ }
1430
+ }
1431
+ }
1432
+ else {
1433
+ sql.push(`-- WARNING: Could not determine identity key for "${fc.tableName}". DELETE skipped.`);
1434
+ }
1435
+ sql.push('');
1436
+ }
1437
+ else {
1438
+ sql.push(`-- INFO: Data file for "${fc.tableName}" was removed in the new version. Existing rows preserved.`, '');
1439
+ }
1440
+ }
1441
+ }
1442
+ // Row-level changes
1443
+ for (const change of dataDiff.changes) {
1444
+ const tableDef = allTables.find((t) => t.name === change.tableName);
1445
+ if (!tableDef)
1446
+ continue;
1447
+ // Inserted rows
1448
+ if (change.insertedRows.length > 0) {
1449
+ sql.push(`-- InsertData: ${change.tableName}`);
1450
+ for (const row of change.insertedRows) {
1451
+ sql.push(...this.generateRowUpsertSQL(row, tableDef, change.keyColumn, allTables));
1452
+ }
1453
+ sql.push('');
1454
+ }
1455
+ // Updated rows
1456
+ if (change.updatedRows.length > 0) {
1457
+ sql.push(`-- UpdateData: ${change.tableName}`);
1458
+ for (const row of change.updatedRows) {
1459
+ sql.push(...this.generateRowUpsertSQL(row, tableDef, change.keyColumn, allTables));
1460
+ }
1461
+ sql.push('');
1462
+ }
1463
+ // Deleted rows (approved)
1464
+ const approvedDeletes = approvedDeletesMap.get(change.tableName) ?? [];
1465
+ if (approvedDeletes.length > 0) {
1466
+ sql.push(`-- DeleteData: ${change.tableName}`);
1467
+ if (Array.isArray(change.keyColumn)) {
1468
+ sql.push(`-- WARNING: composite key — DELETE skipped for "${change.tableName}". Manual action may be required.`);
1469
+ }
1470
+ else {
1471
+ for (const row of approvedDeletes) {
1472
+ const keyVal = row[change.keyColumn];
1473
+ sql.push(`DELETE FROM "${change.tableName}" WHERE "${change.keyColumn}" = $$${String(keyVal).replace(/\$\$/g, "''")}$$;`);
1474
+ }
1475
+ }
1476
+ sql.push('');
1477
+ }
1478
+ // Relation changes
1479
+ for (const relChange of change.relationChanges) {
1480
+ if (Array.isArray(change.keyColumn))
1481
+ continue; // composite-key tables don't have nested relations
1482
+ const mapKey = `${change.tableName}::${relChange.targetTable}`;
1483
+ const approvedRelDeletes = approvedRelationDeletesMap.get(mapKey) ?? [];
1484
+ const relSql = await this.generateRelationUpdateSQL(change.tableName, change.keyColumn, relChange.parentKeyValue, relChange.targetTable, relChange.addedRelations, relChange.removedRelations, approvedRelDeletes, allTables, cwd);
1485
+ sql.push(...relSql);
1486
+ }
1487
+ }
1488
+ return sql;
1489
+ }
1490
+ generateRowUpsertSQL(row, tableDef, keyColumn, allTables) {
1491
+ const sql = [];
1492
+ const keyCols = Array.isArray(keyColumn) ? keyColumn : [keyColumn];
1493
+ const keyColsQuoted = new Set(keyCols.map((k) => `"${k}"`));
1494
+ const scalarCols = [];
1495
+ const scalarVals = [];
1496
+ const localeEntries = [];
1497
+ for (const [key, value] of Object.entries(row)) {
1498
+ if (key === 'relations')
1499
+ continue;
1500
+ if (this.checkIsObjectLocale(value)) {
1501
+ localeEntries.push({ name: key, values: value });
1502
+ }
1503
+ else if (tableDef.columns.some((c) => c.name === key)) {
1504
+ scalarCols.push(`"${key}"`);
1505
+ scalarVals.push(this.resolveRawValue(value, key, tableDef, allTables));
1506
+ }
1507
+ }
1508
+ if (scalarCols.length > 0) {
1509
+ if (keyCols.length > 1) {
1510
+ // Use WHERE NOT EXISTS — safe even without a DB-level unique constraint
1511
+ const whereConditions = keyCols
1512
+ .map((k) => {
1513
+ const idx = scalarCols.indexOf(`"${k}"`);
1514
+ const val = idx >= 0 ? scalarVals[idx] : 'NULL';
1515
+ return `"${k}" = ${val}`;
1516
+ })
1517
+ .join(' AND ');
1518
+ sql.push(`INSERT INTO "${tableDef.name}" (${scalarCols.join(', ')}) SELECT ${scalarVals.join(', ')} WHERE NOT EXISTS (SELECT 1 FROM "${tableDef.name}" WHERE ${whereConditions});`);
1519
+ }
1520
+ else {
1521
+ const conflictTarget = `"${keyCols[0]}"`;
1522
+ const setClauses = scalarCols
1523
+ .filter((c) => !keyColsQuoted.has(c))
1524
+ .map((c) => `${c} = EXCLUDED.${c}`)
1525
+ .join(', ');
1526
+ const doConflict = setClauses
1527
+ ? `ON CONFLICT (${conflictTarget}) DO UPDATE SET ${setClauses}`
1528
+ : `ON CONFLICT (${conflictTarget}) DO NOTHING`;
1529
+ sql.push(`INSERT INTO "${tableDef.name}" (${scalarCols.join(', ')}) VALUES (${scalarVals.join(', ')}) ${doConflict};`);
1530
+ }
1531
+ }
1532
+ // Locale upserts — only applicable when key is a single column
1533
+ const localeTableDef = allTables.find((t) => t.name === `${tableDef.name}_locale`);
1534
+ if (localeTableDef &&
1535
+ localeEntries.length > 0 &&
1536
+ !Array.isArray(keyColumn)) {
1537
+ const fkColName = localeTableDef.columns.find((c) => c.type === 'fk' && c.references?.table === tableDef.name)?.name;
1538
+ const fkLocaleColName = localeTableDef.columns.find((c) => c.type === 'fk' && c.references?.table === 'locale')?.name;
1539
+ if (fkColName && fkLocaleColName) {
1540
+ const keyVal = row[keyColumn];
1541
+ const parentKeySQL = this.resolveRawValue(keyVal, keyColumn, tableDef, allTables);
1542
+ const parentSelect = `(SELECT "${tableDef.pk}" FROM "${tableDef.name}" WHERE "${keyColumn}" = ${parentKeySQL})`;
1543
+ const localeCodes = new Set();
1544
+ for (const entry of localeEntries) {
1545
+ for (const code of Object.keys(entry.values))
1546
+ localeCodes.add(code);
1547
+ }
1548
+ for (const code of localeCodes) {
1549
+ const localeCols2 = [];
1550
+ const localeVals = [];
1551
+ for (const entry of localeEntries) {
1552
+ if (entry.values[code] !== undefined) {
1553
+ localeCols2.push(`"${entry.name}"`);
1554
+ localeVals.push(`$$${String(entry.values[code]).replace(/\$\$/g, "''")}$$`);
1555
+ }
1556
+ }
1557
+ if (localeCols2.length > 0) {
1558
+ const insertCols = [
1559
+ `"${fkLocaleColName}"`,
1560
+ `"${fkColName}"`,
1561
+ ...localeCols2,
1562
+ ];
1563
+ const insertVals = [
1564
+ `(SELECT "id" FROM "locale" WHERE "code" = '${code}')`,
1565
+ parentSelect,
1566
+ ...localeVals,
1567
+ ];
1568
+ const setClauses2 = localeCols2
1569
+ .map((c) => `${c} = EXCLUDED.${c}`)
1570
+ .join(', ');
1571
+ sql.push(`INSERT INTO "${tableDef.name}_locale" (${insertCols.join(', ')}) VALUES (${insertVals.join(', ')}) ON CONFLICT ("${fkLocaleColName}", "${fkColName}") DO UPDATE SET ${setClauses2};`);
1572
+ }
1573
+ }
1574
+ }
1575
+ }
1576
+ return sql;
1577
+ }
1578
+ resolveRawValue(value, colName, tableDef, allTables) {
1579
+ if (value === null || value === undefined)
1580
+ return 'NULL';
1581
+ if (typeof value === 'boolean')
1582
+ return value ? 'TRUE' : 'FALSE';
1583
+ if (typeof value === 'number')
1584
+ return String(value);
1585
+ if (typeof value === 'object' && 'where' in value) {
1586
+ const colDef = tableDef.columns.find((c) => c.name === colName);
1587
+ const refTableName = colDef?.references?.table;
1588
+ if (!refTableName) {
1589
+ console.warn(chalk.yellow(`[update] resolveRawValue: could not resolve FK table for column "${colName}" in "${tableDef.name}". Substituting NULL.`));
1590
+ return 'NULL';
1591
+ }
1592
+ const refTableDef = allTables.find((t) => t.name === refTableName);
1593
+ const refPk = refTableDef?.pk ?? 'id';
1594
+ const whereClause = this.getWhereString(value);
1595
+ return `(SELECT "${refPk}" FROM "${refTableName}" ${whereClause})`;
1596
+ }
1597
+ return `$$${String(value).replace(/\$\$/g, "''")}$$`;
1598
+ }
1599
+ async generateRelationUpdateSQL(parentTable, parentKeyColumn, parentKeyValue, targetTable, addedRelations, removedRelations, approvedRemovals, allTables, cwd) {
1600
+ const sql = [];
1601
+ const junctionResult = await this.tableService.getTableIntermediate(parentTable, targetTable, cwd);
1602
+ if (!junctionResult) {
1603
+ sql.push(`-- WARNING: Could not resolve junction table for relation "${parentTable}" ↔ "${targetTable}". Relation changes skipped.`, '');
1604
+ return sql;
1605
+ }
1606
+ const targetTableDef = allTables.find((t) => t.name === targetTable);
1607
+ const targetPk = targetTableDef?.pk ?? 'id';
1608
+ const parentTableDef = allTables.find((t) => t.name === parentTable);
1609
+ const parentPk = parentTableDef?.pk ?? 'id';
1610
+ const parentKeySQL = `(SELECT "${parentPk}" FROM "${parentTable}" WHERE "${parentKeyColumn}" = $$${String(parentKeyValue).replace(/\$\$/g, "''")}$$)`;
1611
+ if (junctionResult.intermediateTable) {
1612
+ const junctionName = junctionResult.name;
1613
+ const fkForParent = junctionResult.table.columns.find((c) => c.type === 'fk' && c.references?.table === parentTable)?.name;
1614
+ const fkForTarget = junctionResult.table.columns.find((c) => c.type === 'fk' && c.references?.table === targetTable)?.name;
1615
+ if (!fkForParent || !fkForTarget)
1616
+ return sql;
1617
+ for (const rel of addedRelations) {
1618
+ const whereClause = this.getWhereString(rel);
1619
+ const targetSQL = `(SELECT "${targetPk}" FROM "${targetTable}" ${whereClause})`;
1620
+ sql.push(`-- AddRelation: ${parentTable} ↔ ${targetTable}`, `INSERT INTO "${junctionName}" ("${fkForParent}", "${fkForTarget}") VALUES (${parentKeySQL}, ${targetSQL}) ON CONFLICT DO NOTHING;`, '');
1621
+ }
1622
+ for (const rel of removedRelations) {
1623
+ const isApproved = approvedRemovals.some((a) => a.parentKeyValue === parentKeyValue &&
1624
+ JSON.stringify(a.relation) === JSON.stringify(rel));
1625
+ if (!isApproved)
1626
+ continue;
1627
+ const whereClause = this.getWhereString(rel);
1628
+ const targetSQL = `(SELECT "${targetPk}" FROM "${targetTable}" ${whereClause})`;
1629
+ sql.push(`-- RemoveRelation: ${parentTable} ↔ ${targetTable}`, `DELETE FROM "${junctionName}" WHERE "${fkForParent}" = ${parentKeySQL} AND "${fkForTarget}" = ${targetSQL};`, '');
1630
+ }
1631
+ }
1632
+ else {
1633
+ // Direct child relation
1634
+ const childTable = junctionResult.name;
1635
+ const fkForParent = junctionResult.table.columns.find((c) => c.type === 'fk' && c.references?.table === parentTable)?.name;
1636
+ if (!fkForParent)
1637
+ return sql;
1638
+ for (const rel of addedRelations) {
1639
+ const whereClause = this.getWhereString(rel);
1640
+ sql.push(`-- AddRelation (child): ${childTable}`, `INSERT INTO "${childTable}" ("${fkForParent}") SELECT ${parentKeySQL} WHERE EXISTS (SELECT 1 FROM "${targetTable}" ${whereClause}) ON CONFLICT DO NOTHING;`, '');
1641
+ }
1642
+ for (const rel of removedRelations) {
1643
+ const isApproved = approvedRemovals.some((a) => a.parentKeyValue === parentKeyValue &&
1644
+ JSON.stringify(a.relation) === JSON.stringify(rel));
1645
+ if (!isApproved)
1646
+ continue;
1647
+ const whereClause = this.getWhereString(rel);
1648
+ sql.push(`-- RemoveRelation (child): ${childTable}`, `DELETE FROM "${childTable}" WHERE "${fkForParent}" = ${parentKeySQL} AND EXISTS (SELECT 1 FROM "${targetTable}" ${whereClause});`, '');
1649
+ }
1650
+ }
1651
+ return sql;
1054
1652
  }
1055
1653
  };
1056
1654
  exports.MigrationService = MigrationService;
@@ -1059,6 +1657,7 @@ exports.MigrationService = MigrationService = __decorate([
1059
1657
  __metadata("design:paramtypes", [database_service_1.DatabaseService,
1060
1658
  developer_service_1.DeveloperService,
1061
1659
  file_system_service_1.FileSystemService,
1062
- table_service_1.TableService])
1660
+ table_service_1.TableService,
1661
+ diff_service_1.DiffService])
1063
1662
  ], MigrationService);
1064
1663
  //# sourceMappingURL=migration.service.js.map