@prisma-next/target-postgres 0.3.0-dev.10 → 0.3.0-dev.113

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 (51) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +9 -2
  3. package/dist/control.d.mts +19 -0
  4. package/dist/control.d.mts.map +1 -0
  5. package/dist/control.mjs +3513 -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 +10 -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 +34 -33
  18. package/src/core/migrations/planner-identity-values.ts +129 -0
  19. package/src/core/migrations/planner-recipes.ts +83 -0
  20. package/src/core/migrations/planner-reconciliation.ts +613 -0
  21. package/src/core/migrations/planner-sql.ts +329 -0
  22. package/src/core/migrations/planner-target-details.ts +16 -0
  23. package/src/core/migrations/planner.ts +411 -406
  24. package/src/core/migrations/runner.ts +32 -36
  25. package/src/core/migrations/statement-builders.ts +9 -7
  26. package/src/core/types.ts +5 -0
  27. package/src/exports/control.ts +56 -8
  28. package/src/exports/pack.ts +5 -2
  29. package/src/exports/runtime.ts +7 -12
  30. package/dist/chunk-RKEXRSSI.js +0 -14
  31. package/dist/chunk-RKEXRSSI.js.map +0 -1
  32. package/dist/core/descriptor-meta.d.ts +0 -9
  33. package/dist/core/descriptor-meta.d.ts.map +0 -1
  34. package/dist/core/migrations/planner.d.ts +0 -14
  35. package/dist/core/migrations/planner.d.ts.map +0 -1
  36. package/dist/core/migrations/runner.d.ts +0 -8
  37. package/dist/core/migrations/runner.d.ts.map +0 -1
  38. package/dist/core/migrations/statement-builders.d.ts +0 -30
  39. package/dist/core/migrations/statement-builders.d.ts.map +0 -1
  40. package/dist/exports/control.d.ts +0 -8
  41. package/dist/exports/control.d.ts.map +0 -1
  42. package/dist/exports/control.js +0 -1255
  43. package/dist/exports/control.js.map +0 -1
  44. package/dist/exports/pack.d.ts +0 -4
  45. package/dist/exports/pack.d.ts.map +0 -1
  46. package/dist/exports/pack.js +0 -11
  47. package/dist/exports/pack.js.map +0 -1
  48. package/dist/exports/runtime.d.ts +0 -12
  49. package/dist/exports/runtime.d.ts.map +0 -1
  50. package/dist/exports/runtime.js +0 -19
  51. package/dist/exports/runtime.js.map +0 -1
@@ -1,5 +1,13 @@
1
+ import {
2
+ escapeLiteral,
3
+ normalizeSchemaNativeType,
4
+ parsePostgresDefault,
5
+ quoteIdentifier,
6
+ } from '@prisma-next/adapter-postgres/control';
1
7
  import type { SchemaIssue } from '@prisma-next/core-control-plane/types';
