@prisma-next/target-postgres 0.3.0-dev.15 → 0.3.0-dev.162

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 +31 -29
  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 -447
  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 -1255
  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,21 +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 { arraysEqual, verifySqlSchema } 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';
15
24
  import type {
16
- ForeignKey,
17
- SqlContract,
18
- SqlStorage,
19
25
  StorageColumn,
20
26
  StorageTable,
27
+ StorageTypeInstance,
21
28
  } from '@prisma-next/sql-contract/types';
29
+ import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
22
30
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
23
-
24
- 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';
25
61
 
26
62
  type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends {
27
63
  readonly frameworkComponents: infer T;
@@ -41,16 +77,8 @@ type PlannerDatabaseDependency = {
41
77
  readonly id: string;
42
78
  readonly label: string;
43
79
  readonly install: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
44
- readonly verifyDatabaseDependencyInstalled: (schema: SqlSchemaIR) => readonly SchemaIssue[];
45
80
  };
46
81
 
47
- export interface PostgresPlanTargetDetails {
48
- readonly schema: string;
49
- readonly objectType: OperationClass;
50
- readonly name: string;
51
- readonly table?: string;
52
- }
53
-
54
82
  interface PlannerConfig {
55
83
  readonly defaultSchema: string;
56
84
  }
@@ -78,38 +106,72 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
78
106
  return policyResult;
79
107
  }
80
108
 
81
- const classification = this.classifySchema(options);
82
- if (classification.kind === 'conflict') {
83
- return plannerFailure(classification.conflicts);
84
- }
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);
85
115
 
86
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);
87
141
 
88
142
  // Build extension operations from component-owned database dependencies
89
143
  operations.push(
90
144
  ...this.buildDatabaseDependencyOperations(options),
91
- ...this.buildTableOperations(options.contract.storage.tables, options.schema, schemaName),
92
- ...this.buildColumnOperations(options.contract.storage.tables, options.schema, schemaName),
93
- ...this.buildPrimaryKeyOperations(
94
- options.contract.storage.tables,
145
+ ...storageTypePlan.operations,
146
+ ...reconciliationPlan.operations,
147
+ ...this.buildTableOperations(
148
+ sortedTables,
95
149
  options.schema,
96
150
  schemaName,
151
+ codecHooks,
152
+ storageTypes,
97
153
  ),
98
- ...this.buildUniqueOperations(options.contract.storage.tables, options.schema, schemaName),
99
- ...this.buildIndexOperations(options.contract.storage.tables, options.schema, schemaName),
100
- ...this.buildForeignKeyOperations(
101
- options.contract.storage.tables,
154
+ ...this.buildColumnOperations(
155
+ sortedTables,
102
156
  options.schema,
157
+ schemaLookups,
103
158
  schemaName,
159
+ codecHooks,
160
+ storageTypes,
104
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),
105
167
  );
106
168
 
107
169
  const plan = createMigrationPlan<PostgresPlanTargetDetails>({
108
170
  targetId: 'postgres',
109
171
  origin: null,
110
172
  destination: {
111
- coreHash: options.contract.coreHash,
112
- ...(options.contract.profileHash ? { profileHash: options.contract.profileHash } : {}),
173
+ storageHash: options.contract.storage.storageHash,
174
+ ...ifDefined('profileHash', options.contract.profileHash),
113
175
  },
114
176
  operations,
115
177
  });
@@ -122,8 +184,8 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
122
184
  return plannerFailure([
123
185
  {
124
186
  kind: 'unsupportedOperation',
125
- summary: 'Init planner requires additive operations be allowed',
126
- 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.',
127
189
  },
128
190
  ]);
129
191
  }
@@ -142,14 +204,15 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
142
204
  const seenDependencyIds = new Set<string>();
143
205
  const seenOperationIds = new Set<string>();
144
206
 
207
+ const installedIds = new Set(options.schema.dependencies.map((d) => d.id));
208
+
145
209
  for (const dependency of dependencies) {
146
210
  if (seenDependencyIds.has(dependency.id)) {
147
211
  continue;
148
212
  }
149
213
  seenDependencyIds.add(dependency.id);
150
214
 
151
- const issues = dependency.verifyDatabaseDependencyInstalled(options.schema);
152
- if (issues.length === 0) {
215
+ if (installedIds.has(dependency.id)) {
153
216
  continue;
154
217
  }
155
218
 
@@ -158,41 +221,77 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
158
221
  continue;
159
222
  }
160
223
  seenOperationIds.add(installOp.id);
161
- // SQL family components are expected to provide compatible target details. This would be better if
162
- // the type system could enforce it but it's not likely to occur in practice.
163
224
  operations.push(installOp as SqlMigrationPlanOperation<PostgresPlanTargetDetails>);
164
225
  }
165
226
  }
