@prisma-next/target-postgres 0.3.0-dev.6 → 0.3.0-dev.64

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 (46) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +8 -1
  3. package/dist/control.d.mts +16 -0
  4. package/dist/control.d.mts.map +1 -0
  5. package/dist/control.mjs +2947 -0
  6. package/dist/control.mjs.map +1 -0
  7. package/dist/descriptor-meta-DxB8oZzB.mjs +13 -0
  8. package/dist/descriptor-meta-DxB8oZzB.mjs.map +1 -0
  9. package/dist/pack.d.mts +7 -0
  10. package/dist/pack.d.mts.map +1 -0
  11. package/dist/pack.mjs +9 -0
  12. package/dist/pack.mjs.map +1 -0
  13. package/dist/runtime.d.mts +9 -0
  14. package/dist/runtime.d.mts.map +1 -0
  15. package/dist/runtime.mjs +21 -0
  16. package/dist/runtime.mjs.map +1 -0
  17. package/package.json +32 -32
  18. package/src/core/migrations/planner-reconciliation.ts +602 -0
  19. package/src/core/migrations/planner.ts +476 -215
  20. package/src/core/migrations/runner.ts +29 -34
  21. package/src/core/migrations/statement-builders.ts +9 -7
  22. package/src/core/types.ts +5 -0
  23. package/src/exports/control.ts +9 -8
  24. package/src/exports/runtime.ts +7 -12
  25. package/dist/chunk-RKEXRSSI.js +0 -14
  26. package/dist/chunk-RKEXRSSI.js.map +0 -1
  27. package/dist/core/descriptor-meta.d.ts +0 -9
  28. package/dist/core/descriptor-meta.d.ts.map +0 -1
  29. package/dist/core/migrations/planner.d.ts +0 -14
  30. package/dist/core/migrations/planner.d.ts.map +0 -1
  31. package/dist/core/migrations/runner.d.ts +0 -8
  32. package/dist/core/migrations/runner.d.ts.map +0 -1
  33. package/dist/core/migrations/statement-builders.d.ts +0 -30
  34. package/dist/core/migrations/statement-builders.d.ts.map +0 -1
  35. package/dist/exports/control.d.ts +0 -8
  36. package/dist/exports/control.d.ts.map +0 -1
  37. package/dist/exports/control.js +0 -1255
  38. package/dist/exports/control.js.map +0 -1
  39. package/dist/exports/pack.d.ts +0 -4
  40. package/dist/exports/pack.d.ts.map +0 -1
  41. package/dist/exports/pack.js +0 -11
  42. package/dist/exports/pack.js.map +0 -1
  43. package/dist/exports/runtime.d.ts +0 -12
  44. package/dist/exports/runtime.d.ts.map +0 -1
  45. package/dist/exports/runtime.js +0 -19
  46. package/dist/exports/runtime.js.map +0 -1