2
8
  import type {
9
+ CodecControlHooks,
10
+ ComponentDatabaseDependency,
3
11
  MigrationOperationPolicy,
4
12
  SqlMigrationPlanner,
5
13
  SqlMigrationPlannerPlanOptions,
@@ -7,21 +15,45 @@ import type {
7
15
  SqlPlannerConflict,
8
16
  } from '@prisma-next/family-sql/control';
9
17
  import {
18
+ collectInitDependencies,
10
19
  createMigrationPlan,
20
+ extractCodecControlHooks,
11
21
  plannerFailure,
12
22
  plannerSuccess,
13
23
  } from '@prisma-next/family-sql/control';
14
- import { arraysEqual, verifySqlSchema } from '@prisma-next/family-sql/schema-verify';
15
- import type {
16
- ForeignKey,
17
- SqlContract,
18
- SqlStorage,
19
- StorageColumn,
20
- StorageTable,
21
- } from '@prisma-next/sql-contract/types';
24
+ import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify';
25
+ import type { ForeignKey, StorageColumn, StorageTable } from '@prisma-next/sql-contract/types';
26
+ import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
22
27
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
23
-
24
- type OperationClass = 'extension' | 'table' | 'unique' | 'index' | 'foreignKey';
28
+ import { ifDefined } from '@prisma-next/utils/defined';
29
+ import { resolveIdentityValue } from './planner-identity-values';
30
+ import {
31
+ buildAddColumnOperationIdentity,
32
+ buildAddNotNullColumnWithTemporaryDefaultOperation,
33
+ } from './planner-recipes';
34
+ import { buildReconciliationPlan } from './planner-reconciliation';
35
+ import {
36
+ buildAddColumnSql,
37
+ buildCreateTableSql,
38
+ buildForeignKeySql,
39
+ columnExistsCheck,
40
+ columnNullabilityCheck,
41
+ constraintExistsCheck,
42
+ qualifyTableName,
43
+ tableIsEmptyCheck,
44
+ toRegclassLiteral,
45
+ } from './planner-sql';
46
+ import { buildTargetDetails } from './planner-target-details';
47
+
48
+ export type OperationClass =
49
+ | 'dependency'
50
+ | 'type'
51
+ | 'table'
52
+ | 'column'
53
+ | 'primaryKey'
54
+ | 'unique'
55
+ | 'index'
56
+ | 'foreignKey';
25
57
 
26
58
  type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends {
27
59
  readonly frameworkComponents: infer T;
@@ -41,7 +73,6 @@ type PlannerDatabaseDependency = {
41
73
  readonly id: string;
42
74
  readonly label: string;
43
75
  readonly install: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
44
- readonly verifyDatabaseDependencyInstalled: (schema: SqlSchemaIR) => readonly SchemaIssue[];
45
76
  };
46
77
 
47
78
  export interface PostgresPlanTargetDetails {
@@ -55,6 +86,12 @@ interface PlannerConfig {
55
86
  readonly defaultSchema: string;
56
87
  }
57
88
 
89
+ export interface PlanningMode {
90
+ readonly includeExtraObjects: boolean;
91
+ readonly allowWidening: boolean;
92
+ readonly allowDestructive: boolean;
93
+ }
94
+
58
95
  const DEFAULT_PLANNER_CONFIG: PlannerConfig = {
59
96
  defaultSchema: 'public',
60
97
  };
@@ -78,38 +115,64 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
78
115
  return policyResult;
79
116
  }
80
117
 
81
- const classification = this.classifySchema(options);
82
- if (classification.kind === 'conflict') {
83
- return plannerFailure(classification.conflicts);
84
- }
118
+ const planningMode = this.resolvePlanningMode(options.policy);
119
+ const schemaIssues = this.collectSchemaIssues(options, planningMode.includeExtraObjects);
120
+
121
+ // Extract codec control hooks once at entry point for reuse across all operations.
122
+ // This avoids repeated iteration over frameworkComponents for each method that needs hooks.
123
+ const codecHooks = extractCodecControlHooks(options.frameworkComponents);
85
124
 
86
125
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
87
126
 
127
+ const reconciliationPlan = buildReconciliationPlan({
128
+ contract: options.contract,
129
+ issues: schemaIssues,
130
+ schemaName,
131
+ mode: planningMode,
132
+ policy: options.policy,
133
+ codecHooks,
134
+ });
135
+ if (reconciliationPlan.conflicts.length > 0) {
136
+ return plannerFailure(reconciliationPlan.conflicts);
137
+ }
138
+
139
+ const storageTypePlan = this.buildStorageTypeOperations(options, schemaName, codecHooks);
140
+ if (storageTypePlan.conflicts.length > 0) {
141
+ return plannerFailure(storageTypePlan.conflicts);
142
+ }
143
+
144
+ // Sort table entries once for reuse across all additive operation builders.
145
+ const sortedTables = sortedEntries(options.contract.storage.tables);
146
+
147
+ // Pre-compute constraint lookups once per schema table for O(1) checks across all builders.
148
+ const schemaLookups = buildSchemaLookupMap(options.schema);
149
+
88
150
  // Build extension operations from component-owned database dependencies
89
151
  operations.push(
90
152
  ...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,
95
- options.schema,
96
- schemaName,
97
- ),
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,
153
+ ...storageTypePlan.operations,
154
+ ...reconciliationPlan.operations,
155
+ ...this.buildTableOperations(sortedTables, options.schema, schemaName, codecHooks),
156
+ ...this.buildColumnOperations(
157
+ sortedTables,
102
158
  options.schema,
159
+ schemaLookups,
103
160
  schemaName,
161
+ codecHooks,
104
162
  ),
163
+ ...this.buildPrimaryKeyOperations(sortedTables, options.schema, schemaName),
164
+ ...this.buildUniqueOperations(sortedTables, schemaLookups, schemaName),
165
+ ...this.buildIndexOperations(sortedTables, schemaLookups, schemaName),
166
+ ...this.buildFkBackingIndexOperations(sortedTables, schemaLookups, schemaName),
167
+ ...this.buildForeignKeyOperations(sortedTables, schemaLookups, schemaName),
105
168
  );
106
169
 
107
170
  const plan = createMigrationPlan<PostgresPlanTargetDetails>({
108
171
  targetId: 'postgres',
109
172
  origin: null,
110
173
  destination: {
111
- coreHash: options.contract.coreHash,
112
- ...(options.contract.profileHash ? { profileHash: options.contract.profileHash } : {}),
174
+ storageHash: options.contract.storageHash,
175
+ ...ifDefined('profileHash', options.contract.profileHash),
113
176
  },
114
177
  operations,
115
178
  });
@@ -122,8 +185,8 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
122
185
  return plannerFailure([
123
186
  {
124
187
  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".',
188
+ summary: 'Migration planner requires additive operations be allowed',
189
+ why: 'The planner requires the "additive" operation class to be allowed in the policy.',
127
190
  },
128
191
  ]);
129
192
  }
