diff --git a/packages/2-sql/1-core/schema-ir/src/ir/resolved-default-equality.ts b/packages/2-sql/1-core/schema-ir/src/ir/resolved-default-equality.ts index 31a4c4afb402..a9600041a2d2 100644 --- a/packages/2-sql/1-core/schema-ir/src/ir/resolved-default-equality.ts +++ b/packages/2-sql/1-core/schema-ir/src/ir/resolved-default-equality.ts @@ -5,12 +5,13 @@ import { canonicalStringify } from '@internal/utils/canonical-stringify'; * Structural equality for two resolved column defaults, ported from the * relational walk's `columnDefaultsEqual` normalized branch: kinds must * match; literal values are normalized (Date and temporal-typed strings to - * ISO instants) then compared canonically (JSON objects match their - * canonical string form); function expressions compare case- and + * ISO instants, and a 64-bit-integer native type's safe-integer number to + * its decimal-text spelling) then compared canonically (JSON objects match + * their canonical string form); function expressions compare case- and * whitespace-insensitively. * - * `nativeType` provides the temporal-normalization context (the actual - * side's resolved native type in a diff comparison). + * `nativeType` provides the temporal- and int64-normalization context (the + * actual side's resolved native type in a diff comparison). */ export function resolvedDefaultsEqual( expected: ColumnDefault, @@ -43,6 +44,12 @@ function isTemporalNativeType(nativeType?: string): boolean { return normalized.includes('timestamp') || normalized === 'date'; } +function isInt64NativeType(nativeType?: string): boolean { + if (!nativeType) return false; + const normalized = nativeType.toLowerCase(); + return normalized === 'int8' || normalized === 'bigint'; +} + function normalizeLiteralValue(value: unknown, nativeType?: string): unknown { if (value instanceof Date) { return value.toISOString(); @@ -53,6 +60,9 @@ function normalizeLiteralValue(value: unknown, nativeType?: string): unknown { return parsed.toISOString(); } } + if (typeof value === 'number' && Number.isSafeInteger(value) && isInt64NativeType(nativeType)) { + return String(value); + } return value; } diff --git a/packages/2-sql/1-core/schema-ir/test/resolved-default-equality.test.ts b/packages/2-sql/1-core/schema-ir/test/resolved-default-equality.test.ts index 08dae62306ae..e3d6252ab93c 100644 --- a/packages/2-sql/1-core/schema-ir/test/resolved-default-equality.test.ts +++ b/packages/2-sql/1-core/schema-ir/test/resolved-default-equality.test.ts @@ -103,4 +103,48 @@ describe('resolvedDefaultsEqual', () => { ); }); }); + + describe('int64 literals', () => { + it('matches a safe-integer number against the decimal text it denotes, under int8', () => { + expect({ + numberFirst: resolvedDefaultsEqual(literal(0), literal('0'), 'int8'), + textFirst: resolvedDefaultsEqual(literal('0'), literal(0), 'int8'), + }).toEqual({ numberFirst: true, textFirst: true }); + }); + + it('matches a safe-integer number against the decimal text it denotes, under bigint', () => { + expect(resolvedDefaultsEqual(literal(42), literal('42'), 'bigint')).toBe(true); + }); + + it('matches a negative safe integer against its decimal text', () => { + expect(resolvedDefaultsEqual(literal(-7), literal('-7'), 'int8')).toBe(true); + }); + + it('fires when the decimal text denotes a different number', () => { + expect(resolvedDefaultsEqual(literal(1), literal('2'), 'int8')).toBe(false); + }); + + it('leaves a number against its decimal text alone without an int8/bigint native type', () => { + expect(resolvedDefaultsEqual(literal(0), literal('0'), 'int4')).toBe(false); + expect(resolvedDefaultsEqual(literal(0), literal('0'))).toBe(false); + }); + + it('declines to match a rounded number against the exact decimal text it lost', () => { + expect( + resolvedDefaultsEqual(literal('9007199254740993'), literal(9007199254740992), 'int8'), + ).toBe(false); + }); + + it('declines to match outside the safe-integer range, even when the text is exact', () => { + expect(resolvedDefaultsEqual(literal(1e17), literal('100000000000000000'), 'int8')).toBe( + false, + ); + }); + + it('still compares two decimal-text strings by identity past the safe integer range', () => { + expect( + resolvedDefaultsEqual(literal('9007199254740993'), literal('9007199254740993'), 'int8'), + ).toBe(true); + }); + }); }); diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.int8-literal-default.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.int8-literal-default.integration.test.ts new file mode 100644 index 000000000000..8320b7fbd85c --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/schema-verify.int8-literal-default.integration.test.ts @@ -0,0 +1,171 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage } from '@internal/sql-contract/types'; +import { + PostgresDatabaseSchemaNode, + postgresCreateNamespace, +} from '@internal/target-postgres/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + controlAdapter, + createDriver, + createTestDatabase, + emptySchema, + familyInstance, + formatRunnerFailure, + frameworkComponents, + type PostgresControlDriver, + postgresTargetDescriptor, + resetDatabase, + synthEdges, + testTimeout, +} from './fixtures/runner-fixtures'; + +const PAST_SAFE_INTEGER_TEXT = '9007199254740993'; + +function moneyContract(): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('int8-literal-default'), + storage: new SqlStorage({ + storageHash: coreHash('int8-literal-default'), + namespaces: { + [UNBOUND_NAMESPACE_ID]: postgresCreateNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { + table: { + ps: { + columns: { + id: { nativeType: 'text', codecId: 'pg/text@1', nullable: false }, + v: { + nativeType: 'int8', + codecId: 'pg/int8number@1', + nullable: false, + default: { kind: 'literal', value: 0 }, + }, + w: { + nativeType: 'int4', + codecId: 'pg/int4@1', + nullable: false, + default: { kind: 'literal', value: 0 }, + }, + bigIntSmall: { + nativeType: 'int8', + codecId: 'pg/int8@1', + nullable: false, + default: { kind: 'literal', value: '0' }, + }, + bigIntPastSafeInteger: { + nativeType: 'int8', + codecId: 'pg/int8@1', + nullable: false, + default: { kind: 'literal', value: PAST_SAFE_INTEGER_TEXT }, + }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }, + }, + }, + }), + }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +describe('Schema verification after runner - int8 literal default (issue #30174)', { + concurrent: false, +}, () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + if (database) { + await database.close(); + } + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + if (driver) { + await driver.close(); + driver = undefined; + } + }, testTimeout); + + it('applies and verifies literal defaults on int8 (BigIntNumber, BigInt) and int4 columns', { + timeout: testTimeout, + }, async () => { + const contract = moneyContract(); + const planner = postgresTargetDescriptor.createPlanner(controlAdapter); + const runner = postgresTargetDescriptor.createRunner(familyInstance); + + const planResult = planner.plan({ + contract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (planResult.kind !== 'success') { + throw new Error(`Planner failed: ${planResult.kind}`); + } + + const executeResult = await runner.execute({ + driver: driver!, + perSpaceOptions: [ + { + space: planResult.plan.spaceId ?? APP_SPACE_ID, + plan: planResult.plan, + migrationEdges: synthEdges(planResult.plan), + driver: driver!, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + }, + ], + }); + + if (!executeResult.ok) { + throw new Error( + `db migrate failed on its own post-apply verification:\n${formatRunnerFailure(executeResult.failure)}`, + ); + } + + const schema = await familyInstance.introspect({ driver: driver!, contract }); + const verifyResult = familyInstance.verifySchema({ + contract, + schema, + strict: false, + frameworkComponents, + }); + + expect(verifyResult.ok).toBe(true); + expect(verifyResult.schema.issues).toHaveLength(0); + + PostgresDatabaseSchemaNode.assert(schema); + const column = schema.namespaces['public']?.tables['ps']?.columns['bigIntPastSafeInteger']; + expect(column?.resolvedDefault).toEqual({ kind: 'literal', value: PAST_SAFE_INTEGER_TEXT }); + }); +});