diff --git a/crates/bindings-typescript/src/lib/binary_reader.ts b/crates/bindings-typescript/src/lib/binary_reader.ts index 7e819e20a64..46a3bd68585 100644 --- a/crates/bindings-typescript/src/lib/binary_reader.ts +++ b/crates/bindings-typescript/src/lib/binary_reader.ts @@ -1,3 +1,6 @@ +// Shared decoder: `readString` is called once per string column per row. +const textDecoder = new TextDecoder('utf-8'); + export default class BinaryReader { /** * The DataView used to read values from the binary data. @@ -196,6 +199,6 @@ export default class BinaryReader { // A view is safe here: TextDecoder copies the bytes synchronously, so nothing // retains a reference to the reader's buffer. Avoids readUInt8Array's copy. const bytes = this.readBytes(length); - return new TextDecoder('utf-8').decode(bytes); + return textDecoder.decode(bytes); } } diff --git a/crates/bindings-typescript/src/lib/binary_writer.ts b/crates/bindings-typescript/src/lib/binary_writer.ts index 048ecccb501..322216f7069 100644 --- a/crates/bindings-typescript/src/lib/binary_writer.ts +++ b/crates/bindings-typescript/src/lib/binary_writer.ts @@ -1,5 +1,8 @@ import { fromByteArray } from 'base64-js'; +// Shared encoder: `writeString` is called once per string column per row. +const textEncoder = new TextEncoder(); + const ArrayBufferPrototypeTransfer = ArrayBuffer.prototype.transfer ?? function (this: ArrayBuffer, newByteLength) { @@ -206,8 +209,26 @@ export default class BinaryWriter { } writeString(value: string): void { - const encoder = new TextEncoder(); - const encodedString = encoder.encode(value); - this.writeUInt8Array(encodedString); + // Fast path: pure-ASCII strings are written straight into the buffer, + // one byte per char, with no intermediate allocation. Non-ASCII input + // falls back to the shared encoder. This method runs once per string + // column per row on every insert/update, so per-call allocations + // (a fresh TextEncoder and a temporary Uint8Array) dominated its cost. + const len = value.length; + this.expandBuffer(4 + len); + const bytes = new Uint8Array(this.buffer.buffer); + let offset = this.offset + 4; + let i = 0; + for (; i < len; i++) { + const c = value.charCodeAt(i); + if (c >= 0x80) break; + bytes[offset++] = c; + } + if (i === len) { + this.view.setUint32(this.offset, len, true); + this.offset = offset; + return; + } + this.writeUInt8Array(textEncoder.encode(value)); } } diff --git a/crates/bindings-typescript/tests/binary_read_write.test.ts b/crates/bindings-typescript/tests/binary_read_write.test.ts index fd4933d56f1..a51d7f58a95 100644 --- a/crates/bindings-typescript/tests/binary_read_write.test.ts +++ b/crates/bindings-typescript/tests/binary_read_write.test.ts @@ -186,3 +186,60 @@ describe('readUInt8Array buffer ownership', () => { expect(value).toBe('héllo ☃'); }); }); + +describe('writeString', () => { + // BSATN layout for `string`: u32 byte length (LE) followed by UTF-8 bytes. + const expected = (value: string): number[] => { + const utf8 = new TextEncoder().encode(value); + return [ + utf8.length & 0xff, + (utf8.length >> 8) & 0xff, + (utf8.length >> 16) & 0xff, + (utf8.length >> 24) & 0xff, + ...utf8, + ]; + }; + const written = (value: string, initialCapacity = 8): number[] => { + const writer = new BinaryWriter(initialCapacity); + writer.writeString(value); + return [...writer.getBuffer()]; + }; + + test.each([ + ['empty', ''], + ['ascii', 'hello world'], + ['ascii at the fast-path boundary', '\x7f'], + ['latin-1', 'héllo'], + ['cjk', '日本語'], + ['astral (surrogate pair)', 'emoji 🎉 mix'], + ['ascii prefix then non-ascii', 'ascii then ÿ'], + ['long ascii', 'x'.repeat(10_000)], + ['long non-ascii', 'ü'.repeat(10_000)], + ])('%s encodes like TextEncoder', (_name, value) => { + expect(written(value)).toEqual(expected(value)); + }); + + test('grows the buffer from a tiny initial capacity', () => { + expect(written('hello world', 1)).toEqual(expected('hello world')); + expect(written('héllo', 1)).toEqual(expected('héllo')); + }); + + test('round-trips through readString', () => { + for (const value of ['', 'plain', 'héllo ☃', '🎉'.repeat(100)]) { + const writer = new BinaryWriter(4); + writer.writeString(value); + expect(new BinaryReader(writer.getBuffer()).readString()).toBe(value); + } + }); + + test('consecutive writes stay contiguous', () => { + const writer = new BinaryWriter(4); + writer.writeString('ab'); + writer.writeString('ç'); + writer.writeU8(7); + const reader = new BinaryReader(writer.getBuffer()); + expect(reader.readString()).toBe('ab'); + expect(reader.readString()).toBe('ç'); + expect(reader.readU8()).toBe(7); + }); +});