@@ -142,14 +205,15 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
142
205
  const seenDependencyIds = new Set<string>();
143
206
  const seenOperationIds = new Set<string>();
144
207
 
208
+ const installedIds = new Set(options.schema.dependencies.map((d) => d.id));
209
+
145
210
  for (const dependency of dependencies) {
146
211
  if (seenDependencyIds.has(dependency.id)) {
147
212
  continue;
148
213
  }
149
214
  seenDependencyIds.add(dependency.id);
150
215
 
151
- const issues = dependency.verifyDatabaseDependencyInstalled(options.schema);
152
- if (issues.length === 0) {
216
+ if (installedIds.has(dependency.id)) {
153
217
  continue;
154
218
  }
155
219
 
@@ -158,41 +222,76 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
158
222
  continue;
159
223
  }
160
224
  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
225
  operations.push(installOp as SqlMigrationPlanOperation<PostgresPlanTargetDetails>);
164
226
  }
165
227
  }
166
228
 
167
229
  return operations;
168
230
  }
169
- private collectDependencies(
231
+
232
+ private buildStorageTypeOperations(
170
233
  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)) {
234
+ schemaName: string,
235
+ codecHooks: Map<string, CodecControlHooks>,
236
+ ): {
237
+ readonly operations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
238
+ readonly conflicts: readonly SqlPlannerConflict[];
239
+ } {
240
+ const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
241
+ const conflicts: SqlPlannerConflict[] = [];
242
+ const storageTypes = options.contract.storage.types ?? {};
243
+
244
+ for (const [typeName, typeInstance] of sortedEntries(storageTypes)) {
245
+ const hook = codecHooks.get(typeInstance.codecId);
246
+ const planResult = hook?.planTypeOperations?.({
247
+ typeName,
248
+ typeInstance,
249
+ contract: options.contract,
250
+ schema: options.schema,
251
+ schemaName,
252
+ policy: options.policy,
253
+ });
254
+ if (!planResult) {
179
255
  continue;
180
256
  }
181
- const initDeps = component.databaseDependencies?.init;
182
- if (initDeps && initDeps.length > 0) {
183
- deps.push(...initDeps);
257
+ for (const operation of planResult.operations) {
258
+ if (!options.policy.allowedOperationClasses.includes(operation.operationClass)) {
259
+ conflicts.push({
260
+ kind: 'missingButNonAdditive',
261
+ summary: `Storage type "${typeName}" requires "${operation.operationClass}" operation "${operation.id}"`,
262
+ location: {
263
+ type: typeName,
264
+ },
265
+ });
266
+ continue;
267
+ }
268
+ operations.push({
269
+ ...operation,
270
+ target: {
271
+ id: operation.target.id,
272
+ details: this.buildTargetDetails('type', typeName, schemaName),
273
+ },
274
+ });
184
275
  }
185
276
  }
186
- return sortDependencies(deps);
277
+
278
+ return { operations, conflicts };
279
+ }
280
+ private collectDependencies(
281
+ options: PlannerOptionsWithComponents,
282
+ ): ReadonlyArray<PlannerDatabaseDependency> {
283
+ const dependencies = collectInitDependencies(options.frameworkComponents);
284
+ return sortDependencies(dependencies.filter(isPostgresPlannerDependency));
187
285
  }
188
286
 
189
287
  private buildTableOperations(
190
- tables: SqlContract<SqlStorage>['storage']['tables'],
288
+ tables: ReadonlyArray<[string, StorageTable]>,
191
289
  schema: SqlSchemaIR,
192
290
  schemaName: string,
291
+ codecHooks: Map<string, CodecControlHooks>,
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),
219
318
  },
