@prisma-next/target-postgres 0.4.0-dev.8 → 0.4.1

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 (44) hide show
  1. package/dist/control.d.mts +1 -9
  2. package/dist/control.d.mts.map +1 -1
  3. package/dist/control.mjs +1693 -4798
  4. package/dist/control.mjs.map +1 -1
  5. package/dist/migration.d.mts +164 -0
  6. package/dist/migration.d.mts.map +1 -0
  7. package/dist/migration.mjs +446 -0
  8. package/dist/migration.mjs.map +1 -0
  9. package/dist/planner-target-details-MXb3oeul.d.mts +11 -0
  10. package/dist/planner-target-details-MXb3oeul.d.mts.map +1 -0
  11. package/dist/postgres-migration-BsHJHV9O.mjs +2793 -0
  12. package/dist/postgres-migration-BsHJHV9O.mjs.map +1 -0
  13. package/package.json +21 -19
  14. package/src/core/migrations/issue-planner.ts +832 -0
  15. package/src/core/migrations/op-factory-call.ts +862 -0
  16. package/src/core/migrations/operations/columns.ts +285 -0
  17. package/src/core/migrations/operations/constraints.ts +191 -0
  18. package/src/core/migrations/operations/data-transform.ts +113 -0
  19. package/src/core/migrations/operations/dependencies.ts +36 -0
  20. package/src/core/migrations/operations/enums.ts +113 -0
  21. package/src/core/migrations/operations/indexes.ts +61 -0
  22. package/src/core/migrations/operations/raw.ts +15 -0
  23. package/src/core/migrations/operations/shared.ts +67 -0
  24. package/src/core/migrations/operations/tables.ts +63 -0
  25. package/src/core/migrations/planner-produced-postgres-migration.ts +67 -0
  26. package/src/core/migrations/planner-strategies.ts +592 -151
  27. package/src/core/migrations/planner-target-details.ts +0 -6
  28. package/src/core/migrations/planner.ts +63 -781
  29. package/src/core/migrations/postgres-migration.ts +20 -0
  30. package/src/core/migrations/render-ops.ts +9 -0
  31. package/src/core/migrations/render-typescript.ts +95 -0
  32. package/src/exports/control.ts +9 -142
  33. package/src/exports/migration.ts +40 -0
  34. package/dist/migration-builders.d.mts +0 -88
  35. package/dist/migration-builders.d.mts.map +0 -1
  36. package/dist/migration-builders.mjs +0 -3
  37. package/dist/operation-descriptors-CxymFSgK.mjs +0 -52
  38. package/dist/operation-descriptors-CxymFSgK.mjs.map +0 -1
  39. package/src/core/migrations/descriptor-planner.ts +0 -464
  40. package/src/core/migrations/operation-descriptors.ts +0 -166
  41. package/src/core/migrations/operation-resolver.ts +0 -929
  42. package/src/core/migrations/planner-reconciliation.ts +0 -798
  43. package/src/core/migrations/scaffolding.ts +0 -140
  44. package/src/exports/migration-builders.ts +0 -56
@@ -1,24 +1,13 @@
1
1
  import {
2
2
  normalizeSchemaNativeType,
3
3
  parsePostgresDefault,
4
- quoteIdentifier,
5
4
  } from '@prisma-next/adapter-postgres/control';
6
- import { errorPlanDoesNotSupportAuthoringSurface } from '@prisma-next/errors/migration';
7
5
  import type {
8
- CodecControlHooks,
9
- ComponentDatabaseDependency,
10
6
  MigrationOperationPolicy,
11
7
  SqlMigrationPlannerPlanOptions,
12
- SqlMigrationPlanOperation,
13
- SqlPlannerConflict,
14
8
  SqlPlannerFailureResult,
15
9
  } from '@prisma-next/family-sql/control';
16
- import {
17
- collectInitDependencies,
18
- createMigrationPlan,
19
- extractCodecControlHooks,
20
- plannerFailure,
21
- } from '@prisma-next/family-sql/control';
10
+ import { extractCodecControlHooks, plannerFailure } from '@prisma-next/family-sql/control';
22
11
  import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify';
