@hed-hog/cli 0.0.95 → 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.
@@ -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}`));
@@ -1052,6 +1113,538 @@ let MigrationService = class MigrationService {
1052
1113
  await this.fileSystem.writeJsonFile(hedhogFilePath, hedhogFile);
1053
1114
  this.log(`Migration file ${migrationFileName} created successfully.`);
1054
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
+ }
1055
1648
  };
1056
1649
  exports.MigrationService = MigrationService;
1057
1650
  exports.MigrationService = MigrationService = __decorate([
@@ -1059,6 +1652,7 @@ exports.MigrationService = MigrationService = __decorate([
1059
1652
  __metadata("design:paramtypes", [database_service_1.DatabaseService,
1060
1653
  developer_service_1.DeveloperService,
1061
1654
  file_system_service_1.FileSystemService,
1062
- table_service_1.TableService])
1655
+ table_service_1.TableService,
1656
+ diff_service_1.DiffService])
1063
1657
  ], MigrationService);
1064
1658
  //# sourceMappingURL=migration.service.js.map