220
319
  ],
221
320
  postcheck: [
@@ -230,91 +329,135 @@ 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>,
236
337
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
237
338
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
238
- for (const [tableName, table] of sortedEntries(tables)) {
339
+ for (const [tableName, table] of tables) {
239
340
  const schemaTable = schema.tables[tableName];
240
341
  if (!schemaTable) {
241
342
  continue;
242
343
  }
344
+ const schemaLookup = schemaLookups.get(tableName);
243
345
  for (const [columnName, column] of sortedEntries(table.columns)) {
244
346
  if (schemaTable.columns[columnName]) {
245
347
  continue;
246
348
  }
247
- operations.push(this.buildAddColumnOperation(schemaName, tableName, columnName, column));
349
+ operations.push(
350
+ this.buildAddColumnOperation({
351
+ schema: schemaName,
352
+ tableName,
353
+ table,
354
+ schemaTable,
355
+ schemaLookup,
356
+ columnName,
357
+ column,
358
+ codecHooks,
359
+ }),
360
+ );
248
361
  }
249
362
  }
250
363
  return operations;
251
364
  }
252
365
 
253
- private buildAddColumnOperation(
254
- schema: string,
255
- tableName: string,
256
- columnName: string,
257
- column: StorageColumn,
258
- ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
259
- const qualified = qualifyTableName(schema, tableName);
366
+ private buildAddColumnOperation(options: {
367
+ readonly schema: string;
368
+ readonly tableName: string;
369
+ readonly table: StorageTable;
370
+ readonly schemaTable: SqlSchemaIR['tables'][string];
371
+ readonly schemaLookup: SchemaTableLookup | undefined;
372
+ readonly columnName: string;
373
+ readonly column: StorageColumn;
374
+ readonly codecHooks: Map<string, CodecControlHooks>;
375
+ }): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
376
+ const { schema, tableName, table, schemaTable, schemaLookup, columnName, column, codecHooks } =
377
+ options;
260
378
  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
- ];
379
+ const hasDefault = column.default !== undefined;
380
+ // Planner logic decides whether this column needs the coordinated multi-step
381
+ // strategy. The strategy recipe itself is built by a dedicated helper.
382
+ const needsTemporaryDefault = notNull && !hasDefault;
383
+ const temporaryDefault = needsTemporaryDefault
384
+ ? resolveIdentityValue(column, codecHooks)
385
+ : null;
386
+ const canUseSharedTemporaryDefault =
387
+ needsTemporaryDefault &&
388
+ temporaryDefault !== null &&
389
+ canUseSharedTemporaryDefaultStrategy({
390
+ table,
391
+ schemaTable,
392
+ schemaLookup,
393
+ columnName,
394
+ });
395
+
396
+ if (canUseSharedTemporaryDefault) {
397
+ return buildAddNotNullColumnWithTemporaryDefaultOperation({
398
+ schema,
399
+ tableName,
400
+ columnName,
401
+ column,
402
+ codecHooks,
403
+ temporaryDefault,
404
+ });
405
+ }
295
406
 
407
+ const qualified = qualifyTableName(schema, tableName);
408
+ const requiresEmptyTableCheck = needsTemporaryDefault && !canUseSharedTemporaryDefault;
296
409
  return {
297
- id: `column.${tableName}.${columnName}`,
298
- label: `Add column ${columnName} to ${tableName}`,
299
- summary: `Adds column ${columnName} to table ${tableName}`,
410
+ ...buildAddColumnOperationIdentity(schema, tableName, columnName),
300
411
  operationClass: 'additive',
301
- target: {
302
- id: 'postgres',
303
- details: this.buildTargetDetails('table', tableName, schema),
304
- },
305
- precheck,
306
- execute,
307
- postcheck,
412
+ precheck: [
413
+ {
414
+ description: `ensure column "${columnName}" is missing`,
415
+ sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
416
+ },
417
+ ...(requiresEmptyTableCheck
418
+ ? [
419
+ {
420
+ description: `ensure table "${tableName}" is empty before adding NOT NULL column without default`,
421
+ sql: tableIsEmptyCheck(qualified),
422
+ },
423
+ ]
424
+ : []),
425
+ ],
426
+ execute: [
427
+ {
428
+ description: `add column "${columnName}"`,
429
+ sql: buildAddColumnSql(qualified, columnName, column, codecHooks),
430
+ },
431
+ ],
432
+ postcheck: [
433
+ {
434
+ description: `verify column "${columnName}" exists`,
435
+ sql: columnExistsCheck({ schema, table: tableName, column: columnName }),
436
+ },
437
+ ...(notNull
438
+ ? [
439
+ {
440
+ description: `verify column "${columnName}" is NOT NULL`,
441
+ sql: columnNullabilityCheck({
442
+ schema,
443
+ table: tableName,
444
+ column: columnName,
445
+ nullable: false,
446
+ }),
447
+ },
448
+ ]
449
+ : []),
450
+ ],
308
451
  };
309
452
  }