package/package.json CHANGED
@@ -1,28 +1,31 @@
1
1
  {
2
2
  "name": "@prisma-next/target-postgres",
3
- "version": "0.3.0-dev.6",
3
+ "version": "0.3.0-dev.64",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Postgres target pack for Prisma Next",
7
7
  "dependencies": {
8
8
  "arktype": "^2.0.0",
9
- "@prisma-next/cli": "0.3.0-dev.6",
10
- "@prisma-next/contract": "0.3.0-dev.6",
11
- "@prisma-next/core-control-plane": "0.3.0-dev.6",
12
- "@prisma-next/core-execution-plane": "0.3.0-dev.6",
13
- "@prisma-next/family-sql": "0.3.0-dev.6",
14
- "@prisma-next/sql-errors": "0.3.0-dev.6",
15
- "@prisma-next/sql-schema-ir": "0.3.0-dev.6",
16
- "@prisma-next/sql-contract": "0.3.0-dev.6",
17
- "@prisma-next/utils": "0.3.0-dev.6"
9
+ "@prisma-next/cli": "0.3.0-dev.64",
10
+ "@prisma-next/contract": "0.3.0-dev.64",
11
+ "@prisma-next/core-control-plane": "0.3.0-dev.64",
12
+ "@prisma-next/core-execution-plane": "0.3.0-dev.64",
13
+ "@prisma-next/family-sql": "0.3.0-dev.64",
14
+ "@prisma-next/sql-contract": "0.3.0-dev.64",
15
+ "@prisma-next/sql-relational-core": "0.3.0-dev.64",
16
+ "@prisma-next/sql-runtime": "0.3.0-dev.64",
17
+ "@prisma-next/sql-errors": "0.3.0-dev.64",
18
+ "@prisma-next/sql-schema-ir": "0.3.0-dev.64",
19
+ "@prisma-next/utils": "0.3.0-dev.64"
18
20
  },
19
21
  "devDependencies": {
20
- "@vitest/coverage-v8": "4.0.16",
21
- "tsup": "8.5.1",
22
+ "tsdown": "0.18.4",
22
23
  "typescript": "5.9.3",
23
- "vitest": "4.0.16",
24
- "@prisma-next/adapter-postgres": "0.3.0-dev.6",
25
- "@prisma-next/driver-postgres": "0.3.0-dev.6",
24
+ "vitest": "4.0.17",
25
+ "@prisma-next/adapter-postgres": "0.3.0-dev.64",
26
+ "@prisma-next/driver-postgres": "0.3.0-dev.64",
27
+ "@prisma-next/tsconfig": "0.0.0",
28
+ "@prisma-next/tsdown": "0.0.0",
26
29
  "@prisma-next/test-utils": "0.0.1"
27
30
  },
28
31
  "files": [
@@ -31,27 +34,24 @@
31
34
  "packs"
32
35
  ],
33
36
  "exports": {
34
- "./control": {
35
- "types": "./dist/exports/control.d.ts",
36
- "import": "./dist/exports/control.js"
37
- },
38
- "./runtime": {
39
- "types": "./dist/exports/runtime.d.ts",
40
- "import": "./dist/exports/runtime.js"
41
- },
42
- "./pack": {
43
- "types": "./dist/exports/pack.d.ts",
44
- "import": "./dist/exports/pack.js"
45
- }
37
+ "./control": "./dist/control.mjs",
38
+ "./pack": "./dist/pack.mjs",
39
+ "./runtime": "./dist/runtime.mjs",
40
+ "./package.json": "./package.json"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/prisma/prisma-next.git",
45
+ "directory": "packages/3-targets/3-targets/postgres"
46
46
  },
47
47
  "scripts": {
48
- "build": "tsup --config tsup.config.ts && tsc --project tsconfig.build.json",
48
+ "build": "tsdown",
49
49
  "test": "vitest run --passWithNoTests",
50
50
  "test:coverage": "vitest run --coverage --passWithNoTests",
51
51
  "typecheck": "tsc --project tsconfig.json --noEmit",
52
- "lint": "biome check . --config-path ../../../../biome.json --error-on-warnings",
53
- "lint:fix": "biome check --write . --config-path ../../../biome.json",
54
- "lint:fix:unsafe": "biome check --write --unsafe . --config-path ../../../biome.json",
55
- "clean": "node ../../../../scripts/clean.mjs"
52
+ "lint": "biome check . --error-on-warnings",
53
+ "lint:fix": "biome check --write .",
54
+ "lint:fix:unsafe": "biome check --write --unsafe .",
55
+ "clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output"
56
56
  }
57
57
  }