166
227
 
167
228
  return operations;
168
229
  }
169
- private collectDependencies(
230
+
231
+ private buildStorageTypeOperations(
170
232
  options: PlannerOptionsWithComponents,
171
- ): ReadonlyArray<PlannerDatabaseDependency> {
172
- const components = options.frameworkComponents;
173
- if (components.length === 0) {
174
- return [];
175
- }
176
- const deps: PlannerDatabaseDependency[] = [];
177
- for (const component of components) {
178
- 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) {
179
254
  continue;
180
255
  }
181
- const initDeps = component.databaseDependencies?.init;
182
- if (initDeps && initDeps.length > 0) {
183
- 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
+ });
184
274
  }
185
275
  }
186
- 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));
187
284
  }
188
285
 
189
286
  private buildTableOperations(
190
- tables: SqlContract<SqlStorage>['storage']['tables'],
287
+ tables: ReadonlyArray<[string, StorageTable]>,
191
288
  schema: SqlSchemaIR,
192
289
  schemaName: string,
290
+ codecHooks: Map<string, CodecControlHooks>,
291
+ storageTypes: Record<string, StorageTypeInstance>,
193
292
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
194
293
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
195
- for (const [tableName, table] of sortedEntries(tables)) {
294
+ for (const [tableName, table] of tables) {
196
295
  if (schema.tables[tableName]) {
197
296
  continue;
198
297
  }
@@ -215,7 +314,7 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
215
314
  execute: [
216
315
  {
217
316
  description: `create table "${tableName}"`,
218
- sql: buildCreateTableSql(qualified, table),
317
+ sql: buildCreateTableSql(qualified, table, codecHooks, storageTypes),
219
318
  },
220
319
  ],
221
320
  postcheck: [
@@ -230,91 +329,155 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
230
329
  }
231
330
 
232
331
  private buildColumnOperations(
233
- tables: SqlContract<SqlStorage>['storage']['tables'],
332
+ tables: ReadonlyArray<[string, StorageTable]>,
234
333
  schema: SqlSchemaIR,
334
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
235
335
  schemaName: string,
336
+ codecHooks: Map<string, CodecControlHooks>,
337
+ storageTypes: Record<string, StorageTypeInstance>,
236
338
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
237
339
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
238
- for (const [tableName, table] of sortedEntries(tables)) {
340
+ for (const [tableName, table] of tables) {
239
341
  const schemaTable = schema.tables[tableName];
240
342
  if (!schemaTable) {
241
343
  continue;
242
344
  }
345
+ const schemaLookup = schemaLookups.get(tableName);
243
346
  for (const [columnName, column] of sortedEntries(table.columns)) {
244
347
  if (schemaTable.columns[columnName]) {
245
348
  continue;
246
349
  }
247
- 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
+ );
248
363
  }
249
364
  }
250
365
  return operations;
251
366
  }
252
367
 
253
- private buildAddColumnOperation(
254
- schema: string,
255
- tableName: string,
256
- columnName: string,
257
- column: StorageColumn,
258
- ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
259
- const qualified = qualifyTableName(schema, tableName);
260
- const notNull = column.nullable === false;
261
- const precheck = [
262
- {
263
- description: `ensure column "${columnName}" is missing`,
264
- sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
265
- },
266
- ...(notNull
267
- ? [
268
- {
269
- description: `ensure table "${tableName}" is empty before adding NOT NULL column`,
270
- sql: tableIsEmptyCheck(qualified),
271
- },
272
- ]
273
- : []),
274
- ];
275
- const execute = [
276
- {
277
- description: `add column "${columnName}"`,
278
- sql: buildAddColumnSql(qualified, columnName, column),
279
- },
280
- ];
281
- const postcheck = [
282
- {
283
- description: `verify column "${columnName}" exists`,
284
- sql: columnExistsCheck({ schema, table: tableName, column: columnName }),
285
- },
286
- ...(notNull
287
- ? [
288
- {
289
- description: `verify column "${columnName}" is NOT NULL`,
290
- sql: columnIsNotNullCheck({ schema, table: tableName, column: columnName }),
291
- },
292
- ]
293
- : []),
294
- ];
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
+ });
295
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;
296
422
  return {
297
- id: `column.${tableName}.${columnName}`,
298
- label: `Add column ${columnName} to ${tableName}`,
299
- summary: `Adds column ${columnName} to table ${tableName}`,
423
+ ...buildAddColumnOperationIdentity(schema, tableName, columnName),
300
424
  operationClass: 'additive',
301
- target: {
302
- id: 'postgres',
303
- details: this.buildTargetDetails('table', tableName, schema),
304
- },
305
- precheck,
306
- execute,
307
- 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
+ ],
308
471
  };
309
472
  }
