@prisma-next/target-postgres 0.3.0-dev.3 → 0.3.0-dev.30

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