@prisma-next/target-postgres 0.3.0-pr.99.5 → 0.3.0

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 (66) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -8
  3. package/dist/control.d.mts +19 -0
  4. package/dist/control.d.mts.map +1 -0
  5. package/dist/control.mjs +5382 -0
  6. package/dist/control.mjs.map +1 -0
  7. package/dist/descriptor-meta-CAf16lsJ.mjs +32 -0
  8. package/dist/descriptor-meta-CAf16lsJ.mjs.map +1 -0
  9. package/dist/migration-builders.d.mts +88 -0
  10. package/dist/migration-builders.d.mts.map +1 -0
  11. package/dist/migration-builders.mjs +3 -0
  12. package/dist/operation-descriptors-CxymFSgK.mjs +52 -0
  13. package/dist/operation-descriptors-CxymFSgK.mjs.map +1 -0
  14. package/dist/pack.d.mts +45 -0
  15. package/dist/pack.d.mts.map +1 -0
  16. package/dist/pack.mjs +9 -0
  17. package/dist/pack.mjs.map +1 -0
  18. package/dist/runtime.d.mts +9 -0
  19. package/dist/runtime.d.mts.map +1 -0
  20. package/dist/runtime.mjs +20 -0
  21. package/dist/runtime.mjs.map +1 -0
  22. package/package.json +30 -28
  23. package/src/core/authoring.ts +15 -0
  24. package/src/core/descriptor-meta.ts +5 -0
  25. package/src/core/migrations/descriptor-planner.ts +466 -0
  26. package/src/core/migrations/operation-descriptors.ts +166 -0
  27. package/src/core/migrations/operation-resolver.ts +929 -0
  28. package/src/core/migrations/planner-ddl-builders.ts +256 -0
  29. package/src/core/migrations/planner-identity-values.ts +135 -0
  30. package/src/core/migrations/planner-recipes.ts +91 -0
  31. package/src/core/migrations/planner-reconciliation.ts +798 -0
  32. package/src/core/migrations/planner-schema-lookup.ts +54 -0
  33. package/src/core/migrations/planner-sql-checks.ts +322 -0
  34. package/src/core/migrations/planner-strategies.ts +262 -0
  35. package/src/core/migrations/planner-target-details.ts +38 -0
  36. package/src/core/migrations/planner-type-resolution.ts +26 -0
  37. package/src/core/migrations/planner.ts +410 -460
  38. package/src/core/migrations/runner.ts +134 -38
  39. package/src/core/migrations/statement-builders.ts +6 -6
  40. package/src/core/types.ts +5 -0
  41. package/src/exports/control.ts +182 -12
  42. package/src/exports/migration-builders.ts +56 -0
  43. package/src/exports/pack.ts +7 -3
  44. package/src/exports/runtime.ts +6 -12
  45. package/dist/chunk-RKEXRSSI.js +0 -14
  46. package/dist/chunk-RKEXRSSI.js.map +0 -1
  47. package/dist/core/descriptor-meta.d.ts +0 -9
  48. package/dist/core/descriptor-meta.d.ts.map +0 -1
  49. package/dist/core/migrations/planner.d.ts +0 -14
  50. package/dist/core/migrations/planner.d.ts.map +0 -1
  51. package/dist/core/migrations/runner.d.ts +0 -8
  52. package/dist/core/migrations/runner.d.ts.map +0 -1
  53. package/dist/core/migrations/statement-builders.d.ts +0 -30
  54. package/dist/core/migrations/statement-builders.d.ts.map +0 -1
  55. package/dist/exports/control.d.ts +0 -8
  56. package/dist/exports/control.d.ts.map +0 -1
  57. package/dist/exports/control.js +0 -1260
  58. package/dist/exports/control.js.map +0 -1
  59. package/dist/exports/pack.d.ts +0 -4
  60. package/dist/exports/pack.d.ts.map +0 -1
  61. package/dist/exports/pack.js +0 -11
  62. package/dist/exports/pack.js.map +0 -1
  63. package/dist/exports/runtime.d.ts +0 -12
  64. package/dist/exports/runtime.d.ts.map +0 -1
  65. package/dist/exports/runtime.js +0 -19
  66. package/dist/exports/runtime.js.map +0 -1