310
473
 
311
474
  private buildPrimaryKeyOperations(
312
- tables: SqlContract<SqlStorage>['storage']['tables'],
475
+ tables: ReadonlyArray<[string, StorageTable]>,
313
476
  schema: SqlSchemaIR,
314
477
  schemaName: string,
315
478
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
316
479
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
317
- for (const [tableName, table] of sortedEntries(tables)) {
480
+ for (const [tableName, table] of tables) {
318
481
  if (!table.primaryKey) {
319
482
  continue;
320
483
  }
@@ -358,15 +521,15 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
358
521
  }
359
522
 
360
523
  private buildUniqueOperations(
361
- tables: SqlContract<SqlStorage>['storage']['tables'],
362
- schema: SqlSchemaIR,
524
+ tables: ReadonlyArray<[string, StorageTable]>,
525
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
363
526
  schemaName: string,
364
527
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
365
528
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
366
- for (const [tableName, table] of sortedEntries(tables)) {
367
- const schemaTable = schema.tables[tableName];
529
+ for (const [tableName, table] of tables) {
530
+ const lookup = schemaLookups.get(tableName);
368
531
  for (const unique of table.uniques) {
369
- if (schemaTable && hasUniqueConstraint(schemaTable, unique.columns)) {
532
+ if (lookup && hasUniqueConstraint(lookup, unique.columns)) {
370
533
  continue;
371
534
  }
372
535
  const constraintName = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
@@ -382,7 +545,12 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
382
545
  precheck: [
383
546
  {
384
547
  description: `ensure unique constraint "${constraintName}" is missing`,
385
- sql: constraintExistsCheck({ constraintName, schema: schemaName, exists: false }),
548
+ sql: constraintExistsCheck({
549
+ constraintName,
550
+ schema: schemaName,
551
+ table: tableName,
552
+ exists: false,
553
+ }),
386
554
  },
387
555
  ],
388
556
  execute: [
@@ -396,7 +564,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
396
564
  postcheck: [
397
565
  {
398
566
  description: `verify unique constraint "${constraintName}" exists`,
399
- sql: constraintExistsCheck({ constraintName, schema: schemaName }),
567
+ sql: constraintExistsCheck({ constraintName, schema: schemaName, table: tableName }),
400
568
  },
401
569
  ],
402
570
  });
@@ -406,18 +574,18 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
406
574
  }
407
575
 
408
576
  private buildIndexOperations(
409
- tables: SqlContract<SqlStorage>['storage']['tables'],
410
- schema: SqlSchemaIR,
577
+ tables: ReadonlyArray<[string, StorageTable]>,
578
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
411
579
  schemaName: string,
412
580
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
413
581
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
414
- for (const [tableName, table] of sortedEntries(tables)) {
415
- const schemaTable = schema.tables[tableName];
582
+ for (const [tableName, table] of tables) {
583
+ const lookup = schemaLookups.get(tableName);
416
584
  for (const index of table.indexes) {
417
- if (schemaTable && hasIndex(schemaTable, index.columns)) {
585
+ if (lookup && hasIndex(lookup, index.columns)) {
418
586
  continue;
419
587
  }
420
- const indexName = index.name ?? `${tableName}_${index.columns.join('_')}_idx`;
588
+ const indexName = index.name ?? defaultIndexName(tableName, index.columns);
421
589
  operations.push({
422
590
  id: `index.${tableName}.${indexName}`,
423
591
  label: `Create index ${indexName} on ${tableName}`,
@@ -454,16 +622,76 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
454
622
  return operations;
455
623
  }
456
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
+
457
684
  private buildForeignKeyOperations(
458
- tables: SqlContract<SqlStorage>['storage']['tables'],
459
- schema: SqlSchemaIR,
685
+ tables: ReadonlyArray<[string, StorageTable]>,
686
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
460
687
  schemaName: string,
461
688
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
462
689
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
463
- for (const [tableName, table] of sortedEntries(tables)) {
464
- const schemaTable = schema.tables[tableName];
690
+ for (const [tableName, table] of tables) {
691
+ const lookup = schemaLookups.get(tableName);
465
692
  for (const foreignKey of table.foreignKeys) {
466
- if (schemaTable && hasForeignKey(schemaTable, foreignKey)) {
693
+ if (foreignKey.constraint === false) continue;
694
+ if (lookup && hasForeignKey(lookup, foreignKey)) {
467
695
  continue;
468
696
  }
469
697
  const fkName = foreignKey.name ?? `${tableName}_${foreignKey.columns.join('_')}_fkey`;
@@ -482,6 +710,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
482
710
  sql: constraintExistsCheck({
483
711
  constraintName: fkName,
484
712
  schema: schemaName,
713
+ table: tableName,
485
714
  exists: false,
486
715
  }),
487
716
  },
@@ -489,18 +718,17 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
489
718
  execute: [
490
719
  {
491
720
  description: `add foreign key "${fkName}"`,
492
- sql: `ALTER TABLE ${qualifyTableName(schemaName, tableName)}
493
- ADD CONSTRAINT ${quoteIdentifier(fkName)}
494
- FOREIGN KEY (${foreignKey.columns.map(quoteIdentifier).join(', ')})
495
- REFERENCES ${qualifyTableName(schemaName, foreignKey.references.table)} (${foreignKey.references.columns
496
- .map(quoteIdentifier)
497
- .join(', ')})`,
721
+ sql: buildForeignKeySql(schemaName, tableName, fkName, foreignKey),
498
722
  },
499
723
  ],
500
724
  postcheck: [
501
725
  {
502
726
  description: `verify foreign key "${fkName}" exists`,
503
- sql: constraintExistsCheck({ constraintName: fkName, schema: schemaName }),
727
+ sql: constraintExistsCheck({
728
+ constraintName: fkName,
729
+ schema: schemaName,
730
+ table: tableName,
731
+ }),
504
732
  },
505
733
  ],
506
734
  });
@@ -515,348 +743,83 @@ REFERENCES ${qualifyTableName(schemaName, foreignKey.references.table)} (${forei
515
743
  schema: string,
516
744
  table?: string,
517
745
  ): PostgresPlanTargetDetails {
518
- return {
519
- schema,
520
- objectType,
521
- name,
522
- ...(table ? { table } : {}),
523
- };
746
+ return buildTargetDetails(objectType, name, schema, table);
524
747
  }
525
748
 
526
- private classifySchema(options: PlannerOptionsWithComponents):
527
- | { kind: 'ok' }
528
- | {
529
- kind: 'conflict';
530
- conflicts: SqlPlannerConflict[];
531
- } {
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[] {
532
762
  const verifyOptions: VerifySqlSchemaOptionsWithComponents = {
533
763
  contract: options.contract,
534
764
  schema: options.schema,
535
- strict: false,
765
+ strict,
536
766
  typeMetadataRegistry: new Map(),
537
767
  frameworkComponents: options.frameworkComponents,
768
+ normalizeDefault: parsePostgresDefault,
769
+ normalizeNativeType: normalizeSchemaNativeType,
538
770
  };
539
771
  const verifyResult = verifySqlSchema(verifyOptions);
540
-
541
- const conflicts = this.extractConflicts(verifyResult.schema.issues);
542
- if (conflicts.length > 0) {
543
- return { kind: 'conflict', conflicts };
544
- }
545
- return { kind: 'ok' };
546
- }
547
-
548
- private extractConflicts(issues: readonly SchemaIssue[]): SqlPlannerConflict[] {
549
- const conflicts: SqlPlannerConflict[] = [];
550
- for (const issue of issues) {
551
- if (isAdditiveIssue(issue)) {
552
- continue;
553
- }
554
- const conflict = this.convertIssueToConflict(issue);
555
- if (conflict) {
556
- conflicts.push(conflict);
557
- }
558
- }
559
- return conflicts.sort(conflictComparator);
560
- }
561
-
562
- private convertIssueToConflict(issue: SchemaIssue): SqlPlannerConflict | null {
563
- switch (issue.kind) {
564
- case 'type_mismatch':
565
- return this.buildConflict('typeMismatch', issue);
566
- case 'nullability_mismatch':
567
- return this.buildConflict('nullabilityConflict', issue);
568
- case 'primary_key_mismatch':
569
- return this.buildConflict('indexIncompatible', issue);
570
- case 'unique_constraint_mismatch':
571
- return this.buildConflict('indexIncompatible', issue);
572
- case 'index_mismatch':
573
- return this.buildConflict('indexIncompatible', issue);
574
- case 'foreign_key_mismatch':
575
- return this.buildConflict('foreignKeyConflict', issue);
576
- default:
577
- return null;
578
- }
579
- }
580
-
581
- private buildConflict(kind: SqlPlannerConflict['kind'], issue: SchemaIssue): SqlPlannerConflict {
582
- const location = buildConflictLocation(issue);
583
- const meta =
584
- issue.expected || issue.actual
585
- ? Object.freeze({
586
- ...(issue.expected ? { expected: issue.expected } : {}),
587
- ...(issue.actual ? { actual: issue.actual } : {}),
588
- })
589
- : undefined;
590
-
591
- return {
592
- kind,
593
- summary: issue.message,
594
- ...(location ? { location } : {}),
595
- ...(meta ? { meta } : {}),
596
- };
772
+ return verifyResult.schema.issues;
597
773
  }
598
774
  }
599
775
 
600
- function isSqlDependencyProvider(component: unknown): component is {
601
- readonly databaseDependencies?: {
602
- readonly init?: readonly PlannerDatabaseDependency[];
603
- };
604
- } {
605
- 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) {
606
787
  return false;
607
788
  }
608
- const record = component as Record<string, unknown>;
609
789
 
610
- // If present, enforce familyId match to avoid mixing families at runtime.
611
- if (Object.hasOwn(record, 'familyId') && record['familyId'] !== 'sql') {
612
- 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
+ }
613
797
  }
614
798
 
615
- if (!Object.hasOwn(record, 'databaseDependencies')) {
616
- 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
+ }
617
806
  }
618
- const deps = record['databaseDependencies'];
619
- return deps === undefined || (typeof deps === 'object' && deps !== null);
807
+
808
+ return true;
620
809
  }
621
810
 
622
811
  function sortDependencies(
623
812
  dependencies: ReadonlyArray<PlannerDatabaseDependency>,
624
813
  ): ReadonlyArray<PlannerDatabaseDependency> {
625
- if (dependencies.length <= 1) {
626
- return dependencies;
627
- }
628
814
  return [...dependencies].sort((a, b) => a.id.localeCompare(b.id));
629
815
  }
630
816
 
631
- function buildCreateTableSql(qualifiedTableName: string, table: StorageTable): string {
632
- const columnDefinitions = Object.entries(table.columns).map(
633
- ([columnName, column]: [string, StorageColumn]) => {
634
- const parts = [
635
- quoteIdentifier(columnName),
636
- column.nativeType,
637
- column.nullable ? '' : 'NOT NULL',
638
- ].filter(Boolean);
639
- return parts.join(' ');
640
- },
641
- );
642
-
643
- const constraintDefinitions: string[] = [];
644
- if (table.primaryKey) {
645
- constraintDefinitions.push(
646
- `PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
647
- );
648
- }
649
-
650
- const allDefinitions = [...columnDefinitions, ...constraintDefinitions];
651
- return `CREATE TABLE ${qualifiedTableName} (\n ${allDefinitions.join(',\n ')}\n)`;
652
- }
653
-
654
- function qualifyTableName(schema: string, table: string): string {
655
- return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
656
- }
657
-
658
- function toRegclassLiteral(schema: string, name: string): string {
659
- const regclass = `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`;
660
- return `'${escapeLiteral(regclass)}'`;
661
- }
662
-
663
- /** Escapes and quotes a SQL identifier (table, column, schema name). */
664
- function quoteIdentifier(identifier: string): string {
665
- // TypeScript enforces string type - no runtime check needed for internal callers
666
- return `"${identifier.replace(/"/g, '""')}"`;
667
- }
668
-
669
- function escapeLiteral(value: string): string {
670
- 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');
671
821
  }
672
822
 
673
823
  function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]> {
674
824
  return Object.entries(record).sort(([a], [b]) => a.localeCompare(b)) as Array<[string, V]>;
675
825
  }
676
-
677
- function constraintExistsCheck({
678
- constraintName,
679
- schema,
680
- exists = true,
681
- }: {
682
- constraintName: string;
683
- schema: string;
684
- exists?: boolean;
685
- }): string {
686
- const existsClause = exists ? 'EXISTS' : 'NOT EXISTS';
687
- return `SELECT ${existsClause} (
688
- SELECT 1 FROM pg_constraint c
689
- JOIN pg_namespace n ON c.connamespace = n.oid
690
- WHERE c.conname = '${escapeLiteral(constraintName)}'
691
- AND n.nspname = '${escapeLiteral(schema)}'
692
- )`;
693
- }
694
-
695
- function columnExistsCheck({
696
- schema,
697
- table,
698
- column,
699
- exists = true,
700
- }: {
701
- schema: string;
702
- table: string;
703
- column: string;
704
- exists?: boolean;
705
- }): string {
706
- const existsClause = exists ? '' : 'NOT ';
707
- return `SELECT ${existsClause}EXISTS (
708
- SELECT 1
709
- FROM information_schema.columns
710
- WHERE table_schema = '${escapeLiteral(schema)}'
711
- AND table_name = '${escapeLiteral(table)}'
712
- AND column_name = '${escapeLiteral(column)}'
713
- )`;
714
- }
715
-
716
- function columnIsNotNullCheck({
717
- schema,
718
- table,
719
- column,
720
- }: {
721
- schema: string;
722
- table: string;
723
- column: string;
724
- }): string {
725
- return `SELECT EXISTS (
726
- SELECT 1
727
- FROM information_schema.columns
728
- WHERE table_schema = '${escapeLiteral(schema)}'
729
- AND table_name = '${escapeLiteral(table)}'
730
- AND column_name = '${escapeLiteral(column)}'
731
- AND is_nullable = 'NO'
732
- )`;
733
- }
734
-
735
- function tableIsEmptyCheck(qualifiedTableName: string): string {
736
- return `SELECT NOT EXISTS (SELECT 1 FROM ${qualifiedTableName} LIMIT 1)`;
737
- }
738
-
739
- function buildAddColumnSql(
740
- qualifiedTableName: string,
741
- columnName: string,
742
- column: StorageColumn,
743
- ): string {
744
- const parts = [
745
- `ALTER TABLE ${qualifiedTableName}`,
746
- `ADD COLUMN ${quoteIdentifier(columnName)} ${column.nativeType}`,
747
- column.nullable ? '' : 'NOT NULL',
748
- ].filter(Boolean);
749
- return parts.join(' ');
750
- }
751
-
752
- function tableHasPrimaryKeyCheck(
753
- schema: string,
754
- table: string,
755
- exists: boolean,
756
- constraintName?: string,
757
- ): string {
758
- const comparison = exists ? '' : 'NOT ';
759
- const constraintFilter = constraintName
760
- ? `AND c2.relname = '${escapeLiteral(constraintName)}'`
761
- : '';
762
- return `SELECT ${comparison}EXISTS (
763
- SELECT 1
764
- FROM pg_index i
765
- JOIN pg_class c ON c.oid = i.indrelid
766
- JOIN pg_namespace n ON n.oid = c.relnamespace
767
- LEFT JOIN pg_class c2 ON c2.oid = i.indexrelid
768
- WHERE n.nspname = '${escapeLiteral(schema)}'
769
- AND c.relname = '${escapeLiteral(table)}'
770
- AND i.indisprimary
771
- ${constraintFilter}
772
- )`;
773
- }
774
-
775
- function hasUniqueConstraint(
776
- table: SqlSchemaIR['tables'][string],
777
- columns: readonly string[],
778
- ): boolean {
779
- return table.uniques.some((unique) => arraysEqual(unique.columns, columns));
780
- }
781
-
782
- function hasIndex(table: SqlSchemaIR['tables'][string], columns: readonly string[]): boolean {
783
- return table.indexes.some((index) => !index.unique && arraysEqual(index.columns, columns));
784
- }
785
-
786
- function hasForeignKey(table: SqlSchemaIR['tables'][string], fk: ForeignKey): boolean {
787
- return table.foreignKeys.some(
788
- (candidate) =>
789
- arraysEqual(candidate.columns, fk.columns) &&
790
- candidate.referencedTable === fk.references.table &&
791
- arraysEqual(candidate.referencedColumns, fk.references.columns),
792
- );
793
- }
794
-
795
- function isAdditiveIssue(issue: SchemaIssue): boolean {
796
- switch (issue.kind) {
797
- case 'missing_table':
798
- case 'missing_column':
799
- case 'extension_missing':
800
- return true;
801
- case 'primary_key_mismatch':
802
- return issue.actual === undefined;
803
- case 'unique_constraint_mismatch':
804
- case 'index_mismatch':
805
- case 'foreign_key_mismatch':
806
- return issue.indexOrConstraint === undefined;
807
- default:
808
- return false;
809
- }
810
- }
811
-
812
- function buildConflictLocation(issue: SchemaIssue) {
813
- const location: {
814
- table?: string;
815
- column?: string;
816
- constraint?: string;
817
- } = {};
818
- if (issue.table) {
819
- location.table = issue.table;
820
- }
821
- if (issue.column) {
822
- location.column = issue.column;
823
- }
824
- if (issue.indexOrConstraint) {
825
- location.constraint = issue.indexOrConstraint;
826
- }
827
- return Object.keys(location).length > 0 ? location : undefined;
828
- }
829
-
830
- function conflictComparator(a: SqlPlannerConflict, b: SqlPlannerConflict): number {
831
- if (a.kind !== b.kind) {
832
- return a.kind < b.kind ? -1 : 1;
833
- }
834
- const aLocation = a.location ?? {};
835
- const bLocation = b.location ?? {};
836
- const tableCompare = compareStrings(aLocation.table, bLocation.table);
837
- if (tableCompare !== 0) {
838
- return tableCompare;
839
- }
840
- const columnCompare = compareStrings(aLocation.column, bLocation.column);
841
- if (columnCompare !== 0) {
842
- return columnCompare;
843
- }
844
- const constraintCompare = compareStrings(aLocation.constraint, bLocation.constraint);
845
- if (constraintCompare !== 0) {
846
- return constraintCompare;
847
- }
848
- return compareStrings(a.summary, b.summary);
849
- }
850
-
851
- function compareStrings(a?: string, b?: string): number {
852
- if (a === b) {
853
- return 0;
854
- }
855
- if (a === undefined) {
856
- return -1;
857
- }
858
- if (b === undefined) {
859
- return 1;
860
- }
861
- return a < b ? -1 : 1;
862
- }