310
453
 
311
454
  private buildPrimaryKeyOperations(
312
- tables: SqlContract<SqlStorage>['storage']['tables'],
455
+ tables: ReadonlyArray<[string, StorageTable]>,
313
456
  schema: SqlSchemaIR,
314
457
  schemaName: string,
315
458
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
316
459
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
317
- for (const [tableName, table] of sortedEntries(tables)) {
460
+ for (const [tableName, table] of tables) {
318
461
  if (!table.primaryKey) {
319
462
  continue;
320
463
  }
@@ -358,15 +501,15 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
358
501
  }
359
502
 
360
503
  private buildUniqueOperations(
361
- tables: SqlContract<SqlStorage>['storage']['tables'],
362
- schema: SqlSchemaIR,
504
+ tables: ReadonlyArray<[string, StorageTable]>,
505
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
363
506
  schemaName: string,
364
507
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
365
508
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
366
- for (const [tableName, table] of sortedEntries(tables)) {
367
- const schemaTable = schema.tables[tableName];
509
+ for (const [tableName, table] of tables) {
510
+ const lookup = schemaLookups.get(tableName);
368
511
  for (const unique of table.uniques) {
369
- if (schemaTable && hasUniqueConstraint(schemaTable, unique.columns)) {
512
+ if (lookup && hasUniqueConstraint(lookup, unique.columns)) {
370
513
  continue;
371
514
  }
372
515
  const constraintName = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
@@ -406,18 +549,18 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
406
549
  }
407
550
 
408
551
  private buildIndexOperations(
409
- tables: SqlContract<SqlStorage>['storage']['tables'],
410
- schema: SqlSchemaIR,
552
+ tables: ReadonlyArray<[string, StorageTable]>,
553
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
411
554
  schemaName: string,
412
555
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
413
556
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
414
- for (const [tableName, table] of sortedEntries(tables)) {
415
- const schemaTable = schema.tables[tableName];
557
+ for (const [tableName, table] of tables) {
558
+ const lookup = schemaLookups.get(tableName);
416
559
  for (const index of table.indexes) {
417
- if (schemaTable && hasIndex(schemaTable, index.columns)) {
560
+ if (lookup && hasIndex(lookup, index.columns)) {
418
561
  continue;
419
562
  }
420
- const indexName = index.name ?? `${tableName}_${index.columns.join('_')}_idx`;
563
+ const indexName = index.name ?? defaultIndexName(tableName, index.columns);
421
564
  operations.push({
422
565
  id: `index.${tableName}.${indexName}`,
423
566
  label: `Create index ${indexName} on ${tableName}`,
@@ -454,16 +597,76 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
454
597
  return operations;
455
598
  }
456
599
 
600
+ /**
601
+ * Generates FK-backing index operations for FKs with `index: true`,
602
+ * but only when no matching user-declared index exists in `contractTable.indexes`.
603
+ */
604
+ private buildFkBackingIndexOperations(
605
+ tables: ReadonlyArray<[string, StorageTable]>,
606
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
607
+ schemaName: string,
608
+ ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
609
+ const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
610
+ for (const [tableName, table] of tables) {
611
+ const lookup = schemaLookups.get(tableName);
612
+ // Collect column sets of user-declared indexes to avoid duplicates
613
+ const declaredIndexColumns = new Set(table.indexes.map((idx) => idx.columns.join(',')));
614
+
615
+ for (const fk of table.foreignKeys) {
616
+ if (fk.index === false) continue;
617
+ // Skip if user already declared an index with these columns
618
+ if (declaredIndexColumns.has(fk.columns.join(','))) continue;
619
+ // Skip if the index already exists in the database
620
+ if (lookup && hasIndex(lookup, fk.columns)) continue;
621
+
622
+ const indexName = defaultIndexName(tableName, fk.columns);
623
+ operations.push({
624
+ id: `index.${tableName}.${indexName}`,
625
+ label: `Create FK-backing index ${indexName} on ${tableName}`,
626
+ summary: `Creates FK-backing index ${indexName} on ${tableName}`,
627
+ operationClass: 'additive',
628
+ target: {
629
+ id: 'postgres',
630
+ details: this.buildTargetDetails('index', indexName, schemaName, tableName),
631
+ },
632
+ precheck: [
633
+ {
634
+ description: `ensure index "${indexName}" is missing`,
635
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NULL`,
636
+ },
637
+ ],
638
+ execute: [
639
+ {
640
+ description: `create FK-backing index "${indexName}"`,
641
+ sql: `CREATE INDEX ${quoteIdentifier(indexName)} ON ${qualifyTableName(
642
+ schemaName,
643
+ tableName,
644
+ )} (${fk.columns.map(quoteIdentifier).join(', ')})`,
645
+ },
646
+ ],
647
+ postcheck: [
648
+ {
649
+ description: `verify index "${indexName}" exists`,
650
+ sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NOT NULL`,
651
+ },
652
+ ],
653
+ });
654
+ }
655
+ }
656
+ return operations;
657
+ }
658
+
457
659
  private buildForeignKeyOperations(
458
- tables: SqlContract<SqlStorage>['storage']['tables'],
459
- schema: SqlSchemaIR,
660
+ tables: ReadonlyArray<[string, StorageTable]>,
661
+ schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
460
662
  schemaName: string,
461
663
  ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
462
664
  const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
463
- for (const [tableName, table] of sortedEntries(tables)) {
464
- const schemaTable = schema.tables[tableName];
665
+ for (const [tableName, table] of tables) {
666
+ const lookup = schemaLookups.get(tableName);
465
667
  for (const foreignKey of table.foreignKeys) {
466
- if (schemaTable && hasForeignKey(schemaTable, foreignKey)) {
668
+ if (foreignKey.constraint === false) continue;
669
+ if (lookup && hasForeignKey(lookup, foreignKey)) {
467
670
  continue;
468
671
  }
469
672
  const fkName = foreignKey.name ?? `${tableName}_${foreignKey.columns.join('_')}_fkey`;
@@ -489,12 +692,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
489
692
  execute: [
490
693
  {
491
694
  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(', ')})`,
695
+ sql: buildForeignKeySql(schemaName, tableName, fkName, foreignKey),
498
696
  },
499
697
  ],
500
698
  postcheck: [
@@ -515,240 +713,87 @@ REFERENCES ${qualifyTableName(schemaName, foreignKey.references.table)} (${forei
515
713
  schema: string,
516
714
  table?: string,
517
715
  ): PostgresPlanTargetDetails {
518
- return {
519
- schema,
520
- objectType,
521
- name,
522
- ...(table ? { table } : {}),
523
- };
716
+ return buildTargetDetails(objectType, name, schema, table);
717
+ }
718
+
719
+ private resolvePlanningMode(policy: MigrationOperationPolicy): PlanningMode {
720
+ const allowWidening = policy.allowedOperationClasses.includes('widening');
721
+ const allowDestructive = policy.allowedOperationClasses.includes('destructive');
722
+ // `db init` uses additive-only policy and intentionally ignores extras.
723
+ // Any reconciliation-capable policy should inspect extras to reconcile strict equality.
724
+ const includeExtraObjects = allowWidening || allowDestructive;
725
+ return { includeExtraObjects, allowWidening, allowDestructive };
524
726
  }
525
727
 
526
- private classifySchema(options: PlannerOptionsWithComponents):
527
- | { kind: 'ok' }
528
- | {
529
- kind: 'conflict';
530
- conflicts: SqlPlannerConflict[];
531
- } {
728
+ private collectSchemaIssues(
729
+ options: PlannerOptionsWithComponents,
730
+ strict: boolean,
731
+ ): readonly SchemaIssue[] {
532
732
  const verifyOptions: VerifySqlSchemaOptionsWithComponents = {
533
733
  contract: options.contract,
534
734
  schema: options.schema,
535
- strict: false,
735
+ strict,
536
736
  typeMetadataRegistry: new Map(),
537
737
  frameworkComponents: options.frameworkComponents,
738
+ normalizeDefault: parsePostgresDefault,
739
+ normalizeNativeType: normalizeSchemaNativeType,
538
740
  };
539
741
  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
- };
742
+ return verifyResult.schema.issues;
597
743
  }
598
744
  }
599
745
 
600
- function isSqlDependencyProvider(component: unknown): component is {
601
- readonly databaseDependencies?: {
602
- readonly init?: readonly PlannerDatabaseDependency[];
603
- };
604
- } {
605
- if (typeof component !== 'object' || component === null) {
746
+ function canUseSharedTemporaryDefaultStrategy(options: {
747
+ readonly table: StorageTable;
748
+ readonly schemaTable: SqlSchemaIR['tables'][string];
749
+ readonly schemaLookup: SchemaTableLookup | undefined;
750
+ readonly columnName: string;
751
+ }): boolean {
752
+ const { table, schemaTable, schemaLookup, columnName } = options;
753
+
754
+ // Shared placeholders are only safe when later plan steps do not require
755
+ // row-specific values for this newly added column.
756
+ if (table.primaryKey?.columns.includes(columnName) && !schemaTable.primaryKey) {
606
757
  return false;
607
758
  }
608
- const record = component as Record<string, unknown>;
609
759
 
610
- // If present, enforce familyId match to avoid mixing families at runtime.
611
- if (Object.hasOwn(record, 'familyId') && record['familyId'] !== 'sql') {
612
- return false;
760
+ for (const unique of table.uniques) {
761
+ if (!unique.columns.includes(columnName)) {
762
+ continue;
763
+ }
764
+ if (!schemaLookup || !hasUniqueConstraint(schemaLookup, unique.columns)) {
765
+ return false;
766
+ }
613
767
  }
614
768
 
615
- if (!Object.hasOwn(record, 'databaseDependencies')) {
616
- return false;
769
+ for (const foreignKey of table.foreignKeys) {
770
+ if (foreignKey.constraint === false || !foreignKey.columns.includes(columnName)) {
771
+ continue;
772
+ }
773
+ if (!schemaLookup || !hasForeignKey(schemaLookup, foreignKey)) {
774
+ return false;
775
+ }
617
776
  }
618
- const deps = record['databaseDependencies'];
619
- return deps === undefined || (typeof deps === 'object' && deps !== null);
777
+
778
+ return true;
620
779
  }
621
780
 
622
781
  function sortDependencies(
623
782
  dependencies: ReadonlyArray<PlannerDatabaseDependency>,
624
783
  ): ReadonlyArray<PlannerDatabaseDependency> {
625
- if (dependencies.length <= 1) {
626
- return dependencies;
627
- }
628
784
  return [...dependencies].sort((a, b) => a.id.localeCompare(b.id));
629
785
  }
630
786
 
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, "''");
787
+ function isPostgresPlannerDependency(
788
+ dependency: ComponentDatabaseDependency<unknown>,
789
+ ): dependency is PlannerDatabaseDependency {
790
+ return dependency.install.every((operation) => operation.target.id === 'postgres');
671
791
  }
672
792
 
673
793
  function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]> {
674
794
  return Object.entries(record).sort(([a], [b]) => a.localeCompare(b)) as Array<[string, V]>;
675
795
  }
676
796
 
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
797
  function tableHasPrimaryKeyCheck(
753
798
  schema: string,
754
799
  table: string,
@@ -772,91 +817,51 @@ function tableHasPrimaryKeyCheck(
772
817
  )`;
773
818
  }
774
819
 
775
- function hasUniqueConstraint(
776
- table: SqlSchemaIR['tables'][string],
777
- columns: readonly string[],
778
- ): boolean {
779
- return table.uniques.some((unique) => arraysEqual(unique.columns, columns));
820
+ /**
821
+ * Pre-computed lookup sets for a schema table's constraints.
822
+ * Converts O(n*m) linear scans to O(1) Set lookups per constraint check.
823
+ */
824
+ interface SchemaTableLookup {
825
+ readonly uniqueKeys: Set<string>;
826
+ readonly indexKeys: Set<string>;
827
+ readonly uniqueIndexKeys: Set<string>;
828
+ readonly fkKeys: Set<string>;
780
829
  }
781
830
 
782
- function hasIndex(table: SqlSchemaIR['tables'][string], columns: readonly string[]): boolean {
783
- return table.indexes.some((index) => !index.unique && arraysEqual(index.columns, columns));
831
+ function buildSchemaLookupMap(schema: SqlSchemaIR): ReadonlyMap<string, SchemaTableLookup> {
832
+ const map = new Map<string, SchemaTableLookup>();
833
+ for (const [tableName, table] of Object.entries(schema.tables)) {
834
+ map.set(tableName, buildSchemaTableLookup(table));
835
+ }
836
+ return map;
784
837
  }
785
838
 
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),
839
+ function buildSchemaTableLookup(table: SqlSchemaIR['tables'][string]): SchemaTableLookup {
840
+ const uniqueKeys = new Set(table.uniques.map((u) => u.columns.join(',')));
841
+ const indexKeys = new Set(table.indexes.map((i) => i.columns.join(',')));
842
+ const uniqueIndexKeys = new Set(
843
+ table.indexes.filter((i) => i.unique).map((i) => i.columns.join(',')),
844
+ );
845
+ const fkKeys = new Set(
846
+ table.foreignKeys.map(
847
+ (fk) => `${fk.columns.join(',')}|${fk.referencedTable}|${fk.referencedColumns.join(',')}`,
848
+ ),
792
849
  );
850
+ return { uniqueKeys, indexKeys, uniqueIndexKeys, fkKeys };
793
851
  }
794
852
 
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
- }
853
+ function hasUniqueConstraint(lookup: SchemaTableLookup, columns: readonly string[]): boolean {
854
+ const key = columns.join(',');
855
+ return lookup.uniqueKeys.has(key) || lookup.uniqueIndexKeys.has(key);
810
856
  }
811
857
 
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;
858
+ function hasIndex(lookup: SchemaTableLookup, columns: readonly string[]): boolean {
859
+ const key = columns.join(',');
860
+ return lookup.indexKeys.has(key) || lookup.uniqueKeys.has(key);
828
861
  }
829
862
 
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;
863
+ function hasForeignKey(lookup: SchemaTableLookup, fk: ForeignKey): boolean {
864
+ return lookup.fkKeys.has(
865
+ `${fk.columns.join(',')}|${fk.references.table}|${fk.references.columns.join(',')}`,
866
+ );
862
867
  }