fix(schema-ir): match int8 decimal-text defaults against their safe-integer number - #30194
Merged
Conversation
…nteger number A literal @default on an int8 column made db migrate fail its own post-apply verification: Postgres's own introspection normalizer (parsePostgresDefault's isBigInt branch) always reads an int8/bigint default back as decimal text, regardless of DDL spelling, so pg/int8number@1's JSON-number canonical default (e.g. 0) could never match the introspected "0" it produced. The fix lives in resolvedDefaultsEqual's comparison-only helper, normalizeLiteralValue, beside the existing temporal-normalization branch: a safe-integer number is compared against the decimal text it denotes only under an int8/bigint native type. literalValuesEqual stays byte-for-byte unchanged and the normalized value never reaches resolvedDefault or DDL rendering, so it cannot corrupt the codec-typed value pgRenderDdlColumnDefault depends on for pg/int8@1 (decimal string) or pg/int8number@1 (JSON number). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UcqoY3CKfnubdZt5YQk2Rq Signed-off-by: Steven McClankerton <tatarintsev@prisma.io>
@prisma/orm-extension-arktype-json
@prisma/orm-extension-middleware-cache
@prisma/orm-extension-paradedb
@prisma/orm-extension-pgvector
@prisma/orm-extension-postgis
@prisma/orm-extension-supabase
@prisma/orm-family-mongo
@prisma/orm-family-sql
@prisma/orm-framework
@prisma/orm-mongo
@prisma/orm-postgres
@prisma/orm-sqlite
@prisma/orm-target-mongo
@prisma/orm-target-postgres
@prisma/orm-target-sqlite
@prisma/orm-toolchain
commit: |
Contributor
size-limit report 📦
|
Contributor
|
Caution Review failedAn error occurred during the review process. Please try again later. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
SevInf
approved these changes
Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A literal
@defaulton anint8column madedb migratefail its own post-apply verification withMIGRATION.SCHEMA_VERIFY_FAILED. Anint4column with the same default verified fine.Fixes #30174
The true root cause
parsePostgresDefault'sisBigIntbranch (packages/3-targets/3-targets/postgres/src/core/default-normalizer.ts:225-228) returns decimal text for everyint8default, regardless of DDL spelling. So even a bareDEFAULT 0introspects back as"0"(string) against the contract's0(number) forpg/int8number@1. It is a string/number type asymmetry, not a syntax one.Postgres does not re-derive a quoted cast for small
bigintdefaults:bigint DEFAULT 0comes back as0— bare withinint4range, quoted+cast only beyond it. Verified on PostgreSQL 17.11 and PGlite 18.3, byte-identical:The
'0'::int8our own DDL renders is ours, not Postgres's:pgRenderDdlColumnDefaultruns the value throughcodec.encode,pgInt8NumberEncodereturns a string, andpgInlineLiteralquotes and casts any string wire.Why the fix cannot live on the render path or in
postgresResolveDefaultpostgresResolveDefault's output becomesresolvedDefault(packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts:127-129), whichcolumnLike()(packages/3-targets/3-targets/postgres/src/core/migrations/column-ddl-rendering.ts:56) maps into theStorageColumn.defaultslot that reachescodec.decodeJson(packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts:1770). "One differ drives both verify and plan" (packages/3-targets/3-targets/postgres/src/core/migrations/diff-database-schema.ts:214).The two codecs bound to the same
resolvedNativeTypeare mutually incompatible:PgInt8NumberCodec.decodeJsonrequires a JSON number (codecs.ts:723-725— its own doc comment calls this "the deliberate exception to the decimal-text rule ... and the codec's purpose"), whilePgInt8Codec.decodeJsonrequires a decimal string (codecs.ts:660-668). Introspection has no codec identity to consult, so it cannot pick a shape per column, and any normalization performed on the sharedresolvedDefaultbefore it reaches DDL rendering corrupts one codec or the other. Confirmed with three live-database probes:What the fix is
One spelling-gated branch in the module-private
normalizeLiteralValue(packages/2-sql/1-core/schema-ir/src/ir/resolved-default-equality.ts), beside the existing temporal-normalization branch: a safe-integer number is compared against the decimal text it denotes, only under anint8/bigintnative type. It normalizes rather than widens:literalValuesEqualis byte-for-byte unchanged, andresolvedDefaultsEqual's signature is unchanged. The normalized value lives for the one comparison call and never reachesresolvedDefaultor the DDL renderer, so it cannot corrupt the codec-typed valuepgRenderDdlColumnDefaultdepends on.Soundness
String(n)is injective over safe integers, so no two distinct values collide.pgInt8NumberGuard(packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts:158-169) already rejects everything outsideNumber.isSafeIntegerat both encode and decode, so the gate this fix adds is exactly coextensive with the codec's own domain and can produce no reachable false negative.On the reporter's claim
He is right that
BigIntandBigIntNumberproduce byte-identical DDL. What's wrong is only the inference that the codec family causes it:pg/int8@1carries"value": "0"and round-trips text→text;pg/int8number@1carries"value": 0and is the sole reproducer.The layering residue, stated honestly
int8is a Postgres alias for the standard SQLbigint, and this shared SQL-core package now names it.isTemporalNativeTypedirectly above already matchestimestamptzthe same way, andbigintitself is standard SQL accepted by MySQL/MSSQL/SQLite. The clean fix is a comparison-only target hook — see follow-up below.Testing
schema-ir: 264 passedfamily-sql(packages/2-sql/9-family): 340 passedadapter-postgres: 867 passed, 3 expected-fail, 0 failedNumber.MAX_SAFE_INTEGER-adjacentint8default with no precision lossint8/bigintnative type, a rounded-number-vs-exact-text rejection, an outside-safe-integer-range rejection (even when the text is exact), and two huge decimal-text strings compared by identityFollow-ups (not opened as issues; not fixed in this PR)
DefaultResolver(packages/2-sql/9-family/src/core/migrations/contract-to-schema-ir.ts:62-71) cannot carry this normalization because its output is dual-purpose (verify and plan/DDL). The proper home is a new comparison-only target hook alongsideDefaultResolver/NativeTypeExpander/DefaultRenderer, so this equality file needs noint8/bigintspelling at all. That's an architecture change (Ask First) beyond this bug fix.int8-family list-default crash.BigInt[] @default([1,2])->CLI.UNEXPECTED: pg/int8@1 database JSON value must be a decimal string;BigIntNumber[] @default([1,2])->... must be a number. Cause:pgRenderDdlColumnDefault(control-adapter.ts:1770) hands the whole array tocodec.decodeJsoninstead of decoding per element. Pre-existing, out of scope, and the same codec-shape constraint this fix navigates — the reporter's money model has 22 defaultedint8columns.🤖 Generated with Claude Code
https://claude.ai/code/session_01UcqoY3CKfnubdZt5YQk2Rq