From 5293022db854bf389eb9d63e149e03e7108df003 Mon Sep 17 00:00:00 2001 From: Mahathir Mohammad Shuvo Date: Tue, 25 Aug 2026 19:47:24 +0600 Subject: [PATCH 1/4] fix(target-postgres): measure identifier length in bytes, not characters PostgreSQL truncates identifiers at NAMEDATALEN - 1, which is 63 *bytes*. quoteIdentifier compared identifier.length, so a name written in non-ASCII characters could sit well under 63 characters and still overrun: a 50-character Cyrillic column name is 96 UTF-8 bytes, and the warning never fired. validateEnumValueLength in the same module already measured bytes via TextEncoder. Both checks now share one byteLength helper so they cannot drift apart again, and the warning text says "byte" rather than "character". Signed-off-by: Mahathir Mohammad Shuvo --- .../3-targets/postgres/src/core/sql-utils.ts | 20 ++++++++--- .../3-targets/postgres/test/sql-utils.test.ts | 35 ++++++++++++++++++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts index 1673f289a3fb..9ffca6a41bd0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts +++ b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts @@ -14,13 +14,25 @@ import { postgresError } from './errors'; const MAX_IDENTIFIER_LENGTH = 63; +const utf8 = new TextEncoder(); + +/** + * UTF-8 byte length — the unit PostgreSQL measures identifiers and enum labels + * in. `NAMEDATALEN - 1` is 63 *bytes*, so a name written in non-ASCII + * characters can sit well under 63 characters and still overrun. Both length + * checks in this module read through here so they cannot drift apart. + */ +function byteLength(value: string): number { + return utf8.encode(value).length; +} + /** * Validates and quotes a PostgreSQL identifier (table, column, type, schema names). * * Security validations: * - Rejects null bytes which could cause truncation or unexpected behavior * - Rejects empty identifiers - * - Warns on identifiers exceeding PostgreSQL's 63-character limit + * - Warns on identifiers exceeding PostgreSQL's 63-byte limit * * @throws `CONTRACT.IDENTIFIER_INVALID` structured error If the identifier contains null bytes or is empty */ @@ -35,9 +47,9 @@ export function quoteIdentifier(identifier: string): string { meta: { value: identifier.replace(/\0/g, '\\0'), context: 'identifier' }, }); } - if (identifier.length > MAX_IDENTIFIER_LENGTH) { + if (byteLength(identifier) > MAX_IDENTIFIER_LENGTH) { console.warn( - `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's ${MAX_IDENTIFIER_LENGTH}-character limit and will be truncated`, + `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's ${MAX_IDENTIFIER_LENGTH}-byte limit and will be truncated`, ); } return `"${identifier.replace(/"/g, '""')}"`; @@ -95,7 +107,7 @@ export function quoteQualifiedName(name: string): string { * @throws `CONTRACT.IDENTIFIER_INVALID` structured error If the value exceeds the maximum length */ export function validateEnumValueLength(value: string, enumTypeName: string): void { - if (new TextEncoder().encode(value).length > MAX_IDENTIFIER_LENGTH) { + if (byteLength(value) > MAX_IDENTIFIER_LENGTH) { throw postgresError( 'CONTRACT.IDENTIFIER_INVALID', `Enum value "${value.slice(0, 20)}..." for type "${enumTypeName}" exceeds PostgreSQL's ` + diff --git a/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts b/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts index 07380183b169..61e02cea6fb4 100644 --- a/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts +++ b/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts @@ -68,11 +68,44 @@ describe('quoteIdentifier', () => { expect(result).toBe(`"${identifier}"`); expect(warnSpy).toHaveBeenCalledWith( - `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's 63-character limit and will be truncated`, + `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's 63-byte limit and will be truncated`, ); warnSpy.mockRestore(); }); + + it('warns for a multibyte identifier over 63 bytes but under 63 characters', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // A Cyrillic column name: 50 characters, 96 UTF-8 bytes. Postgres measures + // the byte length, so it stores this name truncated to 63 bytes — the + // declared object can then never be matched against the live one. + const identifier = 'электронная_почта_адрес_подтверждена_пользователем'; + + const result = quoteIdentifier(identifier); + + expect(identifier.length).toBeLessThanOrEqual(63); + expect(new TextEncoder().encode(identifier).length).toBeGreaterThan(63); + expect(result).toBe(`"${identifier}"`); + expect(warnSpy).toHaveBeenCalledWith( + `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's 63-byte limit and will be truncated`, + ); + + warnSpy.mockRestore(); + }); + + it('stays silent for a multibyte identifier that is exactly 63 bytes', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // '€' (U+20AC) is 3 UTF-8 bytes: 21 characters = 63 bytes, exactly at the limit. + const identifier = '€'.repeat(21); + + const result = quoteIdentifier(identifier); + + expect(new TextEncoder().encode(identifier).length).toBe(63); + expect(result).toBe(`"${identifier}"`); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); }); describe('escapeLiteral', () => { From 1f88c751234bb7328b277a21d6a7b5e5e4204f57 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:12:06 +0200 Subject: [PATCH 2/4] test(target-postgres): cover the 63-byte limit with identifiers longer than 21 code units A UTF-16 code unit is at most 3 UTF-8 bytes, so an identifier of 21 code units or fewer can never exceed 63 bytes. The next commit skips the byte count for those names. The old exactly-63-byte fixture was 21 euro signs, so after that change it would no longer reach the byte comparison. The exactly-63-byte fixture is now 42 ASCII characters plus 7 euro signs (49 code units). A new test covers the shortest name that can go over: 21 euro signs plus one ASCII character, 22 code units and 64 bytes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-targets/postgres/test/sql-utils.test.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts b/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts index 61e02cea6fb4..8baca82fff55 100644 --- a/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts +++ b/packages/3-targets/3-targets/postgres/test/sql-utils.test.ts @@ -93,13 +93,31 @@ describe('quoteIdentifier', () => { warnSpy.mockRestore(); }); + it('warns for the shortest identifier that can exceed 63 bytes', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // '€' (U+20AC) is 3 UTF-8 bytes, the most one UTF-16 code unit can take. + const identifier = `${'€'.repeat(21)}a`; + + const result = quoteIdentifier(identifier); + + expect(identifier.length).toBe(22); + expect(new TextEncoder().encode(identifier).length).toBe(64); + expect(result).toBe(`"${identifier}"`); + expect(warnSpy).toHaveBeenCalledWith( + `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's 63-byte limit and will be truncated`, + ); + + warnSpy.mockRestore(); + }); + it('stays silent for a multibyte identifier that is exactly 63 bytes', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - // '€' (U+20AC) is 3 UTF-8 bytes: 21 characters = 63 bytes, exactly at the limit. - const identifier = '€'.repeat(21); + // Over 21 code units, so the length alone cannot rule out an overrun. + const identifier = `${'a'.repeat(42)}${'€'.repeat(7)}`; const result = quoteIdentifier(identifier); + expect(identifier.length).toBe(49); expect(new TextEncoder().encode(identifier).length).toBe(63); expect(result).toBe(`"${identifier}"`); expect(warnSpy).not.toHaveBeenCalled(); From 6ec2a9fac48e9e6562cafae6bc38760e23e5dc9d Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:12:36 +0200 Subject: [PATCH 3/4] perf(target-postgres): skip the UTF-8 byte count for identifiers too short to exceed 63 bytes quoteIdentifier runs for every table, column and alias on every runtime query. The byte count encodes the name into a new Uint8Array each time. One UTF-16 code unit is at most 3 UTF-8 bytes, so a name of 21 code units or fewer always fits, and only longer names are encoded. The warning fires for exactly the same names as before. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-targets/3-targets/postgres/src/core/sql-utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts index 9ffca6a41bd0..d3807fb5b65b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts +++ b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts @@ -14,6 +14,8 @@ import { postgresError } from './errors'; const MAX_IDENTIFIER_LENGTH = 63; +const MAX_UTF8_BYTES_PER_UTF16_UNIT = 3; + const utf8 = new TextEncoder(); /** @@ -47,7 +49,10 @@ export function quoteIdentifier(identifier: string): string { meta: { value: identifier.replace(/\0/g, '\\0'), context: 'identifier' }, }); } - if (byteLength(identifier) > MAX_IDENTIFIER_LENGTH) { + if ( + identifier.length * MAX_UTF8_BYTES_PER_UTF16_UNIT > MAX_IDENTIFIER_LENGTH && + byteLength(identifier) > MAX_IDENTIFIER_LENGTH + ) { console.warn( `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's ${MAX_IDENTIFIER_LENGTH}-byte limit and will be truncated`, ); From 63f57c13bec89e44fd75a6a3c9185f1783c15429 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 24 Sep 2026 20:13:05 +0200 Subject: [PATCH 4/4] refactor(target-postgres): rename MAX_IDENTIFIER_LENGTH to MAX_IDENTIFIER_BYTES The constant is a byte count. "Length" suggests .length, which counts UTF-16 code units, the mix-up this PR fixes. The new name matches WIRE_NAME_PREFIX_MAX_BYTES in the SQL schema IR. The doc comment on byteLength is cut to one line that says what the helper returns. Co-Authored-By: Claude Opus 5.5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-targets/postgres/src/core/sql-utils.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts index d3807fb5b65b..23ecececb885 100644 --- a/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts +++ b/packages/3-targets/3-targets/postgres/src/core/sql-utils.ts @@ -12,18 +12,13 @@ import { postgresError } from './errors'; -const MAX_IDENTIFIER_LENGTH = 63; +const MAX_IDENTIFIER_BYTES = 63; const MAX_UTF8_BYTES_PER_UTF16_UNIT = 3; const utf8 = new TextEncoder(); -/** - * UTF-8 byte length — the unit PostgreSQL measures identifiers and enum labels - * in. `NAMEDATALEN - 1` is 63 *bytes*, so a name written in non-ASCII - * characters can sit well under 63 characters and still overrun. Both length - * checks in this module read through here so they cannot drift apart. - */ +/** UTF-8 byte length — the unit PostgreSQL measures identifiers and enum labels in. */ function byteLength(value: string): number { return utf8.encode(value).length; } @@ -50,11 +45,11 @@ export function quoteIdentifier(identifier: string): string { }); } if ( - identifier.length * MAX_UTF8_BYTES_PER_UTF16_UNIT > MAX_IDENTIFIER_LENGTH && - byteLength(identifier) > MAX_IDENTIFIER_LENGTH + identifier.length * MAX_UTF8_BYTES_PER_UTF16_UNIT > MAX_IDENTIFIER_BYTES && + byteLength(identifier) > MAX_IDENTIFIER_BYTES ) { console.warn( - `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's ${MAX_IDENTIFIER_LENGTH}-byte limit and will be truncated`, + `Identifier "${identifier.slice(0, 20)}..." exceeds PostgreSQL's ${MAX_IDENTIFIER_BYTES}-byte limit and will be truncated`, ); } return `"${identifier.replace(/"/g, '""')}"`; @@ -112,11 +107,11 @@ export function quoteQualifiedName(name: string): string { * @throws `CONTRACT.IDENTIFIER_INVALID` structured error If the value exceeds the maximum length */ export function validateEnumValueLength(value: string, enumTypeName: string): void { - if (byteLength(value) > MAX_IDENTIFIER_LENGTH) { + if (byteLength(value) > MAX_IDENTIFIER_BYTES) { throw postgresError( 'CONTRACT.IDENTIFIER_INVALID', `Enum value "${value.slice(0, 20)}..." for type "${enumTypeName}" exceeds PostgreSQL's ` + - `${MAX_IDENTIFIER_LENGTH}-byte label limit`, + `${MAX_IDENTIFIER_BYTES}-byte label limit`, { meta: { value, context: 'enum-label' } }, ); }