@@ -1,5 +1,11 @@
1
- import type { SchemaIssue } from '@prisma-next/core-control-plane/types';
1
+ import {
2
+ normalizeSchemaNativeType,
3
+ parsePostgresDefault,
4
+ quoteIdentifier,
5
+ } from '@prisma-next/adapter-postgres/control';
2
6
  import type {
7
+ CodecControlHooks,
8
+ ComponentDatabaseDependency,
3
9
  MigrationOperationPolicy,
4
10
  SqlMigrationPlanner,
5
11
  SqlMigrationPlannerPlanOptions,
@@ -7,26 +13,51 @@ import type {
7
13
  SqlPlannerConflict,
8
14
  } from '@prisma-next/family-sql/control';
9
15
  import {
16
+ collectInitDependencies,
10
17
  createMigrationPlan,
18
+ extractCodecControlHooks,
11
19
  plannerFailure,
12
20
  plannerSuccess,
13
21
  } from '@prisma-next/family-sql/control';
14
- import {
15
- arraysEqual,
16
- isIndexSatisfied,
17
- isUniqueConstraintSatisfied,
18
- verifySqlSchema,
19
- } from '@prisma-next/family-sql/schema-verify';
22
+ import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify';
23
+ import type { SchemaIssue } from '@prisma-next/framework-components/control';
20
24
  import type {
21
- ForeignKey,
22
- SqlContract,
23
- SqlStorage,
24
25
  StorageColumn,
25
26
  StorageTable,
27
+ StorageTypeInstance,
26
28
  } from '@prisma-next/sql-contract/types';
29
+ import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
27
30
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
28
-
29
- type OperationClass = 'extension' | 'table' | 'unique' | 'index' | 'foreignKey';
31
+ import { ifDefined } from '@prisma-next/utils/defined';
32
+ import { buildAddColumnSql, buildCreateTableSql, buildForeignKeySql } from './planner-ddl-builders';
33
+ import { resolveIdentityValue } from './planner-identity-values';
34
+ import {
35
+ buildAddColumnOperationIdentity,
36
+ buildAddNotNullColumnWithTemporaryDefaultOperation,
37
+ } from './planner-recipes';
38
+ import { buildReconciliationPlan } from './planner-reconciliation';
39
+ import {
40
+ buildSchemaLookupMap,
41
+ hasForeignKey,
42
+ hasIndex,
43
+ hasUniqueConstraint,
44
+ type SchemaTableLookup,
45
+ } from './planner-schema-lookup';
46
+ import {
47
+ columnExistsCheck,
48
+ columnNullabilityCheck,
49
+ constraintExistsCheck,
50
+ qualifyTableName,
51
+ tableHasPrimaryKeyCheck,
52
+ tableIsEmptyCheck,
53
+ toRegclassLiteral,
54
+ } from './planner-sql-checks';
55
+ import {
56
+ buildTargetDetails,
57
+ type OperationClass,
58
+ type PlanningMode,
59
+ type PostgresPlanTargetDetails,
60
+ } from './planner-target-details';
30
61
 
31
62
  type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends {
32
63
  readonly frameworkComponents: infer T;
@@ -46,16 +77,8 @@ type PlannerDatabaseDependency = {
46
77
  readonly id: string;
47
78
  readonly label: string;
48
79
  readonly install: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
49
- readonly verifyDatabaseDependencyInstalled: (schema: SqlSchemaIR) => readonly SchemaIssue[];
50
80
  };
51
81
 
52
- export interface PostgresPlanTargetDetails {
53
- readonly schema: string;
54
- readonly objectType: OperationClass;
55
- readonly name: string;
56
- readonly table?: string;
57
- }
58
-
59
82
  interface PlannerConfig {
60
83
  readonly defaultSchema: string;
61
84
  }
@@ -83,38 +106,72 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
83
106
  return policyResult;
84
107
  }
85
108
 
86
- const classification = this.classifySchema(options);
87
- if (classification.kind === 'conflict') {
88
- return plannerFailure(classification.conflicts);
89
- }
109
+ const planningMode = this.resolvePlanningMode(options.policy);
110
+ const schemaIssues = this.collectSchemaIssues(options, planningMode.includeExtraObjects);
111
+
112
+ // Extract codec control hooks once at entry point for reuse across all operations.
113
+ // This avoids repeated iteration over frameworkComponents for each method that needs hooks.
114
+ const codecHooks = extractCodecControlHooks(options.frameworkComponents);
90
115
 
91
116
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
117
+ const storageTypes = options.contract.storage.types ?? {};
118
+
119
+ const reconciliationPlan = buildReconciliationPlan({
120
+ contract: options.contract,
121
+ issues: schemaIssues,
122
+ schemaName,
123
+ mode: planningMode,
124
+ policy: options.policy,
125
+ codecHooks,
126
+ });
127
+ if (reconciliationPlan.conflicts.length > 0) {
128
+ return plannerFailure(reconciliationPlan.conflicts);
129
+ }
130
+
131
+ const storageTypePlan = this.buildStorageTypeOperations(options, schemaName, codecHooks);
132
+ if (storageTypePlan.conflicts.length > 0) {
133
+ return plannerFailure(storageTypePlan.conflicts);
134
+ }
135
+
136
+ // Sort table entries once for reuse across all additive operation builders.
137
+ const sortedTables = sortedEntries(options.contract.storage.tables);
138
+
139
+ // Pre-compute constraint lookups once per schema table for O(1) checks across all builders.
140
+ const schemaLookups = buildSchemaLookupMap(options.schema);
92
141
 
93
142
  // Build extension operations from component-owned database dependencies
94
143
  operations.push(
95
144
  ...this.buildDatabaseDependencyOperations(options),
96
- ...this.buildTableOperations(options.contract.storage.tables, options.schema, schemaName),
97
- ...this.buildColumnOperations(options.contract.storage.tables, options.schema, schemaName),
98
- ...this.buildPrimaryKeyOperations(
99
- options.contract.storage.tables,
145
+ ...storageTypePlan.operations,
146
+ ...reconciliationPlan.operations,
147
+ ...this.buildTableOperations(
148
+ sortedTables,
100
149
  options.schema,
101
150
  schemaName,
151
+ codecHooks,
152
+ storageTypes,
102
153
  ),
103
- ...this.buildUniqueOperations(options.contract.storage.tables, options.schema, schemaName),
104
- ...this.buildIndexOperations(options.contract.storage.tables, options.schema, schemaName),
105
- ...this.buildForeignKeyOperations(
106
- options.contract.storage.tables,
154
+ ...this.buildColumnOperations(
155
+ sortedTables,
107
156
  options.schema,
157
+ schemaLookups,
108
158
  schemaName,
159
+ codecHooks,
160
+ storageTypes,
109
161
  ),
162
+ ...this.buildPrimaryKeyOperations(sortedTables, options.schema, schemaName),
163
+ ...this.buildUniqueOperations(sortedTables, schemaLookups, schemaName),
164
+ ...this.buildIndexOperations(sortedTables, schemaLookups, schemaName),
165
+ ...this.buildFkBackingIndexOperations(sortedTables, schemaLookups, schemaName),
166
+ ...this.buildForeignKeyOperations(sortedTables, schemaLookups, schemaName),
110
167
  );
111
168
 
112
169
  const plan = createMigrationPlan<PostgresPlanTargetDetails>({
113
170
  targetId: 'postgres',
114
171
  origin: null,
115
172
  destination: {
116
- coreHash: options.contract.coreHash,
117
- ...(options.contract.profileHash ? { profileHash: options.contract.profileHash } : {}),
173
+ storageHash: options.contract.storage.storageHash,
174
+ ...ifDefined('profileHash', options.contract.profileHash),
118
175
  },
119
176
  operations,
120
177
  });
@@ -127,8 +184,8 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
127
184
  return plannerFailure([
128
185
  {
129
186
  kind: 'unsupportedOperation',
130
- summary: 'Init planner requires additive operations be allowed',
131
- why: 'The init planner only emits additive operations. Update the policy to include "additive".',
187
+ summary: 'Migration planner requires additive operations be allowed',
188
+ why: 'The planner requires the "additive" operation class to be allowed in the policy.',
132
189
  },
133
190
  ]);
134
191
  }
@@ -147,14 +204,15 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
147
204
  const seenDependencyIds = new Set<string>();
148
205
  const seenOperationIds = new Set<string>();
149
206
 
207
+ const installedIds = new Set(options.schema.dependencies.map((d) => d.id));
208
+
150
209
  for (const dependency of dependencies) {
151
210
  if (seenDependencyIds.has(dependency.id)) {
152
211
  continue;
153
212
  }
154
213
  seenDependencyIds.add(dependency.id);
155
214
 
156
- const issues = dependency.verifyDatabaseDependencyInstalled(options.schema);
157
- if (issues.length === 0) {
215
+ if (installedIds.has(dependency.id)) {
158
216
  continue;
159
217
  }
160
218
 
@@ -163,41 +221,77 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
163
221
  continue;
164
222
  }
165
223
  seenOperationIds.add(installOp.id);
166
- // SQL family components are expected to provide compatible target details. This would be better if
167
- // the type system could enforce it but it's not likely to occur in practice.
168
224
  operations.push(installOp as SqlMigrationPlanOperation<PostgresPlanTargetDetails>);
169
225
  }
170
226
  }
171
227
 
172
228
  return operations;
173
229
  }