23
12
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
24
13
  import type {
@@ -27,44 +16,9 @@ import type {
27
16
  MigrationScaffoldContext,
28
17
  SchemaIssue,
29
18
  } from '@prisma-next/framework-components/control';
30
- import type {
31
- StorageColumn,
32
- StorageTable,
33
- StorageTypeInstance,
34
- } from '@prisma-next/sql-contract/types';
35
- import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
36
- import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
37
- import { ifDefined } from '@prisma-next/utils/defined';
38
- import { buildAddColumnSql, buildCreateTableSql, buildForeignKeySql } from './planner-ddl-builders';
39
- import { resolveIdentityValue } from './planner-identity-values';
40
- import {
41
- buildAddColumnOperationIdentity,
42
- buildAddNotNullColumnWithTemporaryDefaultOperation,
43
- } from './planner-recipes';
44
- import { buildReconciliationPlan } from './planner-reconciliation';
45
- import {
46
- buildSchemaLookupMap,
47
- hasForeignKey,
48
- hasIndex,
49
- hasUniqueConstraint,
50
- type SchemaTableLookup,
51
- } from './planner-schema-lookup';
52
- import {
53
- columnExistsCheck,
54
- columnNullabilityCheck,
55
- constraintExistsCheck,
56
- qualifyTableName,
57
- tableHasPrimaryKeyCheck,
58
- tableIsEmptyCheck,
59
- toRegclassLiteral,
60
- } from './planner-sql-checks';
61
- import {
62
- buildTargetDetails,
63
- type OperationClass,
64
- type PlanningMode,
65
- type PostgresPlanTargetDetails,
66
- } from './planner-target-details';
67
- import { renderDescriptorTypeScript } from './scaffolding';
19
+ import { planIssues } from './issue-planner';
20
+ import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration';
21
+ import { postgresPlannerStrategies } from './planner-strategies';
68
22
 
69
23
  type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends {
70
24
  readonly frameworkComponents: infer T;
@@ -80,12 +34,6 @@ type VerifySqlSchemaOptionsWithComponents = Parameters<typeof verifySqlSchema>[0
80
34
  readonly frameworkComponents: PlannerFrameworkComponents;
81
35
  };
82
36
 
83
- type PlannerDatabaseDependency = {
84
- readonly id: string;
85
- readonly label: string;
86
- readonly install: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
87
- };
88
-
89
37
  interface PlannerConfig {
90
38
  readonly defaultSchema: string;
91
39
  }
@@ -104,44 +52,31 @@ export function createPostgresMigrationPlanner(
104
52
  }
105
53
 
106
54
  /**
107
- * Postgres planner success plan: the SQL-typed plan (so target-detail
108
- * typing is preserved for direct SQL callers, including this package's unit
109
- * tests) plus the framework-required `renderTypeScript()` stub.
110
- */
111
- export type PostgresPlanWithRender = ReturnType<
112
- typeof createMigrationPlan<PostgresPlanTargetDetails>
113
- > & {
114
- renderTypeScript(): string;
115
- };
116
-
117
- /**
118
- * Result of `PostgresMigrationPlanner.plan()`. A discriminated union that
119
- * satisfies both the framework's `MigrationPlannerResult` (success plan
120
- * carries `renderTypeScript()`) and the SQL family's typed result shape
121
- * (success plan is a `SqlMigrationPlan<PostgresPlanTargetDetails>`).
55
+ * Result of `PostgresMigrationPlanner.plan()`. A discriminated union whose
56
+ * success variant carries a `TypeScriptRenderablePostgresMigration` a
57
+ * migration object that both the CLI (via `renderTypeScript()`) and the
58
+ * SQL-typed callers (via `operations`, `describe()`, etc.) consume
59
+ * uniformly.
122
60
  */
123
61
  export type PostgresPlanResult =
124
- | { readonly kind: 'success'; readonly plan: PostgresPlanWithRender }
62
+ | { readonly kind: 'success'; readonly plan: TypeScriptRenderablePostgresMigration }
125
63
  | SqlPlannerFailureResult;
126
64
 
127
65
  /**
128
- * Postgres migration planner.
129
- *
130
- * Implements the framework's `MigrationPlanner<'sql', 'postgres'>` directly
131
- * — meaning it owns `emptyMigration()` and attaches the `renderTypeScript()`
132
- * stub to success plans (Postgres uses descriptor-flow authoring, so
133
- * `renderTypeScript()` throws on planner-produced plans). No external wrapper
134
- * is required at the descriptor level.
66
+ * Postgres migration planner — a thin wrapper over `planIssues`.
135
67
  *
136
- * `plan()` accepts the framework's option shape (with `contract`/`schema`
137
- * typed as `unknown`); SQL-typed callers may pass the more specific
138
- * `SqlMigrationPlannerPlanOptions`, since those structurally satisfy the
139
- * looser framework contract. Internally we treat options as the SQL-typed
140
- * superset so the existing planner logic stays identical.
141
- *
142
- * `fromHash` is accepted but ignored: Postgres is descriptor-flow and never
143
- * needs it (only class-flow planners like Mongo populate `describe()` from
144
- * `fromHash`).
68
+ * `plan()` verifies the live schema against the target contract (producing
69
+ * `SchemaIssue[]`) and delegates to `planIssues` with the unified
70
+ * `postgresPlannerStrategies` list: enum-change, NOT-NULL backfill,
71
+ * type-change, nullable-tightening, codec-hook storage types,
72
+ * component-declared dependency installs, and shared-temp-default /
73
+ * empty-table-guarded NOT-NULL add-column. The same strategy list runs for
74
+ * `migration plan`, `db update`, and `db init`; behavior diverges purely on
75
+ * `policy.allowedOperationClasses` (the data-safe strategies short-circuit
76
+ * when `'data'` is excluded). The issue planner applies operation-class
77
+ * policy gates and emits a single `PostgresOpFactoryCall[]` that drives both
78
+ * the runtime-ops view (via `renderOps`) and the `renderTypeScript()`
79
+ * authoring surface.
145
80
  */
146
81
  export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgres'> {
147
82
  constructor(private readonly config: PlannerConfig) {}
@@ -151,107 +86,66 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
151
86
  readonly schema: unknown;
152
87
  readonly policy: MigrationOperationPolicy;
153
88
  readonly fromHash?: string;
89
+ /**
90
+ * The "from" contract (state the planner assumes the database starts
91
+ * at). Only `migration plan` supplies this; `db update` / `db init`
92
+ * reconcile against the live schema with no old contract. When present
93
+ * alongside the `'data'` operation class, strategies that need from/to
94
+ * column shape comparisons (unsafe type change, nullability tightening)
95
+ * activate.
96
+ */
97
+ readonly fromContract?: unknown;
154
98
  readonly schemaName?: string;
155
99
  readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
156
100
  }): PostgresPlanResult {
157
- return this.planSql(options as SqlMigrationPlannerPlanOptions);
101
+ return this.planSql(options as SqlMigrationPlannerPlanOptions, options.fromHash ?? '');
158
102
  }