@@ -0,0 +1,602 @@
1
+ import { quoteIdentifier } from '@prisma-next/adapter-postgres/control';
2
+ import type { SchemaIssue } from '@prisma-next/core-control-plane/types';
3
+ import type {
4
+ MigrationOperationPolicy,
5
+ SqlMigrationPlanOperation,
6
+ SqlPlannerConflict,
7
+ } from '@prisma-next/family-sql/control';
8
+ import type { SqlContract, SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types';
9
+ import { ifDefined } from '@prisma-next/utils/defined';
10
+ import type { PlanningMode, PostgresPlanTargetDetails } from './planner';
11
+ import {
12
+ buildColumnTypeSql,
13
+ buildTargetDetails,
14
+ columnExistsCheck,
15
+ columnNullabilityCheck,
16
+ constraintExistsCheck,
17
+ qualifyTableName,
18
+ toRegclassLiteral,
19
+ } from './planner';
20
+
21
+ // ============================================================================
22
+ // Public API
23
+ // ============================================================================
24
+
25
+ export function buildReconciliationPlan(options: {
26
+ readonly contract: SqlContract<SqlStorage>;
27
+ readonly issues: readonly SchemaIssue[];
28
+ readonly schemaName: string;
29
+ readonly mode: PlanningMode;
30
+ readonly policy: MigrationOperationPolicy;
31
+ }): {
32
+ readonly operations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
33
+ readonly conflicts: readonly SqlPlannerConflict[];
34
+ } {
35
+ const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
36
+ const conflicts: SqlPlannerConflict[] = [];
37
+ const { mode } = options;
38
+ const seenOperationIds = new Set<string>();
39
+
40
+ for (const issue of sortSchemaIssues(options.issues)) {
41
+ if (isAdditiveIssue(issue)) {
42
+ continue;
43
+ }
44
+
45
+ const operation = buildReconciliationOperationFromIssue({
46
+ issue,
47
+ contract: options.contract,
48
+ schemaName: options.schemaName,
49
+ mode,
50
+ });
51
+
52
+ if (operation) {
53
+ // Skip duplicates: different schema issues may produce the same operation id
54
+ // (e.g., extra_unique_constraint and extra_index on the same object).
55
+ if (!seenOperationIds.has(operation.id)) {
56
+ seenOperationIds.add(operation.id);
57
+ if (options.policy.allowedOperationClasses.includes(operation.operationClass)) {
58
+ operations.push(operation);
59
+ } else {
60
+ const conflict = convertIssueToConflict(issue);
61
+ if (conflict) {
62
+ conflicts.push(conflict);
63
+ }
64
+ }
65
+ }
66
+ } else {
67
+ const conflict = convertIssueToConflict(issue);
68
+ if (conflict) {
69
+ conflicts.push(conflict);
70
+ }
71
+ }
72
+ }
73
+
74
+ return {
75
+ operations,
76
+ conflicts: conflicts.sort(conflictComparator),
77
+ };
78
+ }
79
+
80
+ // ============================================================================
81
+ // Issue Classification
82
+ // ============================================================================
83
+
84
+ function isAdditiveIssue(issue: SchemaIssue): boolean {
85
+ switch (issue.kind) {
86
+ case 'type_missing':
87
+ case 'type_values_mismatch':
88
+ case 'missing_table':
89
+ case 'missing_column':
90
+ case 'extension_missing':
91
+ return true;
92
+ case 'primary_key_mismatch':
93
+ return issue.actual === undefined;
94
+ case 'unique_constraint_mismatch':
95
+ case 'index_mismatch':
96
+ case 'foreign_key_mismatch':
97
+ return issue.indexOrConstraint === undefined;
98
+ default:
99
+ return false;
100
+ }
101
+ }
102
+
103
+ // ============================================================================
104
+ // Operation Builders
105
+ // ============================================================================
106
+
107
+ function buildReconciliationOperationFromIssue(options: {
108
+ readonly issue: SchemaIssue;
109
+ readonly contract: SqlContract<SqlStorage>;
110
+ readonly schemaName: string;
111
+ readonly mode: PlanningMode;
112
+ }): SqlMigrationPlanOperation<PostgresPlanTargetDetails> | null {
113
+ const { issue, contract, schemaName, mode } = options;
114
+ switch (issue.kind) {
115
+ case 'extra_table':
116
+ if (!mode.allowDestructive || !issue.table) {
117
+ return null;
118
+ }
119
+ return buildDropTableOperation(schemaName, issue.table);
120
+
121
+ case 'extra_column':
122
+ if (!mode.allowDestructive || !issue.table || !issue.column) {
123
+ return null;
124
+ }
125
+ return buildDropColumnOperation(schemaName, issue.table, issue.column);
126
+
127
+ case 'extra_index':
128
+ if (!mode.allowDestructive || !issue.table || !issue.indexOrConstraint) {
129
+ return null;
130
+ }
131
+ return buildDropIndexOperation(schemaName, issue.table, issue.indexOrConstraint);
132
+
133
+ case 'extra_foreign_key':
134
+ case 'extra_unique_constraint': {
135
+ if (!mode.allowDestructive || !issue.table || !issue.indexOrConstraint) {
136
+ return null;
137
+ }
138
+ const constraintKind = issue.kind === 'extra_foreign_key' ? 'foreignKey' : 'unique';
139
+ return buildDropConstraintOperation(
140
+ schemaName,
141
+ issue.table,
142
+ issue.indexOrConstraint,
143
+ constraintKind,
144
+ );
145
+ }
146
+
147
+ case 'extra_primary_key': {
148
+ if (!mode.allowDestructive || !issue.table) {
149
+ return null;
150
+ }
151
+ const constraintName = issue.indexOrConstraint ?? `${issue.table}_pkey`;
152
+ return buildDropConstraintOperation(schemaName, issue.table, constraintName, 'primaryKey');
153
+ }
154
+
155
+ case 'nullability_mismatch': {
156
+ if (!issue.table || !issue.column) {
157
+ return null;
158
+ }
159
+ if (issue.expected === 'true') {
160
+ // Contract wants nullable, DB has NOT NULL → widening
161
+ return mode.allowWidening
162
+ ? buildDropNotNullOperation(schemaName, issue.table, issue.column)
163
+ : null;
164
+ }
165
+ // Contract wants NOT NULL, DB has nullable → destructive
166
+ return mode.allowDestructive
167
+ ? buildSetNotNullOperation(schemaName, issue.table, issue.column)
168
+ : null;
169
+ }
170
+
171
+ case 'type_mismatch': {
172
+ if (!mode.allowDestructive || !issue.table || !issue.column) {
173
+ return null;
174
+ }
175
+ const contractColumn = getContractColumn(contract, issue.table, issue.column);
176
+ if (!contractColumn) {
177
+ return null;
178
+ }
179
+ return buildAlterColumnTypeOperation(schemaName, issue.table, issue.column, contractColumn);
180
+ }
181
+
182
+ // Remaining issue kinds (default_missing, default_mismatch, primary_key_mismatch,
183
+ // unique_constraint_mismatch, index_mismatch, foreign_key_mismatch) do not yet have
184
+ // reconciliation operation builders. They fall through to the caller, which converts them to
185
+ // conflicts via convertIssueToConflict. When a new SchemaIssue kind is added, add a
186
+ // case here if the planner can emit an operation for it; otherwise it becomes a conflict.
187
+ default:
188
+ return null;
189
+ }
190
+ }
191
+
192
+ function getContractColumn(
193
+ contract: SqlContract<SqlStorage>,
194
+ tableName: string,
195
+ columnName: string,
196
+ ): StorageColumn | null {
197
+ const table = contract.storage.tables[tableName];
198
+ if (!table) {
199
+ return null;
200
+ }
201
+ return table.columns[columnName] ?? null;
202
+ }
203
+
204
+ function buildDropTableOperation(
205
+ schemaName: string,
206
+ tableName: string,
207
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
208
+ return {
209
+ id: `dropTable.${tableName}`,
210
+ label: `Drop table ${tableName}`,
211
+ summary: `Drops extra table ${tableName}`,
212
+ operationClass: 'destructive',
213
+ target: {
214
+ id: 'postgres',
215
+ details: buildTargetDetails('table', tableName, schemaName),
216
+ },
217
+ precheck: [
218
+ {
219
+ description: `ensure table "${tableName}" exists`,
220
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, tableName)}) IS NOT NULL`,
221
+ },
222
+ ],
223
+ execute: [
224
+ {
225
+ description: `drop table "${tableName}"`,
226
+ sql: `DROP TABLE ${qualifyTableName(schemaName, tableName)}`,
227
+ },
228
+ ],
229
+ postcheck: [
230
+ {
231
+ description: `verify table "${tableName}" is removed`,
232
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, tableName)}) IS NULL`,
233
+ },
234
+ ],
235
+ };
236
+ }
237
+
238
+ function buildDropColumnOperation(
239
+ schemaName: string,
240
+ tableName: string,
241
+ columnName: string,
242
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
243
+ return {
244
+ id: `dropColumn.${tableName}.${columnName}`,
245
+ label: `Drop column ${columnName} from ${tableName}`,
246
+ summary: `Drops extra column ${columnName} from table ${tableName}`,
247
+ operationClass: 'destructive',
248
+ target: {
249
+ id: 'postgres',
250
+ details: buildTargetDetails('column', columnName, schemaName, tableName),
251
+ },
252
+ precheck: [
253
+ {
254
+ description: `ensure column "${columnName}" exists`,
255
+ sql: columnExistsCheck({ schema: schemaName, table: tableName, column: columnName }),
256
+ },
257
+ ],
258
+ execute: [
259
+ {
260
+ description: `drop column "${columnName}"`,
261
+ sql: `ALTER TABLE ${qualifyTableName(schemaName, tableName)} DROP COLUMN ${quoteIdentifier(columnName)}`,
262
+ },
263
+ ],
264
+ postcheck: [
265
+ {
266
+ description: `verify column "${columnName}" is removed`,
267
+ sql: columnExistsCheck({
268
+ schema: schemaName,
269
+ table: tableName,
270
+ column: columnName,
271
+ exists: false,
272
+ }),
273
+ },
274
+ ],
275
+ };
276
+ }
277
+
278
+ function buildDropIndexOperation(
279
+ schemaName: string,
280
+ tableName: string,
281
+ indexName: string,
282
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
283
+ return {
284
+ id: `dropIndex.${tableName}.${indexName}`,
285
+ label: `Drop index ${indexName} on ${tableName}`,
286
+ summary: `Drops extra index ${indexName} on table ${tableName}`,
287
+ operationClass: 'destructive',
288
+ target: {
289
+ id: 'postgres',
290
+ details: buildTargetDetails('index', indexName, schemaName, tableName),
291
+ },
292
+ precheck: [
293
+ {
294
+ description: `ensure index "${indexName}" exists`,
295
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NOT NULL`,
296
+ },
297
+ ],
298
+ execute: [
299
+ {
300
+ description: `drop index "${indexName}"`,
301
+ sql: `DROP INDEX ${qualifyTableName(schemaName, indexName)}`,
302
+ },
303
+ ],
304
+ postcheck: [
305
+ {
306
+ description: `verify index "${indexName}" is removed`,
307
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NULL`,
308
+ },
309
+ ],
310
+ };
311
+ }
312
+
313
+ function buildDropConstraintOperation(
314
+ schemaName: string,
315
+ tableName: string,
316
+ constraintName: string,
317
+ constraintKind: 'foreignKey' | 'unique' | 'primaryKey',
318
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
319
+ return {
320
+ id: `dropConstraint.${tableName}.${constraintName}`,
321
+ label: `Drop constraint ${constraintName} on ${tableName}`,
322
+ summary: `Drops extra constraint ${constraintName} on table ${tableName}`,
323
+ operationClass: 'destructive',
324
+ target: {
325
+ id: 'postgres',
326
+ details: buildTargetDetails(constraintKind, constraintName, schemaName, tableName),
327
+ },
328
+ precheck: [
329
+ {
330
+ description: `ensure constraint "${constraintName}" exists`,
331
+ sql: constraintExistsCheck({ constraintName, schema: schemaName }),
332
+ },
333
+ ],
334
+ execute: [
335
+ {
336
+ description: `drop constraint "${constraintName}"`,
337
+ sql: `ALTER TABLE ${qualifyTableName(schemaName, tableName)}
338
+ DROP CONSTRAINT ${quoteIdentifier(constraintName)}`,
339
+ },
340
+ ],
341
+ postcheck: [
342
+ {
343
+ description: `verify constraint "${constraintName}" is removed`,
344
+ sql: constraintExistsCheck({ constraintName, schema: schemaName, exists: false }),
345
+ },
346
+ ],
347
+ };
348
+ }
349
+
350
+ function buildDropNotNullOperation(
351
+ schemaName: string,
352
+ tableName: string,
353
+ columnName: string,
354
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
355
+ return {
356
+ id: `alterNullability.${tableName}.${columnName}`,
357
+ label: `Relax nullability for ${columnName} on ${tableName}`,
358
+ summary: `Drops NOT NULL constraint for ${columnName} on table ${tableName}`,
359
+ operationClass: 'widening',
360
+ target: {
361
+ id: 'postgres',
362
+ details: buildTargetDetails('column', columnName, schemaName, tableName),
363
+ },
364
+ precheck: [
365
+ {
366
+ description: `ensure column "${columnName}" exists`,
367
+ sql: columnExistsCheck({ schema: schemaName, table: tableName, column: columnName }),
368
+ },
369
+ ],
370
+ execute: [
371
+ {
372
+ description: `drop NOT NULL from "${columnName}"`,
373
+ sql: `ALTER TABLE ${qualifyTableName(schemaName, tableName)}
374
+ ALTER COLUMN ${quoteIdentifier(columnName)} DROP NOT NULL`,
375
+ },
376
+ ],
377
+ postcheck: [
378
+ {
379
+ description: `verify "${columnName}" is nullable`,
380
+ sql: columnNullabilityCheck({
381
+ schema: schemaName,
382
+ table: tableName,
383
+ column: columnName,
384
+ nullable: true,
385
+ }),
386
+ },
387
+ ],
388
+ };
389
+ }
390
+
391
+ function buildSetNotNullOperation(
392
+ schemaName: string,
393
+ tableName: string,
394
+ columnName: string,
395
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
396
+ const qualified = qualifyTableName(schemaName, tableName);
397
+ return {
398
+ id: `alterNullability.${tableName}.${columnName}`,
399
+ label: `Enforce NOT NULL for ${columnName} on ${tableName}`,
400
+ summary: `Sets NOT NULL on ${columnName} for table ${tableName}`,
401
+ operationClass: 'destructive',
402
+ target: {
403
+ id: 'postgres',
404
+ details: buildTargetDetails('column', columnName, schemaName, tableName),
405
+ },
406
+ precheck: [
407
+ {
408
+ description: `ensure column "${columnName}" exists`,
409
+ sql: columnExistsCheck({ schema: schemaName, table: tableName, column: columnName }),
410
+ },
411
+ {
412
+ description: `ensure "${columnName}" has no NULL values`,
413
+ sql: `SELECT NOT EXISTS (
414
+ SELECT 1 FROM ${qualified}
415
+ WHERE ${quoteIdentifier(columnName)} IS NULL
416
+ LIMIT 1
417
+ )`,
418
+ },
419
+ ],
420
+ execute: [
421
+ {
422
+ description: `set NOT NULL on "${columnName}"`,
423
+ sql: `ALTER TABLE ${qualified}
424
+ ALTER COLUMN ${quoteIdentifier(columnName)} SET NOT NULL`,
425
+ },
426
+ ],
427
+ postcheck: [
428
+ {
429
+ description: `verify "${columnName}" is NOT NULL`,
430
+ sql: columnNullabilityCheck({
431
+ schema: schemaName,
432
+ table: tableName,
433
+ column: columnName,
434
+ nullable: false,
435
+ }),
436
+ },
437
+ ],
438
+ };
439
+ }
440
+
441
+ function buildAlterColumnTypeOperation(
442
+ schemaName: string,
443
+ tableName: string,
444
+ columnName: string,
445
+ column: StorageColumn,
446
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
447
+ const qualified = qualifyTableName(schemaName, tableName);
448
+ const expectedType = buildColumnTypeSql(column);
449
+ return {
450
+ id: `alterType.${tableName}.${columnName}`,
451
+ label: `Alter type for ${columnName} on ${tableName}`,
452
+ summary: `Changes type of ${columnName} to ${expectedType}`,
453
+ operationClass: 'destructive',
454
+ target: {
455
+ id: 'postgres',
456
+ details: buildTargetDetails('column', columnName, schemaName, tableName),
457
+ },
458
+ meta: {
459
+ warning: 'TABLE_REWRITE',
460
+ detail:
461
+ 'ALTER COLUMN TYPE requires a full table rewrite and acquires an ACCESS EXCLUSIVE lock. On large tables, this can cause significant downtime.',
462
+ },
463
+ precheck: [
464
+ {
465
+ description: `ensure column "${columnName}" exists`,
466
+ sql: columnExistsCheck({ schema: schemaName, table: tableName, column: columnName }),
467
+ },
468
+ ],
469
+ execute: [
470
+ {
471
+ description: `alter type of "${columnName}"`,
472
+ sql: `ALTER TABLE ${qualified}
473
+ ALTER COLUMN ${quoteIdentifier(columnName)}
474
+ TYPE ${expectedType}
475
+ USING ${quoteIdentifier(columnName)}::${expectedType}`,
476
+ },
477
+ ],
478
+ postcheck: [
479
+ {
480
+ description: `verify column "${columnName}" exists after type change`,
481
+ sql: columnExistsCheck({ schema: schemaName, table: tableName, column: columnName }),
482
+ },
483
+ ],
484
+ };
485
+ }
486
+
487
+ // ============================================================================
488
+ // Conflict Conversion
489
+ // ============================================================================
490
+
491
+ function convertIssueToConflict(issue: SchemaIssue): SqlPlannerConflict | null {
492
+ switch (issue.kind) {
493
+ case 'type_mismatch':
494
+ return buildConflict('typeMismatch', issue);
495
+ case 'nullability_mismatch':
496
+ return buildConflict('nullabilityConflict', issue);
497
+ case 'default_missing':
498
+ case 'default_mismatch':
499
+ case 'extra_table':
500
+ case 'extra_column':
501
+ case 'extra_primary_key':
502
+ case 'extra_foreign_key':
503
+ case 'extra_unique_constraint':
504
+ case 'extra_index':
505
+ return buildConflict('missingButNonAdditive', issue);
506
+ case 'primary_key_mismatch':
507
+ case 'unique_constraint_mismatch':
508
+ case 'index_mismatch':
509
+ return buildConflict('indexIncompatible', issue);
510
+ case 'foreign_key_mismatch':
511
+ return buildConflict('foreignKeyConflict', issue);
512
+ // Additive issue kinds (missing_table, missing_column, type_missing, type_values_mismatch,
513
+ // extension_missing) are filtered by isAdditiveIssue before reaching this method.
514
+ // If a new SchemaIssue kind is introduced, add a mapping here so it becomes a conflict
515
+ // rather than being silently ignored.
516
+ default:
517
+ return null;
518
+ }
519
+ }
520
+
521
+ function buildConflict(kind: SqlPlannerConflict['kind'], issue: SchemaIssue): SqlPlannerConflict {
522
+ const location = buildConflictLocation(issue);
523
+ const meta =
524
+ issue.expected || issue.actual
525
+ ? Object.freeze({
526
+ ...ifDefined('expected', issue.expected),
527
+ ...ifDefined('actual', issue.actual),
528
+ })
529
+ : undefined;
530
+
531
+ return {
532
+ kind,
533
+ summary: issue.message,
534
+ ...ifDefined('location', location),
535
+ ...ifDefined('meta', meta),
536
+ };
537
+ }
538
+
539
+ // ============================================================================
540
+ // Sorting and Comparison Helpers
541
+ // ============================================================================
542
+
543
+ function sortSchemaIssues(issues: readonly SchemaIssue[]): readonly SchemaIssue[] {
544
+ return [...issues].sort((a, b) => {
545
+ const kindCompare = a.kind.localeCompare(b.kind);
546
+ if (kindCompare !== 0) {
547
+ return kindCompare;
548
+ }
549
+ const tableCompare = compareStrings(a.table, b.table);
550
+ if (tableCompare !== 0) {
551
+ return tableCompare;
552
+ }
553
+ const columnCompare = compareStrings(a.column, b.column);
554
+ if (columnCompare !== 0) {
555
+ return columnCompare;
556
+ }
557
+ return compareStrings(a.indexOrConstraint, b.indexOrConstraint);
558
+ });
559
+ }
560
+
561
+ function buildConflictLocation(issue: SchemaIssue) {
562
+ const location = {
563
+ ...ifDefined('table', issue.table),
564
+ ...ifDefined('column', issue.column),
565
+ ...ifDefined('constraint', issue.indexOrConstraint),
566
+ };
567
+ return Object.keys(location).length > 0 ? location : undefined;
568
+ }
569
+
570
+ function conflictComparator(a: SqlPlannerConflict, b: SqlPlannerConflict): number {
571
+ if (a.kind !== b.kind) {
572
+ return a.kind < b.kind ? -1 : 1;
573
+ }
574
+ const aLocation = a.location ?? {};
575
+ const bLocation = b.location ?? {};
576
+ const tableCompare = compareStrings(aLocation.table, bLocation.table);
577
+ if (tableCompare !== 0) {
578
+ return tableCompare;
579
+ }
580
+ const columnCompare = compareStrings(aLocation.column, bLocation.column);
581
+ if (columnCompare !== 0) {
582
+ return columnCompare;
583
+ }
584
+ const constraintCompare = compareStrings(aLocation.constraint, bLocation.constraint);
585
+ if (constraintCompare !== 0) {
586
+ return constraintCompare;
587
+ }
588
+ return compareStrings(a.summary, b.summary);
589
+ }
590
+
591
+ function compareStrings(a?: string, b?: string): number {
592
+ if (a === b) {
593
+ return 0;
594
+ }
595
+ if (a === undefined) {
596
+ return -1;
597
+ }
598
+ if (b === undefined) {
599
+ return 1;
600
+ }
601
+ return a < b ? -1 : 1;
602
+ }