174
- private collectDependencies(
230
+
231
+ private buildStorageTypeOperations(
175
232
  options: PlannerOptionsWithComponents,
176
- ): ReadonlyArray<PlannerDatabaseDependency> {
177
- const components = options.frameworkComponents;
178
- if (components.length === 0) {
179
- return [];
180
- }
181
- const deps: PlannerDatabaseDependency[] = [];
182
- for (const component of components) {
183
- if (!isSqlDependencyProvider(component)) {
233
+ schemaName: string,
234
+ codecHooks: Map<string, CodecControlHooks>,
235
+ ): {
236
+ readonly operations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
237
+ readonly conflicts: readonly SqlPlannerConflict[];
238
+ } {
239
+ const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
240
+ const conflicts: SqlPlannerConflict[] = [];
241
+ const storageTypes = options.contract.storage.types ?? {};
242
+
243
+ for (const [typeName, typeInstance] of sortedEntries(storageTypes)) {
244
+ const hook = codecHooks.get(typeInstance.codecId);
245
+ const planResult = hook?.planTypeOperations?.({
246
+ typeName,
247
+ typeInstance,
248
+ contract: options.contract,
249
+ schema: options.schema,
250
+ schemaName,
251
+ policy: options.policy,
252
+ });
253
+ if (!planResult) {
184
254
  continue;
185
255
  }
186
- const initDeps = component.databaseDependencies?.init;
187
- if (initDeps && initDeps.length > 0) {
188
- deps.push(...initDeps);
256
+ for (const operation of planResult.operations) {
257
+ if (!options.policy.allowedOperationClasses.includes(operation.operationClass)) {
258
+ conflicts.push({
259
+ kind: 'missingButNonAdditive',
260
+ summary: `Storage type "${typeName}" requires "${operation.operationClass}" operation "${operation.id}"`,
261
+ location: {
262
+ type: typeName,
263
+ },
264
+ });
265
+ continue;
266
+ }
267
+ operations.push({
268
+ ...operation,
269
+ target: {
270
+ id: operation.target.id,
271
+ details: this.buildTargetDetails('type', typeName, schemaName),
272
+ },
273
+ });
189
274
  }
190
275
  }
191
- return sortDependencies(deps);
276
+
277
+ return { operations, conflicts };
278
+ }
279
+ private collectDependencies(
280
+ options: PlannerOptionsWithComponents,
281
+ ): ReadonlyArray<PlannerDatabaseDependency> {
282
+ const dependencies = collectInitDependencies(options.frameworkComponents);
283
+ return sortDependencies(dependencies.filter(isPostgresPlannerDependency));
192
284
  }
193
285
 
194
286
  private buildTableOperations(
195
- tables: SqlContract<SqlStorage>['storage']['tables'],
287
+ tables: ReadonlyArray<[string, StorageTable]>,
196
288
  schema: SqlSchemaIR,
197
289
  schemaName: string,
290
+ codecHooks: Map<string, CodecControlHooks>,
291
+ storageTypes: Record<string, StorageTypeInstance>,
198
292
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
199
293
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
200
- for (const [tableName, table] of sortedEntries(tables)) {
294
+ for (const [tableName, table] of tables) {
201
295
  if (schema.tables[tableName]) {
202
296
  continue;
203
297
  }
@@ -220,7 +314,7 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
220
314
  execute: [
221
315
  {
222
316
  description: `create table "${tableName}"`,
223
- sql: buildCreateTableSql(qualified, table),
317
+ sql: buildCreateTableSql(qualified, table, codecHooks, storageTypes),
224
318
  },
225
319
  ],
226
320
  postcheck: [
@@ -235,91 +329,155 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
235
329
  }
236
330
 
237
331
  private buildColumnOperations(
238
- tables: SqlContract<SqlStorage>['storage']['tables'],
332
+ tables: ReadonlyArray<[string, StorageTable]>,
239
333
  schema: SqlSchemaIR,
334
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
240
335
  schemaName: string,
336
+ codecHooks: Map<string, CodecControlHooks>,
337
+ storageTypes: Record<string, StorageTypeInstance>,
241
338
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
242
339
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
243
- for (const [tableName, table] of sortedEntries(tables)) {
340
+ for (const [tableName, table] of tables) {
244
341
  const schemaTable = schema.tables[tableName];
245
342
  if (!schemaTable) {
246
343
  continue;
247
344
  }
345
+ const schemaLookup = schemaLookups.get(tableName);
248
346
  for (const [columnName, column] of sortedEntries(table.columns)) {
249
347
  if (schemaTable.columns[columnName]) {
250
348
  continue;
251
349
  }
252
- operations.push(this.buildAddColumnOperation(schemaName, tableName, columnName, column));
350
+ operations.push(
351
+ this.buildAddColumnOperation({
352
+ schema: schemaName,
353
+ tableName,
354
+ table,
355
+ schemaTable,
356
+ schemaLookup,
357
+ columnName,
358
+ column,
359
+ codecHooks,
360
+ storageTypes,
361
+ }),
362
+ );
253
363
  }
254
364
  }
255
365
  return operations;
256
366
  }
257
367
 
258
- private buildAddColumnOperation(
259
- schema: string,
260
- tableName: string,
261
- columnName: string,
262
- column: StorageColumn,
263
- ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
264
- const qualified = qualifyTableName(schema, tableName);
265
- const notNull = column.nullable === false;
266
- const precheck = [
267
- {
268
- description: `ensure column "${columnName}" is missing`,
269
- sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
270
- },
271
- ...(notNull
272
- ? [
273
- {
274
- description: `ensure table "${tableName}" is empty before adding NOT NULL column`,
275
- sql: tableIsEmptyCheck(qualified),
276
- },
277
- ]
278
- : []),
279
- ];
280
- const execute = [
281
- {
282
- description: `add column "${columnName}"`,
283
- sql: buildAddColumnSql(qualified, columnName, column),
284
- },
285
- ];
286
- const postcheck = [
287
- {
288
- description: `verify column "${columnName}" exists`,
289
- sql: columnExistsCheck({ schema, table: tableName, column: columnName }),
290
- },
291
- ...(notNull
292
- ? [
293
- {
294
- description: `verify column "${columnName}" is NOT NULL`,
295
- sql: columnIsNotNullCheck({ schema, table: tableName, column: columnName }),
296
- },
297
- ]
298
- : []),
299
- ];
368
+ private buildAddColumnOperation(options: {
369
+ readonly schema: string;
370
+ readonly tableName: string;
371
+ readonly table: StorageTable;
372
+ readonly schemaTable: SqlSchemaIR['tables'][string];
373
+ readonly schemaLookup: SchemaTableLookup | undefined;
374
+ readonly columnName: string;
375
+ readonly column: StorageColumn;
376
+ readonly codecHooks: Map<string, CodecControlHooks>;
377
+ readonly storageTypes: Record<string, StorageTypeInstance>;
378
+ }): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
379
+ const {
380
+ schema,
381
+ tableName,
382
+ table,
383
+ schemaTable,
384
+ schemaLookup,
385
+ columnName,
386
+ column,
387
+ codecHooks,
388
+ storageTypes,
389
+ } = options;
390
+ const notNull = !column.nullable;
391
+ const hasDefault = column.default !== undefined;
392
+ // Planner logic decides whether this column needs the coordinated multi-step
393
+ // strategy. The strategy recipe itself is built by a dedicated helper.
394
+ const needsTemporaryDefault = notNull && !hasDefault;
395
+ const temporaryDefault = needsTemporaryDefault
396
+ ? resolveIdentityValue(column, codecHooks, storageTypes)
397
+ : null;
398
+ const canUseSharedTemporaryDefault =
399
+ needsTemporaryDefault &&
400
+ temporaryDefault !== null &&
401
+ canUseSharedTemporaryDefaultStrategy({
402
+ table,
403
+ schemaTable,
404
+ schemaLookup,
405
+ columnName,
406
+ });
300
407
 
408
+ if (canUseSharedTemporaryDefault) {
409
+ return buildAddNotNullColumnWithTemporaryDefaultOperation({
410
+ schema,
411
+ tableName,
412
+ columnName,
413
+ column,
414
+ codecHooks,
415
+ storageTypes,
416
+ temporaryDefault,
417
+ });
418
+ }
419
+
420
+ const qualified = qualifyTableName(schema, tableName);
421
+ const requiresEmptyTableCheck = needsTemporaryDefault && !canUseSharedTemporaryDefault;
301
422
  return {
302
- id: `column.${tableName}.${columnName}`,
303
- label: `Add column ${columnName} to ${tableName}`,
304
- summary: `Adds column ${columnName} to table ${tableName}`,
423
+ ...buildAddColumnOperationIdentity(schema, tableName, columnName),
305
424
  operationClass: 'additive',
306
- target: {
307
- id: 'postgres',
308
- details: this.buildTargetDetails('table', tableName, schema),
309
- },
310
- precheck,
311
- execute,
312
- postcheck,
425
+ precheck: [
426
+ {
427
+ description: `ensure column "${columnName}" is missing`,
428
+ sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
429
+ },
430
+ ...(requiresEmptyTableCheck
431
+ ? [
432
+ {
433
+ description: `ensure table "${tableName}" is empty before adding NOT NULL column without default`,
434
+ sql: tableIsEmptyCheck(qualified),
435
+ },
436
+ ]
437
+ : []),
438
+ ],
439
+ execute: [
440
+ {
441
+ description: `add column "${columnName}"`,
442
+ sql: buildAddColumnSql(
443
+ qualified,
444
+ columnName,
445
+ column,
446
+ codecHooks,
447
+ undefined,
448
+ storageTypes,
449
+ ),
450
+ },
451
+ ],
452
+ postcheck: [
453
+ {
454
+ description: `verify column "${columnName}" exists`,
455
+ sql: columnExistsCheck({ schema, table: tableName, column: columnName }),
456
+ },
457
+ ...(notNull
458
+ ? [
459
+ {
460
+ description: `verify column "${columnName}" is NOT NULL`,
461
+ sql: columnNullabilityCheck({
462
+ schema,
463
+ table: tableName,
464
+ column: columnName,
465
+ nullable: false,
466
+ }),
467
+ },
468
+ ]
469
+ : []),
470
+ ],
313
471
  };
314
472
  }
315
473
 
316
474
  private buildPrimaryKeyOperations(
317
- tables: SqlContract<SqlStorage>['storage']['tables'],
475
+ tables: ReadonlyArray<[string, StorageTable]>,
318
476
  schema: SqlSchemaIR,
319
477
  schemaName: string,
320
478
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
321
479
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
322
- for (const [tableName, table] of sortedEntries(tables)) {
480
+ for (const [tableName, table] of tables) {
323
481
  if (!table.primaryKey) {
324
482
  continue;
325
483
  }
@@ -363,15 +521,15 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
363
521
  }
364
522
 
365
523
  private buildUniqueOperations(
366
- tables: SqlContract<SqlStorage>['storage']['tables'],
367
- schema: SqlSchemaIR,
524
+ tables: ReadonlyArray<[string, StorageTable]>,
525
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
368
526
  schemaName: string,
369
527
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
370
528
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
371
- for (const [tableName, table] of sortedEntries(tables)) {
372
- const schemaTable = schema.tables[tableName];
529
+ for (const [tableName, table] of tables) {
530
+ const lookup = schemaLookups.get(tableName);
373
531
  for (const unique of table.uniques) {
374
- if (schemaTable && hasUniqueConstraint(schemaTable, unique.columns)) {
532
+ if (lookup && hasUniqueConstraint(lookup, unique.columns)) {
375
533
  continue;
376
534
  }
377
535
  const constraintName = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
@@ -387,7 +545,12 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
387
545
  precheck: [
388
546
  {
389
547
  description: `ensure unique constraint "${constraintName}" is missing`,
390
- sql: constraintExistsCheck({ constraintName, schema: schemaName, exists: false }),
548
+ sql: constraintExistsCheck({
549
+ constraintName,
550
+ schema: schemaName,
551
+ table: tableName,
552
+ exists: false,
553
+ }),
391
554
  },
392
555
  ],
393
556
  execute: [
@@ -401,7 +564,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
401
564
  postcheck: [
402
565
  {
403
566
  description: `verify unique constraint "${constraintName}" exists`,
404
- sql: constraintExistsCheck({ constraintName, schema: schemaName }),
567
+ sql: constraintExistsCheck({ constraintName, schema: schemaName, table: tableName }),
405
568
  },
406
569
  ],
407
570
  });
@@ -411,18 +574,18 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
411
574
  }
412
575
 
413
576
  private buildIndexOperations(
414
- tables: SqlContract<SqlStorage>['storage']['tables'],
415
- schema: SqlSchemaIR,
577
+ tables: ReadonlyArray<[string, StorageTable]>,
578
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
416
579
  schemaName: string,
417
580
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
418
581
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
419
- for (const [tableName, table] of sortedEntries(tables)) {
420
- const schemaTable = schema.tables[tableName];
582
+ for (const [tableName, table] of tables) {
583
+ const lookup = schemaLookups.get(tableName);
421
584
  for (const index of table.indexes) {
422
- if (schemaTable && hasIndex(schemaTable, index.columns)) {
585
+ if (lookup && hasIndex(lookup, index.columns)) {
423
586
  continue;
424
587
  }
425
- const indexName = index.name ?? `${tableName}_${index.columns.join('_')}_idx`;
588
+ const indexName = index.name ?? defaultIndexName(tableName, index.columns);
426
589
  operations.push({
427
590
  id: `index.${tableName}.${indexName}`,
428
591
  label: `Create index ${indexName} on ${tableName}`,
@@ -459,16 +622,76 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
459
622
  return operations;
460
623
  }
461
624
 
625
+ /**
626
+ * Generates FK-backing index operations for FKs with `index: true`,
627
+ * but only when no matching user-declared index exists in `contractTable.indexes`.
628
+ */
629
+ private buildFkBackingIndexOperations(
630
+ tables: ReadonlyArray<[string, StorageTable]>,
631
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
632
+ schemaName: string,
633
+ ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
634
+ const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
635
+ for (const [tableName, table] of tables) {
636
+ const lookup = schemaLookups.get(tableName);
637
+ // Collect column sets of user-declared indexes to avoid duplicates
638
+ const declaredIndexColumns = new Set(table.indexes.map((idx) => idx.columns.join(',')));
639
+
640
+ for (const fk of table.foreignKeys) {
641
+ if (fk.index === false) continue;
642
+ // Skip if user already declared an index with these columns
643
+ if (declaredIndexColumns.has(fk.columns.join(','))) continue;
644
+ // Skip if the index already exists in the database
645
+ if (lookup && hasIndex(lookup, fk.columns)) continue;
646
+
647
+ const indexName = defaultIndexName(tableName, fk.columns);
648
+ operations.push({
649
+ id: `index.${tableName}.${indexName}`,
650
+ label: `Create FK-backing index ${indexName} on ${tableName}`,
651
+ summary: `Creates FK-backing index ${indexName} on ${tableName}`,
652
+ operationClass: 'additive',
653
+ target: {
654
+ id: 'postgres',
655
+ details: this.buildTargetDetails('index', indexName, schemaName, tableName),
656
+ },
657
+ precheck: [
658
+ {
659
+ description: `ensure index "${indexName}" is missing`,
660
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NULL`,
661
+ },
662
+ ],
663
+ execute: [
664
+ {
665
+ description: `create FK-backing index "${indexName}"`,
666
+ sql: `CREATE INDEX ${quoteIdentifier(indexName)} ON ${qualifyTableName(
667
+ schemaName,
668
+ tableName,
669
+ )} (${fk.columns.map(quoteIdentifier).join(', ')})`,
670
+ },
671
+ ],
672
+ postcheck: [
673
+ {
674
+ description: `verify index "${indexName}" exists`,
675
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NOT NULL`,
676
+ },
677
+ ],
678
+ });
679
+ }
680
+ }
681
+ return operations;
682
+ }
683
+
462
684
  private buildForeignKeyOperations(
463
- tables: SqlContract<SqlStorage>['storage']['tables'],
464
- schema: SqlSchemaIR,
685
+ tables: ReadonlyArray<[string, StorageTable]>,
686
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
465
687
  schemaName: string,
466
688
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
467
689
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
468
- for (const [tableName, table] of sortedEntries(tables)) {
469
- const schemaTable = schema.tables[tableName];
690
+ for (const [tableName, table] of tables) {
691
+ const lookup = schemaLookups.get(tableName);
470
692
  for (const foreignKey of table.foreignKeys) {
471
- if (schemaTable && hasForeignKey(schemaTable, foreignKey)) {
693
+ if (foreignKey.constraint === false) continue;
694
+ if (lookup && hasForeignKey(lookup, foreignKey)) {
472
695
  continue;
473
696
  }
474
697
  const fkName = foreignKey.name ?? `${tableName}_${foreignKey.columns.join('_')}_fkey`;
@@ -487,6 +710,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
487
710
  sql: constraintExistsCheck({
488
711
  constraintName: fkName,
489
712
  schema: schemaName,
713
+ table: tableName,
490
714
  exists: false,
491
715
  }),
492
716
  },
@@ -494,18 +718,17 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
494
718
  execute: [
495
719
  {
496
720
  description: `add foreign key "${fkName}"`,
497
- sql: `ALTER TABLE ${qualifyTableName(schemaName, tableName)}
498
- ADD CONSTRAINT ${quoteIdentifier(fkName)}
499
- FOREIGN KEY (${foreignKey.columns.map(quoteIdentifier).join(', ')})
500
- REFERENCES ${qualifyTableName(schemaName, foreignKey.references.table)} (${foreignKey.references.columns
501
- .map(quoteIdentifier)
502
- .join(', ')})`,
721
+ sql: buildForeignKeySql(schemaName, tableName, fkName, foreignKey),
503
722
  },
504
723
  ],
505
724
  postcheck: [
506
725
  {
507
726
  description: `verify foreign key "${fkName}" exists`,
508
- sql: constraintExistsCheck({ constraintName: fkName, schema: schemaName }),
727
+ sql: constraintExistsCheck({
728
+ constraintName: fkName,
729
+ schema: schemaName,
730
+ table: tableName,
731
+ }),
509
732
  },
510
733
  ],
511
734
  });
@@ -520,356 +743,83 @@ REFERENCES ${qualifyTableName(schemaName, foreignKey.references.table)} (${forei
520
743
  schema: string,
521
744
  table?: string,
522
745
  ): PostgresPlanTargetDetails {
523
- return {
524
- schema,
525
- objectType,
526
- name,
527
- ...(table ? { table } : {}),
528
- };
746
+ return buildTargetDetails(objectType, name, schema, table);
529
747
  }
530
748
 
531
- private classifySchema(options: PlannerOptionsWithComponents):
532
- | { kind: 'ok' }
533
- | {
534
- kind: 'conflict';
535
- conflicts: SqlPlannerConflict[];
536
- } {
749
+ private resolvePlanningMode(policy: MigrationOperationPolicy): PlanningMode {
750
+ const allowWidening = policy.allowedOperationClasses.includes('widening');
751
+ const allowDestructive = policy.allowedOperationClasses.includes('destructive');
752
+ // `db init` uses additive-only policy and intentionally ignores extras.
753
+ // Any reconciliation-capable policy should inspect extras to reconcile strict equality.
754
+ const includeExtraObjects = allowWidening || allowDestructive;
755
+ return { includeExtraObjects, allowWidening, allowDestructive };
756
+ }
757
+
758
+ private collectSchemaIssues(
759
+ options: PlannerOptionsWithComponents,
760
+ strict: boolean,
761
+ ): readonly SchemaIssue[] {
537
762
  const verifyOptions: VerifySqlSchemaOptionsWithComponents = {
538
763
  contract: options.contract,
539
764
  schema: options.schema,
540
- strict: false,
765
+ strict,
541
766
  typeMetadataRegistry: new Map(),
542
767
  frameworkComponents: options.frameworkComponents,
768
+ normalizeDefault: parsePostgresDefault,
769
+ normalizeNativeType: normalizeSchemaNativeType,
543
770
  };
544
771
  const verifyResult = verifySqlSchema(verifyOptions);
545
-
546
- const conflicts = this.extractConflicts(verifyResult.schema.issues);
547
- if (conflicts.length > 0) {
548
- return { kind: 'conflict', conflicts };
549
- }
550
- return { kind: 'ok' };
551
- }
552
-
553
- private extractConflicts(issues: readonly SchemaIssue[]): SqlPlannerConflict[] {
554
- const conflicts: SqlPlannerConflict[] = [];
555
- for (const issue of issues) {
556
- if (isAdditiveIssue(issue)) {
557
- continue;
558
- }
559
- const conflict = this.convertIssueToConflict(issue);
560
- if (conflict) {
561
- conflicts.push(conflict);
562
- }
563
- }
564
- return conflicts.sort(conflictComparator);
565
- }
566
-
567
- private convertIssueToConflict(issue: SchemaIssue): SqlPlannerConflict | null {
568
- switch (issue.kind) {
569
- case 'type_mismatch':
570
- return this.buildConflict('typeMismatch', issue);
571
- case 'nullability_mismatch':
572
- return this.buildConflict('nullabilityConflict', issue);
573
- case 'primary_key_mismatch':
574
- return this.buildConflict('indexIncompatible', issue);
575
- case 'unique_constraint_mismatch':
576
- return this.buildConflict('indexIncompatible', issue);
577
- case 'index_mismatch':
578
- return this.buildConflict('indexIncompatible', issue);
579
- case 'foreign_key_mismatch':
580
- return this.buildConflict('foreignKeyConflict', issue);
581
- default:
582
- return null;
583
- }
584
- }
585
-
586
- private buildConflict(kind: SqlPlannerConflict['kind'], issue: SchemaIssue): SqlPlannerConflict {
587
- const location = buildConflictLocation(issue);
588
- const meta =
589
- issue.expected || issue.actual
590
- ? Object.freeze({
591
- ...(issue.expected ? { expected: issue.expected } : {}),
592
- ...(issue.actual ? { actual: issue.actual } : {}),
593
- })
594
- : undefined;
595
-
596
- return {
597
- kind,
598
- summary: issue.message,
599
- ...(location ? { location } : {}),
600
- ...(meta ? { meta } : {}),
601
- };
772
+ return verifyResult.schema.issues;
602
773
  }
603
774
  }
604
775
 
605
- function isSqlDependencyProvider(component: unknown): component is {
606
- readonly databaseDependencies?: {
607
- readonly init?: readonly PlannerDatabaseDependency[];
608
- };
609
- } {
610
- if (typeof component !== 'object' || component === null) {
776
+ function canUseSharedTemporaryDefaultStrategy(options: {
777
+ readonly table: StorageTable;
778
+ readonly schemaTable: SqlSchemaIR['tables'][string];
779
+ readonly schemaLookup: SchemaTableLookup | undefined;
780
+ readonly columnName: string;
781
+ }): boolean {
782
+ const { table, schemaTable, schemaLookup, columnName } = options;
783
+
784
+ // Shared placeholders are only safe when later plan steps do not require
785
+ // row-specific values for this newly added column.
786
+ if (table.primaryKey?.columns.includes(columnName) && !schemaTable.primaryKey) {
611
787
  return false;
612
788
  }
613
- const record = component as Record<string, unknown>;
614
789
 
615
- // If present, enforce familyId match to avoid mixing families at runtime.
616
- if (Object.hasOwn(record, 'familyId') && record['familyId'] !== 'sql') {
617
- return false;
790
+ for (const unique of table.uniques) {
791
+ if (!unique.columns.includes(columnName)) {
792
+ continue;
793
+ }
794
+ if (!schemaLookup || !hasUniqueConstraint(schemaLookup, unique.columns)) {
795
+ return false;
796
+ }
618
797
  }
619
798
 
620
- if (!Object.hasOwn(record, 'databaseDependencies')) {
621
- return false;
799
+ for (const foreignKey of table.foreignKeys) {
800
+ if (foreignKey.constraint === false || !foreignKey.columns.includes(columnName)) {
801
+ continue;
802
+ }
803
+ if (!schemaLookup || !hasForeignKey(schemaLookup, foreignKey)) {
804
+ return false;
805
+ }
622
806
  }
623
- const deps = record['databaseDependencies'];
624
- return deps === undefined || (typeof deps === 'object' && deps !== null);
807
+
808
+ return true;
625
809
  }
626
810
 
627
811
  function sortDependencies(
628
812
  dependencies: ReadonlyArray<PlannerDatabaseDependency>,
629
813
  ): ReadonlyArray<PlannerDatabaseDependency> {
630
- if (dependencies.length <= 1) {
631
- return dependencies;
632
- }
633
814
  return [...dependencies].sort((a, b) => a.id.localeCompare(b.id));
634
815
  }
635
816
 
636
- function buildCreateTableSql(qualifiedTableName: string, table: StorageTable): string {
637
- const columnDefinitions = Object.entries(table.columns).map(
638
- ([columnName, column]: [string, StorageColumn]) => {
639
- const parts = [
640
- quoteIdentifier(columnName),
641
- column.nativeType,
642
- column.nullable ? '' : 'NOT NULL',
643
- ].filter(Boolean);
644
- return parts.join(' ');
645
- },
646
- );
647
-
648
- const constraintDefinitions: string[] = [];
649
- if (table.primaryKey) {
650
- constraintDefinitions.push(
651
- `PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
652
- );
653
- }
654
-
655
- const allDefinitions = [...columnDefinitions, ...constraintDefinitions];
656
- return `CREATE TABLE ${qualifiedTableName} (\n ${allDefinitions.join(',\n ')}\n)`;
657
- }
658
-
659
- function qualifyTableName(schema: string, table: string): string {
660
- return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
661
- }
662
-
663
- function toRegclassLiteral(schema: string, name: string): string {
664
- const regclass = `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`;
665
- return `'${escapeLiteral(regclass)}'`;
666
- }
667
-
668
- /** Escapes and quotes a SQL identifier (table, column, schema name). */
669
- function quoteIdentifier(identifier: string): string {
670
- // TypeScript enforces string type - no runtime check needed for internal callers
671
- return `"${identifier.replace(/"/g, '""')}"`;
672
- }
673
-
674
- function escapeLiteral(value: string): string {
675
- return value.replace(/'/g, "''");
817
+ function isPostgresPlannerDependency(
818
+ dependency: ComponentDatabaseDependency<unknown>,
819
+ ): dependency is PlannerDatabaseDependency {
820
+ return dependency.install.every((operation) => operation.target.id === 'postgres');
676
821
  }
677
822
 
678
823
  function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]> {
679
824
  return Object.entries(record).sort(([a], [b]) => a.localeCompare(b)) as Array<[string, V]>;
680
825
  }
681
-
682
- function constraintExistsCheck({
683
- constraintName,
684
- schema,
685
- exists = true,
686
- }: {
687
- constraintName: string;
688
- schema: string;
689
- exists?: boolean;
690
- }): string {
691
- const existsClause = exists ? 'EXISTS' : 'NOT EXISTS';
692
- return `SELECT ${existsClause} (
693
- SELECT 1 FROM pg_constraint c
694
- JOIN pg_namespace n ON c.connamespace = n.oid
695
- WHERE c.conname = '${escapeLiteral(constraintName)}'
696
- AND n.nspname = '${escapeLiteral(schema)}'
697
- )`;
698
- }
699
-
700
- function columnExistsCheck({
701
- schema,
702
- table,
703
- column,
704
- exists = true,
705
- }: {
706
- schema: string;
707
- table: string;
708
- column: string;
709
- exists?: boolean;
710
- }): string {
711
- const existsClause = exists ? '' : 'NOT ';
712
- return `SELECT ${existsClause}EXISTS (
713
- SELECT 1
714
- FROM information_schema.columns
715
- WHERE table_schema = '${escapeLiteral(schema)}'
716
- AND table_name = '${escapeLiteral(table)}'
717
- AND column_name = '${escapeLiteral(column)}'
718
- )`;
719
- }
720
-
721
- function columnIsNotNullCheck({
722
- schema,
723
- table,
724
- column,
725
- }: {
726
- schema: string;
727
- table: string;
728
- column: string;
729
- }): string {
730
- return `SELECT EXISTS (
731
- SELECT 1
732
- FROM information_schema.columns
733
- WHERE table_schema = '${escapeLiteral(schema)}'
734
- AND table_name = '${escapeLiteral(table)}'
735
- AND column_name = '${escapeLiteral(column)}'
736
- AND is_nullable = 'NO'
737
- )`;
738
- }
739
-
740
- function tableIsEmptyCheck(qualifiedTableName: string): string {
741
- return `SELECT NOT EXISTS (SELECT 1 FROM ${qualifiedTableName} LIMIT 1)`;
742
- }
743
-
744
- function buildAddColumnSql(
745
- qualifiedTableName: string,
746
- columnName: string,
747
- column: StorageColumn,
748
- ): string {
749
- const parts = [
750
- `ALTER TABLE ${qualifiedTableName}`,
751
- `ADD COLUMN ${quoteIdentifier(columnName)} ${column.nativeType}`,
752
- column.nullable ? '' : 'NOT NULL',
753
- ].filter(Boolean);
754
- return parts.join(' ');
755
- }
756
-
757
- function tableHasPrimaryKeyCheck(
758
- schema: string,
759
- table: string,
760
- exists: boolean,
761
- constraintName?: string,
762
- ): string {
763
- const comparison = exists ? '' : 'NOT ';
764
- const constraintFilter = constraintName
765
- ? `AND c2.relname = '${escapeLiteral(constraintName)}'`
766
- : '';
767
- return `SELECT ${comparison}EXISTS (
768
- SELECT 1
769
- FROM pg_index i
770
- JOIN pg_class c ON c.oid = i.indrelid
771
- JOIN pg_namespace n ON n.oid = c.relnamespace
772
- LEFT JOIN pg_class c2 ON c2.oid = i.indexrelid
773
- WHERE n.nspname = '${escapeLiteral(schema)}'
774
- AND c.relname = '${escapeLiteral(table)}'
775
- AND i.indisprimary
776
- ${constraintFilter}
777
- )`;
778
- }
779
-
780
- /**
781
- * Checks if table has a unique constraint satisfied by the given columns.
782
- * Uses shared semantic satisfaction predicate from verify-helpers.
783
- */
784
- function hasUniqueConstraint(
785
- table: SqlSchemaIR['tables'][string],
786
- columns: readonly string[],
787
- ): boolean {
788
- return isUniqueConstraintSatisfied(table.uniques, table.indexes, columns);
789
- }
790
-
791
- /**
792
- * Checks if table has an index satisfied by the given columns.
793
- * Uses shared semantic satisfaction predicate from verify-helpers.
794
- */
795
- function hasIndex(table: SqlSchemaIR['tables'][string], columns: readonly string[]): boolean {
796
- return isIndexSatisfied(table.indexes, table.uniques, columns);
797
- }
798
-
799
- function hasForeignKey(table: SqlSchemaIR['tables'][string], fk: ForeignKey): boolean {
800
- return table.foreignKeys.some(
801
- (candidate) =>
802
- arraysEqual(candidate.columns, fk.columns) &&
803
- candidate.referencedTable === fk.references.table &&
804
- arraysEqual(candidate.referencedColumns, fk.references.columns),
805
- );
806
- }
807
-
808
- function isAdditiveIssue(issue: SchemaIssue): boolean {
809
- switch (issue.kind) {
810
- case 'missing_table':
811
- case 'missing_column':
812
- case 'extension_missing':
813
- return true;
814
- case 'primary_key_mismatch':
815
- return issue.actual === undefined;
816
- case 'unique_constraint_mismatch':
817
- case 'index_mismatch':
818
- case 'foreign_key_mismatch':
819
- return issue.indexOrConstraint === undefined;
820
- default:
821
- return false;
822
- }
823
- }
824
-
825
- function buildConflictLocation(issue: SchemaIssue) {
826
- const location: {
827
- table?: string;
828
- column?: string;
829
- constraint?: string;
830
- } = {};
831
- if (issue.table) {
832
- location.table = issue.table;
833
- }
834
- if (issue.column) {
835
- location.column = issue.column;
836
- }
837
- if (issue.indexOrConstraint) {
838
- location.constraint = issue.indexOrConstraint;
839
- }
840
- return Object.keys(location).length > 0 ? location : undefined;
841
- }
842
-
843
- function conflictComparator(a: SqlPlannerConflict, b: SqlPlannerConflict): number {
844
- if (a.kind !== b.kind) {
845
- return a.kind < b.kind ? -1 : 1;
846
- }
847
- const aLocation = a.location ?? {};
848
- const bLocation = b.location ?? {};
849
- const tableCompare = compareStrings(aLocation.table, bLocation.table);
850
- if (tableCompare !== 0) {
851
- return tableCompare;
852
- }
853
- const columnCompare = compareStrings(aLocation.column, bLocation.column);
854
- if (columnCompare !== 0) {
855
- return columnCompare;
856
- }
857
- const constraintCompare = compareStrings(aLocation.constraint, bLocation.constraint);
858
- if (constraintCompare !== 0) {
859
- return constraintCompare;
860
- }
861
- return compareStrings(a.summary, b.summary);
862
- }
863
-
864
- function compareStrings(a?: string, b?: string): number {
865
- if (a === b) {
866
- return 0;
867
- }
868
- if (a === undefined) {
869
- return -1;
870
- }
871
- if (b === undefined) {
872
- return 1;
873
- }
874
- return a < b ? -1 : 1;
875
- }