159
103
 
160
104
  emptyMigration(context: MigrationScaffoldContext): MigrationPlanWithAuthoringSurface {
161
- return {
162
- targetId: 'postgres',
163
- destination: { storageHash: context.toHash },
164
- operations: [],
165
- renderTypeScript(): string {
166
- return renderDescriptorTypeScript([], context);
167
- },
168
- };
105
+ return new TypeScriptRenderablePostgresMigration([], {
106
+ from: context.fromHash,
107
+ to: context.toHash,
108
+ });
169
109
  }
170
110
 
171
- private planSql(options: SqlMigrationPlannerPlanOptions): PostgresPlanResult {
111
+ private planSql(options: SqlMigrationPlannerPlanOptions, fromHash: string): PostgresPlanResult {
172
112
  const schemaName = options.schemaName ?? this.config.defaultSchema;
173
113
  const policyResult = this.ensureAdditivePolicy(options.policy);
174
114
  if (policyResult) {
175
115
  return policyResult;
176
116
  }
177
117
 
178
- const planningMode = this.resolvePlanningMode(options.policy);
179
- const schemaIssues = this.collectSchemaIssues(options, planningMode.includeExtraObjects);
180
-
181
- // Extract codec control hooks once at entry point for reuse across all operations.
182
- // This avoids repeated iteration over frameworkComponents for each method that needs hooks.
118
+ const schemaIssues = this.collectSchemaIssues(options);
183
119
  const codecHooks = extractCodecControlHooks(options.frameworkComponents);
184
-
185
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
186
120
  const storageTypes = options.contract.storage.types ?? {};
187
121
 
188
- const reconciliationPlan = buildReconciliationPlan({
189
- contract: options.contract,
122
+ const result = planIssues({
190
123
  issues: schemaIssues,
124
+ toContract: options.contract,
125
+ // `fromContract` is only supplied by `migration plan`. It is `null` for
126
+ // `db update` / `db init`, which means data-safety strategies needing
127
+ // from/to comparisons (unsafe type change, nullable tightening) are
128
+ // inapplicable there — reconciliation falls through to
129
+ // `mapIssueToCall`'s direct destructive handlers.
130
+ fromContract: options.fromContract ?? null,
191
131
  schemaName,
192
- mode: planningMode,
193
- policy: options.policy,
194
132
  codecHooks,
133
+ storageTypes,
134
+ schema: options.schema,
135
+ policy: options.policy,
136
+ frameworkComponents: options.frameworkComponents,
137
+ strategies: postgresPlannerStrategies,
195
138
  });
196
- if (reconciliationPlan.conflicts.length > 0) {
197
- return plannerFailure(reconciliationPlan.conflicts);
198
- }
199
139
 
200
- const storageTypePlan = this.buildStorageTypeOperations(options, schemaName, codecHooks);
201
- if (storageTypePlan.conflicts.length > 0) {
202
- return plannerFailure(storageTypePlan.conflicts);
140
+ if (!result.ok) {
141
+ return plannerFailure(result.failure);
203
142
  }
204
143
 
205
- // Sort table entries once for reuse across all additive operation builders.
206
- const sortedTables = sortedEntries(options.contract.storage.tables);
207
-
208
- // Pre-compute constraint lookups once per schema table for O(1) checks across all builders.
209
- const schemaLookups = buildSchemaLookupMap(options.schema);
210
-
211
- // Build extension operations from component-owned database dependencies
212
- operations.push(
213
- ...this.buildDatabaseDependencyOperations(options),
214
- ...storageTypePlan.operations,
215
- ...reconciliationPlan.operations,
216
- ...this.buildTableOperations(
217
- sortedTables,
218
- options.schema,
219
- schemaName,
220
- codecHooks,
221
- storageTypes,
222
- ),
223
- ...this.buildColumnOperations(
224
- sortedTables,
225
- options.schema,
226
- schemaLookups,
227
- schemaName,
228
- codecHooks,
229
- storageTypes,
230
- ),
231
- ...this.buildPrimaryKeyOperations(sortedTables, options.schema, schemaName),
232
- ...this.buildUniqueOperations(sortedTables, schemaLookups, schemaName),
233
- ...this.buildIndexOperations(sortedTables, schemaLookups, schemaName),
234
- ...this.buildFkBackingIndexOperations(sortedTables, schemaLookups, schemaName),
235
- ...this.buildForeignKeyOperations(sortedTables, schemaLookups, schemaName),
236
- );
237
-
238
- const plan = createMigrationPlan<PostgresPlanTargetDetails>({
239
- targetId: 'postgres',
240
- origin: null,
241
- destination: {
242
- storageHash: options.contract.storage.storageHash,
243
- ...ifDefined('profileHash', options.contract.profileHash),
244
- },
245
- operations,
246
- });
247
-
248
144
  return Object.freeze({
249
145
  kind: 'success' as const,
250
- plan: Object.freeze({
251
- ...plan,
252
- renderTypeScript(): string {
253
- throw errorPlanDoesNotSupportAuthoringSurface({ targetId: 'postgres' });
254
- },
146
+ plan: new TypeScriptRenderablePostgresMigration(result.value.calls, {
147
+ from: fromHash,
148
+ to: options.contract.storage.storageHash,
255
149
  }),
256
150
  });
257
151
  }
@@ -269,573 +163,12 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
269
163
  return null;
270
164
  }
271
165
 
272
- /**
273
- * Builds migration operations from component-owned database dependencies.
274
- * These operations install database-side persistence structures declared by components.
275
- */
276
- private buildDatabaseDependencyOperations(
277
- options: PlannerOptionsWithComponents,
278
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
279
- const dependencies = this.collectDependencies(options);
280
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
281
- const seenDependencyIds = new Set<string>();
282
- const seenOperationIds = new Set<string>();
283
-
284
- const installedIds = new Set(options.schema.dependencies.map((d) => d.id));
285
-
286
- for (const dependency of dependencies) {
287
- if (seenDependencyIds.has(dependency.id)) {
288
- continue;
289
- }
290
- seenDependencyIds.add(dependency.id);
291
-
292
- if (installedIds.has(dependency.id)) {
293
- continue;
294
- }
295
-
296
- for (const installOp of dependency.install) {
297
- if (seenOperationIds.has(installOp.id)) {
298
- continue;
299
- }
300
- seenOperationIds.add(installOp.id);
301
- operations.push(installOp as SqlMigrationPlanOperation<PostgresPlanTargetDetails>);
302
- }
303
- }
304
-
305
- return operations;
306
- }
307
-
308
- private buildStorageTypeOperations(
309
- options: PlannerOptionsWithComponents,
310
- schemaName: string,
311
- codecHooks: Map<string, CodecControlHooks>,
312
- ): {
313
- readonly operations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
314
- readonly conflicts: readonly SqlPlannerConflict[];
315
- } {
316
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
317
- const conflicts: SqlPlannerConflict[] = [];
318
- const storageTypes = options.contract.storage.types ?? {};
319
-
320
- for (const [typeName, typeInstance] of sortedEntries(storageTypes)) {
321
- const hook = codecHooks.get(typeInstance.codecId);
322
- const planResult = hook?.planTypeOperations?.({
323
- typeName,
324
- typeInstance,
325
- contract: options.contract,
326
- schema: options.schema,
327
- schemaName,
328
- policy: options.policy,
329
- });
330
- if (!planResult) {
331
- continue;
332
- }
333
- for (const operation of planResult.operations) {
334
- if (!options.policy.allowedOperationClasses.includes(operation.operationClass)) {
335
- conflicts.push({
336
- kind: 'missingButNonAdditive',
337
- summary: `Storage type "${typeName}" requires "${operation.operationClass}" operation "${operation.id}"`,
338
- location: {
339
- type: typeName,
340
- },
341
- });
342
- continue;
343
- }
344
- operations.push({
345
- ...operation,
346
- target: {
347
- id: operation.target.id,
348
- details: this.buildTargetDetails('type', typeName, schemaName),
349
- },
350
- });
351
- }
352
- }
353
-
354
- return { operations, conflicts };
355
- }
356
- private collectDependencies(
357
- options: PlannerOptionsWithComponents,
358
- ): ReadonlyArray<PlannerDatabaseDependency> {
359
- const dependencies = collectInitDependencies(options.frameworkComponents);
360
- return sortDependencies(dependencies.filter(isPostgresPlannerDependency));
361
- }
362
-
363
- private buildTableOperations(
364
- tables: ReadonlyArray<[string, StorageTable]>,
365
- schema: SqlSchemaIR,
366
- schemaName: string,
367
- codecHooks: Map<string, CodecControlHooks>,
368
- storageTypes: Record<string, StorageTypeInstance>,
369
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
370
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
371
- for (const [tableName, table] of tables) {
372
- if (schema.tables[tableName]) {
373
- continue;
374
- }
375
- const qualified = qualifyTableName(schemaName, tableName);
376
- operations.push({
377
- id: `table.${tableName}`,
378
- label: `Create table ${tableName}`,
379
- summary: `Creates table ${tableName} with required columns`,
380
- operationClass: 'additive',
381
- target: {
382
- id: 'postgres',
383
- details: this.buildTargetDetails('table', tableName, schemaName),
384
- },
385
- precheck: [
386
- {
387
- description: `ensure table "${tableName}" does not exist`,
388
- sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, tableName)}) IS NULL`,
389
- },
390
- ],
391
- execute: [
392
- {
393
- description: `create table "${tableName}"`,
394
- sql: buildCreateTableSql(qualified, table, codecHooks, storageTypes),
395
- },
396
- ],
397
- postcheck: [
398
- {
399
- description: `verify table "${tableName}" exists`,
400
- sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, tableName)}) IS NOT NULL`,
401
- },
402
- ],
403
- });
404
- }
405
- return operations;
406
- }
407
-
408
- private buildColumnOperations(
409
- tables: ReadonlyArray<[string, StorageTable]>,
410
- schema: SqlSchemaIR,
411
- schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
412
- schemaName: string,
413
- codecHooks: Map<string, CodecControlHooks>,
414
- storageTypes: Record<string, StorageTypeInstance>,
415
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
416
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
417
- for (const [tableName, table] of tables) {
418
- const schemaTable = schema.tables[tableName];
419
- if (!schemaTable) {
420
- continue;
421
- }
422
- const schemaLookup = schemaLookups.get(tableName);
423
- for (const [columnName, column] of sortedEntries(table.columns)) {
424
- if (schemaTable.columns[columnName]) {
425
- continue;
426
- }
427
- operations.push(
428
- this.buildAddColumnOperation({
429
- schema: schemaName,
430
- tableName,
431
- table,
432
- schemaTable,
433
- schemaLookup,
434
- columnName,
435
- column,
436
- codecHooks,
437
- storageTypes,
438
- }),
439
- );
440
- }
441
- }
442
- return operations;
443
- }
444
-
445
- private buildAddColumnOperation(options: {
446
- readonly schema: string;
447
- readonly tableName: string;
448
- readonly table: StorageTable;
449
- readonly schemaTable: SqlSchemaIR['tables'][string];
450
- readonly schemaLookup: SchemaTableLookup | undefined;
451
- readonly columnName: string;
452
- readonly column: StorageColumn;
453
- readonly codecHooks: Map<string, CodecControlHooks>;
454
- readonly storageTypes: Record<string, StorageTypeInstance>;
455
- }): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
456
- const {
457
- schema,
458
- tableName,
459
- table,
460
- schemaTable,
461
- schemaLookup,
462
- columnName,
463
- column,
464
- codecHooks,
465
- storageTypes,
466
- } = options;
467
- const notNull = !column.nullable;
468
- const hasDefault = column.default !== undefined;
469
- // Planner logic decides whether this column needs the coordinated multi-step
470
- // strategy. The strategy recipe itself is built by a dedicated helper.
471
- const needsTemporaryDefault = notNull && !hasDefault;
472
- const temporaryDefault = needsTemporaryDefault
473
- ? resolveIdentityValue(column, codecHooks, storageTypes)
474
- : null;
475
- const canUseSharedTemporaryDefault =
476
- needsTemporaryDefault &&
477
- temporaryDefault !== null &&
478
- canUseSharedTemporaryDefaultStrategy({
479
- table,
480
- schemaTable,
481
- schemaLookup,
482
- columnName,
483
- });
484
-
485
- if (canUseSharedTemporaryDefault) {
486
- return buildAddNotNullColumnWithTemporaryDefaultOperation({
487
- schema,
488
- tableName,
489
- columnName,
490
- column,
491
- codecHooks,
492
- storageTypes,
493
- temporaryDefault,
494
- });
495
- }
496
-
497
- const qualified = qualifyTableName(schema, tableName);
498
- const requiresEmptyTableCheck = needsTemporaryDefault && !canUseSharedTemporaryDefault;
499
- return {
500
- ...buildAddColumnOperationIdentity(schema, tableName, columnName),
501
- operationClass: 'additive',
502
- precheck: [
503
- {
504
- description: `ensure column "${columnName}" is missing`,
505
- sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
506
- },
507
- ...(requiresEmptyTableCheck
508
- ? [
509
- {
510
- description: `ensure table "${tableName}" is empty before adding NOT NULL column without default`,
511
- sql: tableIsEmptyCheck(qualified),
512
- },
513
- ]
514
- : []),
515
- ],
516
- execute: [
517
- {
518
- description: `add column "${columnName}"`,
519
- sql: buildAddColumnSql(
520
- qualified,
521
- columnName,
522
- column,
523
- codecHooks,
524
- undefined,
525
- storageTypes,
526
- ),
527
- },
528
- ],
529
- postcheck: [
530
- {
531
- description: `verify column "${columnName}" exists`,
532
- sql: columnExistsCheck({ schema, table: tableName, column: columnName }),
533
- },
534
- ...(notNull
535
- ? [
536
- {
537
- description: `verify column "${columnName}" is NOT NULL`,
538
- sql: columnNullabilityCheck({
539
- schema,
540
- table: tableName,
541
- column: columnName,
542
- nullable: false,
543
- }),
544
- },
545
- ]
546
- : []),
547
- ],
548
- };
549
- }
550
-
551
- private buildPrimaryKeyOperations(
552
- tables: ReadonlyArray<[string, StorageTable]>,
553
- schema: SqlSchemaIR,
554
- schemaName: string,
555
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
556
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
557
- for (const [tableName, table] of tables) {
558
- if (!table.primaryKey) {
559
- continue;
560
- }
561
- const schemaTable = schema.tables[tableName];
562
- if (!schemaTable || schemaTable.primaryKey) {
563
- continue;
564
- }
565
- const constraintName = table.primaryKey.name ?? `${tableName}_pkey`;
566
- operations.push({
567
- id: `primaryKey.${tableName}.${constraintName}`,
568
- label: `Add primary key ${constraintName} on ${tableName}`,
569
- summary: `Adds primary key ${constraintName} on ${tableName}`,
570
- operationClass: 'additive',
571
- target: {
572
- id: 'postgres',
573
- details: this.buildTargetDetails('table', tableName, schemaName),
574
- },
575
- precheck: [
576
- {
577
- description: `ensure primary key does not exist on "${tableName}"`,
578
- sql: tableHasPrimaryKeyCheck(schemaName, tableName, false),
579
- },
580
- ],
581
- execute: [
582
- {
583
- description: `add primary key "${constraintName}"`,
584
- sql: `ALTER TABLE ${qualifyTableName(schemaName, tableName)}
585
- ADD CONSTRAINT ${quoteIdentifier(constraintName)}
586
- PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
587
- },
588
- ],
589
- postcheck: [
590
- {
591
- description: `verify primary key "${constraintName}" exists`,
592
- sql: tableHasPrimaryKeyCheck(schemaName, tableName, true, constraintName),
593
- },
594
- ],
595
- });
596
- }
597
- return operations;
598
- }
599
-
600
- private buildUniqueOperations(
601
- tables: ReadonlyArray<[string, StorageTable]>,
602
- schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
603
- schemaName: string,
604
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
605
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
606
- for (const [tableName, table] of tables) {
607
- const lookup = schemaLookups.get(tableName);
608
- for (const unique of table.uniques) {
609
- if (lookup && hasUniqueConstraint(lookup, unique.columns)) {
610
- continue;
611
- }
612
- const constraintName = unique.name ?? `${tableName}_${unique.columns.join('_')}_key`;
613
- operations.push({
614
- id: `unique.${tableName}.${constraintName}`,
615
- label: `Add unique constraint ${constraintName} on ${tableName}`,
616
- summary: `Adds unique constraint ${constraintName} on ${tableName}`,
617
- operationClass: 'additive',
618
- target: {
619
- id: 'postgres',
620
- details: this.buildTargetDetails('unique', constraintName, schemaName, tableName),
621
- },
622
- precheck: [
623
- {
624
- description: `ensure unique constraint "${constraintName}" is missing`,
625
- sql: constraintExistsCheck({
626
- constraintName,
627
- schema: schemaName,
628
- table: tableName,
629
- exists: false,
630
- }),
631
- },
632
- ],
633
- execute: [
634
- {
635
- description: `add unique constraint "${constraintName}"`,
636
- sql: `ALTER TABLE ${qualifyTableName(schemaName, tableName)}
637
- ADD CONSTRAINT ${quoteIdentifier(constraintName)}
638
- UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
639
- },
640
- ],
641
- postcheck: [
642
- {
643
- description: `verify unique constraint "${constraintName}" exists`,
644
- sql: constraintExistsCheck({ constraintName, schema: schemaName, table: tableName }),
645
- },
646
- ],
647
- });
648
- }
649
- }
650
- return operations;
651
- }
652
-
653
- private buildIndexOperations(
654
- tables: ReadonlyArray<[string, StorageTable]>,
655
- schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
656
- schemaName: string,
657
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
658
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
659
- for (const [tableName, table] of tables) {
660
- const lookup = schemaLookups.get(tableName);
661
- for (const index of table.indexes) {
662
- if (lookup && hasIndex(lookup, index.columns)) {
663
- continue;
664
- }
665
- const indexName = index.name ?? defaultIndexName(tableName, index.columns);
666
- operations.push({
667
- id: `index.${tableName}.${indexName}`,
668
- label: `Create index ${indexName} on ${tableName}`,
669
- summary: `Creates index ${indexName} on ${tableName}`,
670
- operationClass: 'additive',
671
- target: {
672
- id: 'postgres',
673
- details: this.buildTargetDetails('index', indexName, schemaName, tableName),
674
- },
675
- precheck: [
676
- {
677
- description: `ensure index "${indexName}" is missing`,
678
- sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NULL`,
679
- },
680
- ],
681
- execute: [
682
- {
683
- description: `create index "${indexName}"`,
684
- sql: `CREATE INDEX ${quoteIdentifier(indexName)} ON ${qualifyTableName(
685
- schemaName,
686
- tableName,
687
- )} (${index.columns.map(quoteIdentifier).join(', ')})`,
688
- },
689
- ],
690
- postcheck: [
691
- {
692
- description: `verify index "${indexName}" exists`,
693
- sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NOT NULL`,
694
- },
695
- ],
696
- });
697
- }
698
- }
699
- return operations;
700
- }
701
-
702
- /**
703
- * Generates FK-backing index operations for FKs with `index: true`,
704
- * but only when no matching user-declared index exists in `contractTable.indexes`.
705
- */
706
- private buildFkBackingIndexOperations(
707
- tables: ReadonlyArray<[string, StorageTable]>,
708
- schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
709
- schemaName: string,
710
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
711
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
712
- for (const [tableName, table] of tables) {
713
- const lookup = schemaLookups.get(tableName);
714
- // Collect column sets of user-declared indexes to avoid duplicates
715
- const declaredIndexColumns = new Set(table.indexes.map((idx) => idx.columns.join(',')));
716
-
717
- for (const fk of table.foreignKeys) {
718
- if (fk.index === false) continue;
719
- // Skip if user already declared an index with these columns
720
- if (declaredIndexColumns.has(fk.columns.join(','))) continue;
721
- // Skip if the index already exists in the database
722
- if (lookup && hasIndex(lookup, fk.columns)) continue;
723
-
724
- const indexName = defaultIndexName(tableName, fk.columns);
725
- operations.push({
726
- id: `index.${tableName}.${indexName}`,
727
- label: `Create FK-backing index ${indexName} on ${tableName}`,
728
- summary: `Creates FK-backing index ${indexName} on ${tableName}`,
729
- operationClass: 'additive',
730
- target: {
731
- id: 'postgres',
732
- details: this.buildTargetDetails('index', indexName, schemaName, tableName),
733
- },
734
- precheck: [
735
- {
736
- description: `ensure index "${indexName}" is missing`,
737
- sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NULL`,
738
- },
739
- ],
740
- execute: [
741
- {
742
- description: `create FK-backing index "${indexName}"`,
743
- sql: `CREATE INDEX ${quoteIdentifier(indexName)} ON ${qualifyTableName(
744
- schemaName,
745
- tableName,
746
- )} (${fk.columns.map(quoteIdentifier).join(', ')})`,
747
- },
748
- ],
749
- postcheck: [
750
- {
751
- description: `verify index "${indexName}" exists`,
752
- sql: `SELECT to_regclass(${toRegclassLiteral(schemaName, indexName)}) IS NOT NULL`,
753
- },
754
- ],
755
- });
756
- }
757
- }
758
- return operations;
759
- }
760
-
761
- private buildForeignKeyOperations(
762
- tables: ReadonlyArray<[string, StorageTable]>,
763
- schemaLookups: ReadonlyMap<string, SchemaTableLookup>,
764
- schemaName: string,
765
- ): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
766
- const operations: SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] = [];
767
- for (const [tableName, table] of tables) {
768
- const lookup = schemaLookups.get(tableName);
769
- for (const foreignKey of table.foreignKeys) {
770
- if (foreignKey.constraint === false) continue;
771
- if (lookup && hasForeignKey(lookup, foreignKey)) {
772
- continue;
773
- }
774
- const fkName = foreignKey.name ?? `${tableName}_${foreignKey.columns.join('_')}_fkey`;
775
- operations.push({
776
- id: `foreignKey.${tableName}.${fkName}`,
777
- label: `Add foreign key ${fkName} on ${tableName}`,
778
- summary: `Adds foreign key ${fkName} referencing ${foreignKey.references.table}`,
779
- operationClass: 'additive',
780
- target: {
781
- id: 'postgres',
782
- details: this.buildTargetDetails('foreignKey', fkName, schemaName, tableName),
783
- },
784
- precheck: [
785
- {
786
- description: `ensure foreign key "${fkName}" is missing`,
787
- sql: constraintExistsCheck({
788
- constraintName: fkName,
789
- schema: schemaName,
790
- table: tableName,
791
- exists: false,
792
- }),
793
- },
794
- ],
795
- execute: [
796
- {
797
- description: `add foreign key "${fkName}"`,
798
- sql: buildForeignKeySql(schemaName, tableName, fkName, foreignKey),
799
- },
800
- ],
801
- postcheck: [
802
- {
803
- description: `verify foreign key "${fkName}" exists`,
804
- sql: constraintExistsCheck({
805
- constraintName: fkName,
806
- schema: schemaName,
807
- table: tableName,
808
- }),
809
- },
810
- ],
811
- });
812
- }
813
- }
814
- return operations;
815
- }
816
-
817
- private buildTargetDetails(
818
- objectType: OperationClass,
819
- name: string,
820
- schema: string,
821
- table?: string,
822
- ): PostgresPlanTargetDetails {
823
- return buildTargetDetails(objectType, name, schema, table);
824
- }
825
-
826
- private resolvePlanningMode(policy: MigrationOperationPolicy): PlanningMode {
827
- const allowWidening = policy.allowedOperationClasses.includes('widening');
828
- const allowDestructive = policy.allowedOperationClasses.includes('destructive');
829
- // `db init` uses additive-only policy and intentionally ignores extras.
830
- // Any reconciliation-capable policy should inspect extras to reconcile strict equality.
831
- const includeExtraObjects = allowWidening || allowDestructive;
832
- return { includeExtraObjects, allowWidening, allowDestructive };
833
- }
834
-
835
- private collectSchemaIssues(
836
- options: PlannerOptionsWithComponents,
837
- strict: boolean,
838
- ): readonly SchemaIssue[] {
166
+ private collectSchemaIssues(options: PlannerOptionsWithComponents): readonly SchemaIssue[] {
167
+ // `db init` uses additive-only policy and intentionally ignores extra
168
+ // schema objects. Any reconciliation-capable policy (widening or
169
+ // destructive) must inspect extras to reconcile strict equality.
170
+ const allowed = options.policy.allowedOperationClasses;
171
+ const strict = allowed.includes('widening') || allowed.includes('destructive');
839
172
  const verifyOptions: VerifySqlSchemaOptionsWithComponents = {
840
173
  contract: options.contract,
841
174
  schema: options.schema,
@@ -849,54 +182,3 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
849
182
  return verifyResult.schema.issues;
850
183
  }
851
184
  }
852
-
853
- function canUseSharedTemporaryDefaultStrategy(options: {
854
- readonly table: StorageTable;
855
- readonly schemaTable: SqlSchemaIR['tables'][string];
856
- readonly schemaLookup: SchemaTableLookup | undefined;
857
- readonly columnName: string;
858
- }): boolean {
859
- const { table, schemaTable, schemaLookup, columnName } = options;
860
-
861
- // Shared placeholders are only safe when later plan steps do not require
862
- // row-specific values for this newly added column.
863
- if (table.primaryKey?.columns.includes(columnName) && !schemaTable.primaryKey) {
864
- return false;
865
- }
866
-
867
- for (const unique of table.uniques) {
868
- if (!unique.columns.includes(columnName)) {
869
- continue;
870
- }
871
- if (!schemaLookup || !hasUniqueConstraint(schemaLookup, unique.columns)) {
872
- return false;
873
- }
874
- }
875
-
876
- for (const foreignKey of table.foreignKeys) {
877
- if (foreignKey.constraint === false || !foreignKey.columns.includes(columnName)) {
878
- continue;
879
- }
880
- if (!schemaLookup || !hasForeignKey(schemaLookup, foreignKey)) {
881
- return false;
882
- }
883
- }
884
-
885
- return true;
886
- }
887
-
888
- function sortDependencies(
889
- dependencies: ReadonlyArray<PlannerDatabaseDependency>,
890
- ): ReadonlyArray<PlannerDatabaseDependency> {
891
- return [...dependencies].sort((a, b) => a.id.localeCompare(b.id));
892
- }
893
-
894
- function isPostgresPlannerDependency(
895
- dependency: ComponentDatabaseDependency<unknown>,
896
- ): dependency is PlannerDatabaseDependency {
897
- return dependency.install.every((operation) => operation.target.id === 'postgres');
898
- }
899
-
900
- function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]> {
901
- return Object.entries(record).sort(([a], [b]) => a.localeCompare(b)) as Array<[string, V]>;
902
- }