@@ -36,7 +36,7 @@ export function diffIntrospections(
3636 entries . push ( {
3737 severity : "warn" ,
3838 kind : "live-only" ,
39- message : `+ table ${ key } (exists in db, missing in schema.ts)` ,
39+ message : `table ${ key } (exists in db, missing in schema.ts)` ,
4040 } ) ;
4141 continue ;
4242 }
@@ -48,7 +48,7 @@ export function diffIntrospections(
4848 entries . push ( {
4949 severity : "warn" ,
5050 kind : "schema-only" ,
51- message : `- table ${ key } (in schema.ts, missing in db)` ,
51+ message : `table ${ key } (in schema.ts, missing in db)` ,
5252 } ) ;
5353 }
5454 }
@@ -72,7 +72,7 @@ function diffColumns(
7272 entries . push ( {
7373 severity : "warn" ,
7474 kind : "live-only" ,
75- message : `+ column ${ key } .${ name } (in db, missing in schema.ts)` ,
75+ message : `column ${ key } .${ name } (in db, missing in schema.ts)` ,
7676 } ) ;
7777 continue ;
7878 }
@@ -81,7 +81,7 @@ function diffColumns(
8181 entries . push ( {
8282 severity : "warn" ,
8383 kind : "type-mismatch" ,
84- message : `~ column ${ key } .${ name } (${ declaredCol . pgType } declared, ${ liveCol . pgType } in db)` ,
84+ message : `column ${ key } .${ name } (${ declaredCol . pgType } declared, ${ liveCol . pgType } in db)` ,
8585 } ) ;
8686 }
8787 diffColumnMetadata ( key , name , liveCol , declaredCol , entries ) ;
@@ -92,7 +92,7 @@ function diffColumns(
9292 entries . push ( {
9393 severity : "warn" ,
9494 kind : "schema-only" ,
95- message : `- column ${ key } .${ name } (in schema.ts, missing in db)` ,
95+ message : `column ${ key } .${ name } (in schema.ts, missing in db)` ,
9696 } ) ;
9797 }
9898 }
@@ -109,6 +109,13 @@ function tableKey(table: Pick<IntrospectedTable, "schema" | "name">): string {
109109 * Runtime writes and migrations depend on nullability, defaults, keys,
110110 * generated columns, and FK actions, so drift detection must compare the
111111 * metadata captured by introspection instead of stopping at `pgType`.
112+ *
113+ * Server-generated columns get special treatment: when both sides agree the
114+ * column is server-generated, we skip `hasDefault` and `defaultExpression`
115+ * comparisons because the live DB stores the literal `nextval(...)` /
116+ * `GENERATED AS IDENTITY` expression while the schema models the same fact
117+ * as `serverGenerated: true` metadata. Comparing them would produce noise on
118+ * every introspect → verify roundtrip for serial / bigserial / identity PKs.
112119 */
113120function diffColumnMetadata (
114121 table : string ,
@@ -125,22 +132,28 @@ function diffColumnMetadata(
125132 declared . nullable ,
126133 entries ,
127134 ) ;
128- compareField (
129- table ,
130- column ,
131- "hasDefault" ,
132- live . hasDefault ,
133- declared . hasDefault ,
134- entries ,
135- ) ;
136- compareField (
137- table ,
138- column ,
139- "defaultExpression" ,
140- live . defaultExpression ,
141- declared . defaultExpression ,
142- entries ,
143- ) ;
135+
136+ const bothServerGenerated =
137+ Boolean ( live . serverGenerated ) && Boolean ( declared . serverGenerated ) ;
138+ if ( ! bothServerGenerated ) {
139+ compareField (
140+ table ,
141+ column ,
142+ "hasDefault" ,
143+ live . hasDefault ,
144+ declared . hasDefault ,
145+ entries ,
146+ ) ;
147+ compareField (
148+ table ,
149+ column ,
150+ "defaultExpression" ,
151+ normalizeDefaultExpression ( live . defaultExpression ) ,
152+ normalizeDefaultExpression ( declared . defaultExpression ) ,
153+ entries ,
154+ ) ;
155+ }
156+
144157 compareField (
145158 table ,
146159 column ,
@@ -149,22 +162,24 @@ function diffColumnMetadata(
149162 Boolean ( declared . isPrimaryKey ) ,
150163 entries ,
151164 ) ;
152- compareField (
153- table ,
154- column ,
155- "serverGenerated" ,
156- Boolean ( live . serverGenerated ) ,
157- Boolean ( declared . serverGenerated ) ,
158- entries ,
159- ) ;
165+ if ( live . isPrimaryKey || declared . isPrimaryKey ) {
166+ compareField (
167+ table ,
168+ column ,
169+ "serverGenerated" ,
170+ Boolean ( live . serverGenerated ) ,
171+ Boolean ( declared . serverGenerated ) ,
172+ entries ,
173+ ) ;
174+ }
160175
161176 const liveRef = normalizeReference ( live . references ) ;
162177 const declaredRef = normalizeReference ( declared . references ) ;
163178 if ( liveRef !== declaredRef ) {
164179 entries . push ( {
165180 severity : "warn" ,
166181 kind : "type-mismatch" ,
167- message : `~ column ${ table } .${ column } foreign key (${ declaredRef } declared, ${ liveRef } in db)` ,
182+ message : `column ${ table } .${ column } foreign key (${ declaredRef } declared, ${ liveRef } in db)` ,
168183 } ) ;
169184 }
170185}
@@ -182,7 +197,7 @@ function compareField(
182197 entries . push ( {
183198 severity : "warn" ,
184199 kind : "type-mismatch" ,
185- message : `~ column ${ table } .${ column } ${ field } (${ formatValue (
200+ message : `column ${ table } .${ column } ${ field } (${ formatValue (
186201 declared ,
187202 ) } declared, ${ formatValue ( live ) } in db)`,
188203 } ) ;
@@ -206,3 +221,34 @@ function normalizeReference(
206221function formatValue ( value : unknown ) : string {
207222 return value === undefined ? "undefined" : JSON . stringify ( value ) ;
208223}
224+
225+ /**
226+ * Strip the trivial `'literal'::type` cast Postgres emits around quoted
227+ * string defaults so that `'member'::text` (live) compares equal to `member`
228+ * (declared). Also unescapes `''` -> `'` inside the literal.
229+ *
230+ * Deliberately conservative:
231+ * - Matches a SINGLE quoted literal followed by a single `::type` cast.
232+ * - Does NOT touch expressions that contain `||`, function calls, or
233+ * additional casts — those are kept verbatim and compared as-is so we
234+ * don't claim equality between two non-trivially-different expressions
235+ * and silently miss real drift. Example: `'foo'::text || 'bar'::text`
236+ * and `'foobar'` stay distinct.
237+ */
238+ function normalizeDefaultExpression (
239+ value : string | undefined ,
240+ ) : string | undefined {
241+ if ( value === undefined ) return undefined ;
242+ const trimmed = value . trim ( ) ;
243+ const castedString = SIMPLE_CAST_LITERAL . exec ( trimmed ) ;
244+ if ( castedString ) return castedString [ 1 ] . replaceAll ( "''" , "'" ) ;
245+ return trimmed ;
246+ }
247+
248+ /**
249+ * Matches `'literal'::type` where the literal is a single quoted string with
250+ * `''` escaping and the type is a simple identifier (no parens, no `||`,
251+ * no further casts).
252+ */
253+ const SIMPLE_CAST_LITERAL =
254+ / ^ ' ( (?: [ ^ ' ] | ' ' ) * ) ' : : [ a - z A - Z _ ] [ \w ] * (?: \s * \( \s * \d + \s * \) ) ? $ / ;
0 commit comments