From 1f027fdf6cc60a890e3b97c703c6f0cb9fd7bfff Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:15:12 -0700 Subject: [PATCH] Infer timestamptz columns as TimestamptzString contract infer mapped timestamptz to the Temporal-backed Timestamptz, whose codec needs a global Temporal. Runtimes without it (e.g. Node 24) throw RUNTIME.TEMPORAL_UNAVAILABLE on the first read of an inferred contract, and orm init already scaffolds TimestamptzString for the same columns - so inference and init disagreed. Map timestamptz (and its parameterized and longhand spellings) to TimestamptzString at inference time instead. Fixes prisma/orm#30252. --- .../src/core/psl-infer/postgres-type-map.ts | 10 ++++--- .../test/psl-infer/infer-parse-emit.test.ts | 8 ++++-- .../test/psl-infer/postgres-type-map.test.ts | 27 ++++++++++++------- .../print-psl/print-psl.core.test.ts | 2 +- .../print-psl.defaults-and-types.test.ts | 18 ++++++------- .../src/core/control-mutation-defaults.ts | 5 ++-- 6 files changed, 44 insertions(+), 26 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts index 769786748f4e..db2d86590296 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/postgres-type-map.ts @@ -29,8 +29,12 @@ const PRESERVED_NATIVE_TYPES: Record = { decimal: 'Numeric', timestamp: 'Timestamp', 'timestamp without time zone': 'Timestamp', - timestamptz: 'Timestamptz', - 'timestamp with time zone': 'Timestamptz', + // timestamptz introspects to the string representation: the Temporal-backed + // `Timestamptz` needs a global `Temporal`, which some runtimes (e.g. Node 24) + // do not ship, so an inferred contract would fail on first read. This also + // matches the `orm init` scaffold default (`createdAt TimestamptzString`). + timestamptz: 'TimestamptzString', + 'timestamp with time zone': 'TimestamptzString', date: 'Date', time: 'Time', 'time without time zone': 'Time', @@ -47,7 +51,7 @@ const PARAMETERIZED_NATIVE_TYPES: Record = { numeric: 'Numeric', decimal: 'Numeric', timestamp: 'Timestamp', - timestamptz: 'Timestamptz', + timestamptz: 'TimestamptzString', time: 'Time', timetz: 'Timetz', }; diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-parse-emit.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-parse-emit.test.ts index cde3ec95f971..2cfc8542a697 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/infer-parse-emit.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/infer-parse-emit.test.ts @@ -25,6 +25,10 @@ const authoringTypes = { kind: 'typeConstructor', output: { codecId: 'pg/timestamptz-temporal@1', nativeType: 'timestamptz' }, }, + TimestamptzString: { + kind: 'typeConstructor', + output: { codecId: 'pg/timestamptz-string@1', nativeType: 'timestamptz' }, + }, VarChar: { kind: 'typeConstructor', args: [{ kind: 'number', name: 'length', integer: true, minimum: 1, optional: true }], @@ -136,7 +140,7 @@ describe('Postgres PSL inference round trip', () => { expect(inferred).not.toContain('bareAmount Numeric()'); expect(inferred).toMatch(/jsonValue\s+Json/); expect(inferred).toMatch(/jsonbValue\s+Jsonb/); - expect(inferred).toMatch(/occurredAt\s+Timestamptz/); + expect(inferred).toMatch(/occurredAt\s+TimestamptzString/); expect(inferred).toMatch(/label\s+VarChar\(191\)/); const emitted = parseAndEmit(inferred); @@ -164,7 +168,7 @@ describe('Postgres PSL inference round trip', () => { json_value: { codecId: 'pg/json@1', nativeType: 'json', nullable: false }, jsonb_value: { codecId: 'pg/jsonb@1', nativeType: 'jsonb', nullable: false }, occurred_at: { - codecId: 'pg/timestamptz-temporal@1', + codecId: 'pg/timestamptz-string@1', nativeType: 'timestamptz', nullable: false, }, diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts index a77d62b9cd5f..f69dbf527ad2 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/postgres-type-map.test.ts @@ -14,11 +14,11 @@ describe('createPostgresTypeMap', () => { nativeType: 'numeric', }); expect(typeMap.resolve('timestamptz')).toEqual({ - pslType: { name: 'Timestamptz' }, + pslType: { name: 'TimestamptzString' }, nativeType: 'timestamptz', }); expect(typeMap.resolve('timestamp with time zone')).toEqual({ - pslType: { name: 'Timestamptz' }, + pslType: { name: 'TimestamptzString' }, nativeType: 'timestamp with time zone', }); expect(typeMap.resolve('jsonb')).toEqual({ pslType: { name: 'Jsonb' }, nativeType: 'jsonb' }); @@ -138,28 +138,36 @@ describe('createPostgresTypeMap', () => { }); }); -describe('representation-explicit spellings stay out of introspection', () => { +describe('temporal representation spellings in introspection', () => { const map = createPostgresTypeMap(); it.each([ ['date', 'Date'], ['timestamp', 'Timestamp'], ['timestamp without time zone', 'Timestamp'], - ['timestamptz', 'Timestamptz'], - ['timestamp with time zone', 'Timestamptz'], ['time', 'Time'], ['time without time zone', 'Time'], ])('resolves %s to the bare %s, never a *String spelling', (nativeType, pslName) => { expect(map.resolve(nativeType)).toMatchObject({ pslType: { name: pslName } }); }); - it('keeps precision on the bare spelling', () => { + it.each([ + ['timestamptz', 'TimestamptzString'], + ['timestamp with time zone', 'TimestamptzString'], + ])( + 'resolves %s to the runtime-safe %s spelling (Temporal is unavailable on some runtimes)', + (nativeType, pslName) => { + expect(map.resolve(nativeType)).toMatchObject({ pslType: { name: pslName } }); + }, + ); + + it('keeps precision on the TimestamptzString spelling', () => { expect(map.resolve('timestamptz(6)')).toMatchObject({ - pslType: { name: 'Timestamptz', args: ['6'] }, + pslType: { name: 'TimestamptzString', args: ['6'] }, }); }); - it('never produces a *String name for any native type it knows', () => { + it('produces a *String spelling only for timestamptz', () => { const natives = [ 'date', 'timestamp', @@ -182,6 +190,7 @@ describe('representation-explicit spellings stay out of introspection', () => { : [], ); - expect(stringSpellings).toEqual([]); + expect(stringSpellings.length).toBeGreaterThan(0); + expect(new Set(stringSpellings)).toEqual(new Set(['TimestamptzString'])); }); }); diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts index fae0d6cd5736..c8b729084f70 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.core.test.ts @@ -75,7 +75,7 @@ describe('printPsl', () => { // WARNING: This table has no primary key in the database model AuditLog { event String - timestamp Timestamptz + timestamp TimestamptzString @@map("audit_log") } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts index a31ef40dfe96..653a69096117 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.defaults-and-types.test.ts @@ -53,11 +53,11 @@ describe('printPsl', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model Post { - id Int @id @default(autoincrement()) - title String @default("Untitled") - isPublished Boolean @default(false) @map("is_published") - viewCount Int @default(0) @map("view_count") - createdAt Timestamptz @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + title String @default("Untitled") + isPublished Boolean @default(false) @map("is_published") + viewCount Int @default(0) @map("view_count") + createdAt TimestamptzString @default(now()) @map("created_at") @@map("post") } @@ -431,10 +431,10 @@ describe('printPsl', () => { // Contract inferred from the live database schema. Edit as needed, then run \`prisma contract emit\`. model Data { - id Int @id - computed String @default(dbgenerated("my_custom_func()")) - payload Jsonb @default(dbgenerated("'{}'::jsonb")) - touchedAt Timestamptz @default(dbgenerated("clock_timestamp()")) @map("touched_at") + id Int @id + computed String @default(dbgenerated("my_custom_func()")) + payload Jsonb @default(dbgenerated("'{}'::jsonb")) + touchedAt TimestamptzString @default(dbgenerated("clock_timestamp()")) @map("touched_at") @@map("data") } diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts index 2249be8de988..629697503976 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts @@ -283,8 +283,9 @@ export const postgresNativeAuthoringTypes = { }, // The representation-explicit spellings. Same columns, same precision, same native types — the // only difference is that a read hands back PostgreSQL's own text instead of a `Temporal.*`, so a - // value Temporal cannot express still round-trips. Authoring-only: they claim no introspection - // mapping, because a bare `timestamptz` column introspects to the Temporal-backed name. + // value Temporal cannot express still round-trips, and they work on runtimes without `Temporal`. + // `timestamptz` introspects to `TimestamptzString` (the `orm init` scaffold default); the remaining + // spellings stay authoring-only and claim no introspection mapping. DateString: { kind: 'typeConstructor', output: { codecId: 'pg/date-string@1', nativeType: 'date' },