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

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.
package/package.json CHANGED
@@ -1,33 +1,33 @@
1
1
  {
2
2
  "name": "@prisma-next/target-postgres",
3
- "version": "0.3.0-dev.113",
3
+ "version": "0.3.0-dev.114",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Postgres target pack for Prisma Next",
7
7
  "dependencies": {
8
8
  "arktype": "^2.0.0",
9
- "@prisma-next/cli": "0.3.0-dev.113",
10
- "@prisma-next/family-sql": "0.3.0-dev.113",
11
- "@prisma-next/contract": "0.3.0-dev.113",
12
- "@prisma-next/core-control-plane": "0.3.0-dev.113",
13
- "@prisma-next/core-execution-plane": "0.3.0-dev.113",
14
- "@prisma-next/sql-contract": "0.3.0-dev.113",
15
- "@prisma-next/sql-errors": "0.3.0-dev.113",
16
- "@prisma-next/sql-runtime": "0.3.0-dev.113",
17
- "@prisma-next/utils": "0.3.0-dev.113",
18
- "@prisma-next/sql-relational-core": "0.3.0-dev.113",
19
- "@prisma-next/sql-schema-ir": "0.3.0-dev.113"
9
+ "@prisma-next/cli": "0.3.0-dev.114",
10
+ "@prisma-next/contract": "0.3.0-dev.114",
11
+ "@prisma-next/core-execution-plane": "0.3.0-dev.114",
12
+ "@prisma-next/family-sql": "0.3.0-dev.114",
13
+ "@prisma-next/sql-contract": "0.3.0-dev.114",
14
+ "@prisma-next/sql-errors": "0.3.0-dev.114",
15
+ "@prisma-next/core-control-plane": "0.3.0-dev.114",
16
+ "@prisma-next/sql-runtime": "0.3.0-dev.114",
17
+ "@prisma-next/sql-schema-ir": "0.3.0-dev.114",
18
+ "@prisma-next/sql-relational-core": "0.3.0-dev.114",
19
+ "@prisma-next/utils": "0.3.0-dev.114"
20
20
  },
21
21
  "devDependencies": {
22
22
  "tsdown": "0.18.4",
23
23
  "typescript": "5.9.3",
24
24
  "vitest": "4.0.17",
25
- "@prisma-next/adapter-postgres": "0.3.0-dev.113",
26
- "@prisma-next/driver-postgres": "0.3.0-dev.113",
27
- "@prisma-next/extension-pgvector": "0.3.0-dev.113",
25
+ "@prisma-next/extension-pgvector": "0.3.0-dev.114",
26
+ "@prisma-next/adapter-postgres": "0.3.0-dev.114",
28
27
  "@prisma-next/test-utils": "0.0.1",
29
28
  "@prisma-next/tsconfig": "0.0.0",
30
- "@prisma-next/tsdown": "0.0.0"
29
+ "@prisma-next/tsdown": "0.0.0",
30
+ "@prisma-next/driver-postgres": "0.3.0-dev.114"
31
31
  },
32
32
  "files": [
33
33
  "dist",
@@ -7,12 +7,17 @@ import type {
7
7
  SqlPlannerConflict,
8
8
  } from '@prisma-next/family-sql/control';
9
9
  import type { SqlContract, SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types';
10
+ import { invariant } from '@prisma-next/utils/assertions';
10
11
  import { ifDefined } from '@prisma-next/utils/defined';
11
12
  import type { PlanningMode, PostgresPlanTargetDetails } from './planner';
12
13
  import {
14
+ buildColumnDefaultSql,
13
15
  buildColumnTypeSql,
16
+ buildExpectedFormatType,
17
+ columnDefaultExistsCheck,
14
18
  columnExistsCheck,
15
19
  columnNullabilityCheck,
20
+ columnTypeCheck,
16
21
  constraintExistsCheck,
17
22
  qualifyTableName,
18
23
  toRegclassLiteral,
@@ -189,11 +194,72 @@ function buildReconciliationOperationFromIssue(options: {
189
194
  );
190
195
  }
191
196
 
192
- // Remaining issue kinds (default_missing, default_mismatch, primary_key_mismatch,
193
- // unique_constraint_mismatch, index_mismatch, foreign_key_mismatch) do not yet have
194
- // reconciliation operation builders. They fall through to the caller, which converts them to
195
- // conflicts via convertIssueToConflict. When a new SchemaIssue kind is added, add a
196
- // case here if the planner can emit an operation for it; otherwise it becomes a conflict.
197
+ case 'default_missing': {
198
+ if (!issue.table || !issue.column) {
199
+ return null;
200
+ }
201
+ const contractColMissing = getContractColumn(contract, issue.table, issue.column);
202
+ if (!contractColMissing) {
203
+ return null;
204
+ }
205
+ // NOTE: Being in the `default_missing` case means the verifier found the contract expects a default, so it should exist here. We must still narrow.
206
+ invariant(
207
+ contractColMissing.default !== undefined,
208
+ `default_missing issue for "${issue.table}"."${issue.column}" but contract column has no default`,
209
+ );
210
+ return buildDefaultOperation(
211
+ schemaName,
212
+ issue.table,
213
+ issue.column,
214
+ contractColMissing,
215
+ contractColMissing.default,
216
+ 'additive',
217
+ 'Set',
218
+ );
219
+ }
220
+
221
+ case 'default_mismatch': {
222
+ if (!issue.table || !issue.column) {
223
+ return null;
224
+ }
225
+ if (!mode.allowWidening) {
226
+ return null;
227
+ }
228
+ const contractColMismatch = getContractColumn(contract, issue.table, issue.column);
229
+ if (!contractColMismatch) {
230
+ return null;
231
+ }
232
+ // NOTE: Being in the `default_mismatch` case means the verifier found the contract expects a different default, so it should exist here. We must still narrow.
233
+ invariant(
234
+ contractColMismatch.default !== undefined,
235
+ `default_mismatch issue for "${issue.table}"."${issue.column}" but contract column has no default`,
236
+ );
237
+ return buildDefaultOperation(
238
+ schemaName,
239
+ issue.table,
240
+ issue.column,
241
+ contractColMismatch,
242
+ contractColMismatch.default,
243
+ 'widening',
244
+ 'Change',
245
+ );
246
+ }
247
+
248
+ case 'extra_default': {
249
+ if (!issue.table || !issue.column) {
250
+ return null;
251
+ }
252
+ if (!mode.allowDestructive) {
253
+ return null;
254
+ }
255
+ return buildDropDefaultOperation(schemaName, issue.table, issue.column);
256
+ }
257
+
258
+ // Remaining issue kinds (primary_key_mismatch, unique_constraint_mismatch,
259
+ // index_mismatch, foreign_key_mismatch) do not yet have reconciliation operation
260
+ // builders. They fall through to the caller, which converts them to conflicts via
261
+ // convertIssueToConflict. When a new SchemaIssue kind is added, add a case here if
262
+ // the planner can emit an operation for it; otherwise it becomes a conflict.
197
263
  default:
198
264
  return null;
199
265
  }
@@ -338,7 +404,7 @@ function buildDropConstraintOperation(
338
404
  precheck: [
339
405
  {
340
406
  description: `ensure constraint "${constraintName}" exists`,
341
- sql: constraintExistsCheck({ constraintName, schema: schemaName }),
407
+ sql: constraintExistsCheck({ constraintName, schema: schemaName, table: tableName }),
342
408
  },
343
409
  ],
344
410
  execute: [
@@ -351,7 +417,12 @@ DROP CONSTRAINT ${quoteIdentifier(constraintName)}`,
351
417
  postcheck: [
352
418
  {
353
419
  description: `verify constraint "${constraintName}" is removed`,
354
- sql: constraintExistsCheck({ constraintName, schema: schemaName, exists: false }),
420
+ sql: constraintExistsCheck({
421
+ constraintName,
422
+ schema: schemaName,
423
+ table: tableName,
424
+ exists: false,
425
+ }),
355
426
  },
356
427
  ],
357
428
  };
@@ -488,10 +559,106 @@ USING ${quoteIdentifier(columnName)}::${expectedType}`,
488
559
  ],
489
560
  postcheck: [
490
561
  {
491
- description: `verify column "${columnName}" exists after type change`,
562
+ description: `verify column "${columnName}" has type ${expectedType}`,
563
+ sql: columnTypeCheck({
564
+ schema: schemaName,
565
+ table: tableName,
566
+ column: columnName,
567
+ expectedType: buildExpectedFormatType(column, codecHooks),
568
+ }),
569
+ },
570
+ ],
571
+ };
572
+ }
573
+
574
+ function buildDefaultOperation(
575
+ schemaName: string,
576
+ tableName: string,
577
+ columnName: string,
578
+ column: Omit<StorageColumn, 'default'>,
579
+ columnDefault: NonNullable<StorageColumn['default']>,
580
+ operationClass: 'additive' | 'widening',
581
+ verb: 'Set' | 'Change',
582
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> | null {
583
+ const qualified = qualifyTableName(schemaName, tableName);
584
+ const defaultClause = buildColumnDefaultSql(columnDefault, column);
585
+ // autoincrement defaults are handled by SERIAL types — buildColumnDefaultSql returns ''
586
+ // for them. Until the IR is enriched to distinguish autoincrement (TML-2107), skip.
587
+ if (!defaultClause) return null;
588
+ const verbLower = verb.toLowerCase();
589
+ return {
590
+ id: `setDefault.${tableName}.${columnName}`,
591
+ label: `${verb} default for ${columnName} on ${tableName}`,
592
+ summary: `${verb}s default on column ${columnName} of table ${tableName}`,
593
+ operationClass,
594
+ target: {
595
+ id: 'postgres',
596
+ details: buildTargetDetails('column', columnName, schemaName, tableName),
597
+ },
598
+ precheck: [
599
+ {
600
+ description: `ensure column "${columnName}" exists`,
492
601
  sql: columnExistsCheck({ schema: schemaName, table: tableName, column: columnName }),
493
602
  },
494
603
  ],
604
+ execute: [
605
+ {
606
+ description: `${verbLower} default on "${columnName}"`,
607
+ sql: `ALTER TABLE ${qualified}\nALTER COLUMN ${quoteIdentifier(columnName)} SET ${defaultClause}`,
608
+ },
609
+ ],
610
+ postcheck: [
611
+ {
612
+ description: `verify column "${columnName}" has a default`,
613
+ sql: columnDefaultExistsCheck({
614
+ schema: schemaName,
615
+ table: tableName,
616
+ column: columnName,
617
+ exists: true,
618
+ }),
619
+ },
620
+ ],
621
+ };
622
+ }
623
+
624
+ function buildDropDefaultOperation(
625
+ schemaName: string,
626
+ tableName: string,
627
+ columnName: string,
628
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
629
+ const qualified = qualifyTableName(schemaName, tableName);
630
+ return {
631
+ id: `dropDefault.${tableName}.${columnName}`,
632
+ label: `Drop default for ${columnName} on ${tableName}`,
633
+ summary: `Drops default on column ${columnName} of table ${tableName}`,
634
+ operationClass: 'destructive',
635
+ target: {
636
+ id: 'postgres',
637
+ details: buildTargetDetails('column', columnName, schemaName, tableName),
638
+ },
639
+ precheck: [
640
+ {
641
+ description: `ensure column "${columnName}" exists`,
642
+ sql: columnExistsCheck({ schema: schemaName, table: tableName, column: columnName }),
643
+ },
644
+ ],
645
+ execute: [
646
+ {
647
+ description: `drop default on "${columnName}"`,
648
+ sql: `ALTER TABLE ${qualified}\nALTER COLUMN ${quoteIdentifier(columnName)} DROP DEFAULT`,
649
+ },
650
+ ],
651
+ postcheck: [
652
+ {
653
+ description: `verify column "${columnName}" has no default`,
654
+ sql: columnDefaultExistsCheck({
655
+ schema: schemaName,
656
+ table: tableName,
657
+ column: columnName,
658
+ exists: false,
659
+ }),
660
+ },
661
+ ],
495
662
  };
496
663
  }
497
664
 
@@ -507,6 +674,7 @@ function convertIssueToConflict(issue: SchemaIssue): SqlPlannerConflict | null {
507
674
  return buildConflict('nullabilityConflict', issue);
508
675
  case 'default_missing':
509
676
  case 'default_mismatch':
677
+ case 'extra_default':
510
678
  case 'extra_table':
511
679
  case 'extra_column':
512
680
  case 'extra_primary_key':
@@ -126,7 +126,7 @@ function renderParameterizedTypeSql(
126
126
  return expanded !== column.nativeType ? expanded : null;
127
127
  }
128
128
 
129
- function buildColumnDefaultSql(
129
+ export function buildColumnDefaultSql(
130
130
  columnDefault: PostgresColumnDefault | undefined,
131
131
  column?: StorageColumn,
132
132
  ): string {
@@ -192,10 +192,12 @@ export function toRegclassLiteral(schema: string, name: string): string {
192
192
  export function constraintExistsCheck({
193
193
  constraintName,
194
194
  schema,
195
+ table,
195
196
  exists = true,
196
197
  }: {
197
198
  constraintName: string;
198
199
  schema: string;
200
+ table: string;
199
201
  exists?: boolean;
200
202
  }): string {
201
203
  const existsClause = exists ? 'EXISTS' : 'NOT EXISTS';
@@ -204,6 +206,7 @@ export function constraintExistsCheck({
204
206
  JOIN pg_namespace n ON c.connamespace = n.oid
205
207
  WHERE c.conname = '${escapeLiteral(constraintName)}'
206
208
  AND n.nspname = '${escapeLiteral(schema)}'
209
+ AND c.conrelid = to_regclass(${toRegclassLiteral(schema, table)})
207
210
  )`;
208
211
  }
209
212
 
@@ -250,6 +253,111 @@ export function columnNullabilityCheck({
250
253
  )`;
251
254
  }
252
255
 
256
+ /**
257
+ * Maps contract native type names to the display form returned by PostgreSQL's
258
+ * `format_type()`. Base types use short names in the contract (e.g., `int4`)
259
+ * but `format_type()` returns SQL-standard names (e.g., `integer`).
260
+ *
261
+ * NOTE: The inverse mapping lives in `normalizeFormattedType` in control-adapter.ts.
262
+ * These two maps must stay in sync. A shared bidirectional map in
263
+ * @prisma-next/adapter-postgres would eliminate the drift risk.
264
+ */
265
+ const FORMAT_TYPE_DISPLAY: ReadonlyMap<string, string> = new Map([
266
+ ['int2', 'smallint'],
267
+ ['int4', 'integer'],
268
+ ['int8', 'bigint'],
269
+ ['float4', 'real'],
270
+ ['float8', 'double precision'],
271
+ ['bool', 'boolean'],
272
+ ['timestamp', 'timestamp without time zone'],
273
+ ['timestamptz', 'timestamp with time zone'],
274
+ ['time', 'time without time zone'],
275
+ ['timetz', 'time with time zone'],
276
+ ]);
277
+
278
+ /**
279
+ * Builds the string that `format_type(atttypid, atttypmod)` would return for a
280
+ * contract column. Used for postchecks — separate from `buildColumnTypeSql` which
281
+ * produces DDL-safe strings (e.g., quoted identifiers, SERIAL).
282
+ */
283
+ export function buildExpectedFormatType(
284
+ column: StorageColumn,
285
+ codecHooks: Map<string, CodecControlHooks>,
286
+ ): string {
287
+ // Parameterized types: expand with typeParams.
288
+ // format_type() returns the same form (e.g., 'character varying(255)').
289
+ if (column.typeParams && column.codecId) {
290
+ const hooks = codecHooks.get(column.codecId);
291
+ if (hooks?.expandNativeType) {
292
+ return hooks.expandNativeType({
293
+ nativeType: column.nativeType,
294
+ codecId: column.codecId,
295
+ typeParams: column.typeParams,
296
+ });
297
+ }
298
+ }
299
+
300
+ // User-defined types (enums, composites): format_type() double-quotes names
301
+ // that contain uppercase characters (e.g., "StatusType") but returns lowercase
302
+ // names bare (e.g., status_type). We can't use quoteIdentifier() here because
303
+ // it always quotes, which would break the lowercase case.
304
+ if (column.typeRef) {
305
+ const needsQuoting = column.nativeType !== column.nativeType.toLowerCase();
306
+ return needsQuoting ? `"${column.nativeType}"` : column.nativeType;
307
+ }
308
+
309
+ // Base types: map contract short names to format_type() display names.
310
+ return FORMAT_TYPE_DISPLAY.get(column.nativeType) ?? column.nativeType;
311
+ }
312
+
313
+ /** Checks that the column's full type (including typmods) matches the expected type via `format_type()`. */
314
+ export function columnTypeCheck({
315
+ schema,
316
+ table,
317
+ column,
318
+ expectedType,
319
+ }: {
320
+ schema: string;
321
+ table: string;
322
+ column: string;
323
+ expectedType: string;
324
+ }): string {
325
+ return `SELECT EXISTS (
326
+ SELECT 1
327
+ FROM pg_attribute a
328
+ JOIN pg_class c ON c.oid = a.attrelid
329
+ JOIN pg_namespace n ON n.oid = c.relnamespace
330
+ WHERE n.nspname = '${escapeLiteral(schema)}'
331
+ AND c.relname = '${escapeLiteral(table)}'
332
+ AND a.attname = '${escapeLiteral(column)}'
333
+ AND format_type(a.atttypid, a.atttypmod) = '${escapeLiteral(expectedType)}'
334
+ AND NOT a.attisdropped
335
+ )`;
336
+ }
337
+
338
+ /** Checks that a column default exists (or does not exist) via `information_schema.columns.column_default`. */
339
+ export function columnDefaultExistsCheck({
340
+ schema,
341
+ table,
342
+ column,
343
+ exists = true,
344
+ }: {
345
+ schema: string;
346
+ table: string;
347
+ column: string;
348
+ exists?: boolean;
349
+ }): string {
350
+ const nullCheck = exists ? 'IS NOT NULL' : 'IS NULL';
351
+ return `SELECT EXISTS (
352
+ SELECT 1
353
+ FROM information_schema.columns
354
+ WHERE table_schema = '${escapeLiteral(schema)}'
355
+ AND table_name = '${escapeLiteral(table)}'
356
+ AND column_name = '${escapeLiteral(column)}'
357
+ AND column_default ${nullCheck}
358
+ )`;
359
+ }
360
+
253
361
  export function tableIsEmptyCheck(qualifiedTableName: string): string {
254
362
  return `SELECT NOT EXISTS (SELECT 1 FROM ${qualifiedTableName} LIMIT 1)`;
255
363
  }
@@ -525,7 +525,12 @@ PRIMARY KEY (${table.primaryKey.columns.map(quoteIdentifier).join(', ')})`,
525
525
  precheck: [
526
526
  {
527
527
  description: `ensure unique constraint "${constraintName}" is missing`,
528
- sql: constraintExistsCheck({ constraintName, schema: schemaName, exists: false }),
528
+ sql: constraintExistsCheck({
529
+ constraintName,
530
+ schema: schemaName,
531
+ table: tableName,
532
+ exists: false,
533
+ }),
529
534
  },
530
535
  ],
531
536
  execute: [
@@ -539,7 +544,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
539
544
  postcheck: [
540
545
  {
541
546
  description: `verify unique constraint "${constraintName}" exists`,
542
- sql: constraintExistsCheck({ constraintName, schema: schemaName }),
547
+ sql: constraintExistsCheck({ constraintName, schema: schemaName, table: tableName }),
543
548
  },
544
549
  ],
545
550
  });
@@ -685,6 +690,7 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
685
690
  sql: constraintExistsCheck({
686
691
  constraintName: fkName,
687
692
  schema: schemaName,
693
+ table: tableName,
688
694
  exists: false,
689
695
  }),
690
696
  },
@@ -698,7 +704,11 @@ UNIQUE (${unique.columns.map(quoteIdentifier).join(', ')})`,
698
704
  postcheck: [
699
705
  {
700
706
  description: `verify foreign key "${fkName}" exists`,
701
- sql: constraintExistsCheck({ constraintName: fkName, schema: schemaName }),
707
+ sql: constraintExistsCheck({
708
+ constraintName: fkName,
709
+ schema: schemaName,
710
+ table: tableName,
711
+ }),
702
712
  },
703
713
  ],
704
714
  });