From e89214e3f8a2a66d1158161fd25b9bf905fbebfa Mon Sep 17 00:00:00 2001 From: Ludv1g Date: Fri, 15 May 2026 23:25:56 +0200 Subject: [PATCH 1/2] ts-sdk: coerce numeric inputs to bigint in id-like constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper constructors for `Identity`, `ConnectionId`, `Timestamp`, `TimeDuration`, and `Uuid` all claim to take `bigint` (or `string | bigint` for `Identity`), but real-world callers can end up passing a `number` whenever data round-trips through JSON — `JSON.parse` has no way to produce a bigint, so HTTP responses, custom state caches, and JSON-format SDK endpoints can quietly downgrade a u128/u256 to a lossy number. Before this patch the constructors stored that number verbatim into a field typed as `bigint`, and the failure manifested several layers later in BSATN serialization as `TypeError: Cannot mix BigInt and other types`, with no indication of which call site was at fault. Wrap the non-string branch of each constructor in `BigInt(...)`: * `bigint` inputs are returned unchanged. * `number` inputs are coerced cleanly (and lose precision the same way `JSON.parse` already lost it — this just stops the crash). * `undefined` / objects throw a clear `TypeError` immediately, pointing at the constructor call instead of a deep serialization frame. No public type changes; strict-TS callers see no difference. Adds a small test file pinning the new behavior across all five classes. --- .../src/lib/connection_id.ts | 5 +- .../bindings-typescript/src/lib/identity.ts | 10 ++- .../src/lib/time_duration.ts | 5 +- .../bindings-typescript/src/lib/timestamp.ts | 5 +- crates/bindings-typescript/src/lib/uuid.ts | 8 ++- .../tests/id_ctor_coercion.test.ts | 72 +++++++++++++++++++ 6 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 crates/bindings-typescript/tests/id_ctor_coercion.test.ts diff --git a/crates/bindings-typescript/src/lib/connection_id.ts b/crates/bindings-typescript/src/lib/connection_id.ts index a32bbdf4f14..4c8e8042fe8 100644 --- a/crates/bindings-typescript/src/lib/connection_id.ts +++ b/crates/bindings-typescript/src/lib/connection_id.ts @@ -18,7 +18,10 @@ export class ConnectionId { * Creates a new `ConnectionId`. */ constructor(data: bigint) { - this.__connection_id__ = data; + // Coerce through BigInt() so callers who arrive via JSON (e.g. HTTP + // responses or custom state caches that lose bigint precision) get + // a clear early failure instead of silent field corruption. + this.__connection_id__ = BigInt(data); } /** diff --git a/crates/bindings-typescript/src/lib/identity.ts b/crates/bindings-typescript/src/lib/identity.ts index 6cad2761b5e..bb69e483264 100644 --- a/crates/bindings-typescript/src/lib/identity.ts +++ b/crates/bindings-typescript/src/lib/identity.ts @@ -21,8 +21,14 @@ export class Identity { */ constructor(data: string | bigint) { // we get a JSON with __identity__ when getting a token with a JSON API - // and an bigint when using BSATN - this.__identity__ = typeof data === 'string' ? hexStringToU256(data) : data; + // and an bigint when using BSATN. + // Coerce non-string inputs through BigInt(): callers who go through + // JSON (e.g. the HTTP `/sql` endpoint or custom state caches) can + // end up with `number` for a u256 field, which would otherwise be + // stored verbatim and crash later in BSATN serialization with an + // opaque "Cannot mix BigInt and other types" error. + this.__identity__ = + typeof data === 'string' ? hexStringToU256(data) : BigInt(data); } /** diff --git a/crates/bindings-typescript/src/lib/time_duration.ts b/crates/bindings-typescript/src/lib/time_duration.ts index 15d6141300e..41a2b67bcd1 100644 --- a/crates/bindings-typescript/src/lib/time_duration.ts +++ b/crates/bindings-typescript/src/lib/time_duration.ts @@ -58,7 +58,10 @@ export class TimeDuration { } constructor(micros: bigint) { - this.__time_duration_micros__ = micros; + // Coerce through BigInt() so callers who arrive via JSON (where + // bigint precision is lost) get a clear early failure instead of + // silent field corruption that crashes later in arithmetic. + this.__time_duration_micros__ = BigInt(micros); } static fromMillis(millis: number): TimeDuration { diff --git a/crates/bindings-typescript/src/lib/timestamp.ts b/crates/bindings-typescript/src/lib/timestamp.ts index adc099bfeb7..759969ab5d1 100644 --- a/crates/bindings-typescript/src/lib/timestamp.ts +++ b/crates/bindings-typescript/src/lib/timestamp.ts @@ -26,7 +26,10 @@ export class Timestamp { } constructor(micros: bigint) { - this.__timestamp_micros_since_unix_epoch__ = micros; + // Coerce through BigInt() so callers who arrive via JSON (where + // bigint precision is lost) get a clear early failure instead of + // silent field corruption that crashes later in arithmetic. + this.__timestamp_micros_since_unix_epoch__ = BigInt(micros); } /** diff --git a/crates/bindings-typescript/src/lib/uuid.ts b/crates/bindings-typescript/src/lib/uuid.ts index 2216e1e9499..d30df26be1f 100644 --- a/crates/bindings-typescript/src/lib/uuid.ts +++ b/crates/bindings-typescript/src/lib/uuid.ts @@ -67,11 +67,15 @@ export class Uuid { * @throws {Error} If the value is outside the valid UUID range */ constructor(u: bigint) { + // Coerce through BigInt() so callers who arrive via JSON (where + // bigint precision is lost) hit the range check rather than a + // cryptic `Cannot mix BigInt and other types` error. + const v = BigInt(u); // Must fit in exactly 16 bytes - if (u < 0n || u > Uuid.MAX_UUID_BIGINT) { + if (v < 0n || v > Uuid.MAX_UUID_BIGINT) { throw new Error('Invalid UUID: must be between 0 and `MAX_UUID_BIGINT`'); } - this.__uuid__ = u; + this.__uuid__ = v; } /** diff --git a/crates/bindings-typescript/tests/id_ctor_coercion.test.ts b/crates/bindings-typescript/tests/id_ctor_coercion.test.ts new file mode 100644 index 00000000000..ad7083b8c46 --- /dev/null +++ b/crates/bindings-typescript/tests/id_ctor_coercion.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'vitest'; +import { ConnectionId, Identity, TimeDuration, Timestamp, Uuid } from '../src'; + +// The constructors of these wrapper classes claim to take `bigint` (or +// `string | bigint` for Identity), but real-world callers can end up +// passing `number` — typically after data goes through JSON in some +// external layer (HTTP responses, state caches, custom serializers), +// since JSON has no `bigint` and `JSON.parse` produces `number`. Before +// the coercion patch this silently corrupted the field and crashed +// later during BSATN serialization with an opaque mix-error. These +// tests pin the new behavior: numeric inputs are coerced to bigint and +// the field is always a bigint. + +describe('id-like constructors coerce non-bigint numerics', () => { + test('Identity(number) stores a bigint', () => { + const id = new Identity(123 as unknown as bigint); + expect(typeof id.__identity__).toBe('bigint'); + expect(id.__identity__).toBe(123n); + }); + + test('Identity(bigint) stays bigint', () => { + const id = new Identity(123n); + expect(typeof id.__identity__).toBe('bigint'); + expect(id.__identity__).toBe(123n); + }); + + test('Identity(hex string) still parses', () => { + const id = new Identity( + '0x0000000000000000000000000000000000000000000000000000000000000001' + ); + expect(typeof id.__identity__).toBe('bigint'); + expect(id.__identity__).toBe(1n); + }); + + test('ConnectionId(number) stores a bigint', () => { + const c = new ConnectionId(42 as unknown as bigint); + expect(typeof c.__connection_id__).toBe('bigint'); + expect(c.__connection_id__).toBe(42n); + // Downstream consumers rely on bigint operators working: + expect(c.isZero()).toBe(false); + expect(c.toHexString().length).toBeGreaterThan(0); + }); + + test('Timestamp(number) stores a bigint', () => { + const t = new Timestamp(1_000_000 as unknown as bigint); + expect(typeof t.__timestamp_micros_since_unix_epoch__).toBe('bigint'); + expect(t.microsSinceUnixEpoch).toBe(1_000_000n); + // toDate() does bigint arithmetic — would throw mix-error before. + expect(t.toDate().getTime()).toBe(1000); + }); + + test('TimeDuration(number) stores a bigint', () => { + const d = new TimeDuration(5_000_000 as unknown as bigint); + expect(typeof d.__time_duration_micros__).toBe('bigint'); + expect(d.micros).toBe(5_000_000n); + }); + + test('Uuid(number) is coerced and range-checked', () => { + const u = new Uuid(0 as unknown as bigint); + expect(typeof u.__uuid__).toBe('bigint'); + expect(u.__uuid__).toBe(0n); + }); + + test('Uuid(out-of-range bigint) still throws', () => { + expect(() => new Uuid(Uuid.MAX_UUID_BIGINT + 1n)).toThrow(/Invalid UUID/); + expect(() => new Uuid(-1n)).toThrow(/Invalid UUID/); + }); + + test('Identity(undefined) throws a clear error instead of corrupting', () => { + expect(() => new Identity(undefined as unknown as bigint)).toThrow(); + }); +}); From 7346812337b9b49e68d676a028580765eb2fffa5 Mon Sep 17 00:00:00 2001 From: Ludv1g Date: Fri, 15 May 2026 23:34:04 +0200 Subject: [PATCH 2/2] =?UTF-8?q?ts-sdk:=20clarify=20comment=20=E2=80=94=20J?= =?UTF-8?q?SON=20source=20is=20external,=20not=20/sql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment on Identity's constructor cited the HTTP `/sql` endpoint as a JSON path, but the TS SDK doesn't call `/sql` anywhere. The real number-input source for this constructor is caller-side code outside the SDK (custom state caches, hand-rolled HTTP clients, codegen drift, etc.). Update the comment to be honest about that. No code change. --- crates/bindings-typescript/src/lib/identity.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/bindings-typescript/src/lib/identity.ts b/crates/bindings-typescript/src/lib/identity.ts index bb69e483264..d3b894fbeca 100644 --- a/crates/bindings-typescript/src/lib/identity.ts +++ b/crates/bindings-typescript/src/lib/identity.ts @@ -22,11 +22,12 @@ export class Identity { constructor(data: string | bigint) { // we get a JSON with __identity__ when getting a token with a JSON API // and an bigint when using BSATN. - // Coerce non-string inputs through BigInt(): callers who go through - // JSON (e.g. the HTTP `/sql` endpoint or custom state caches) can - // end up with `number` for a u256 field, which would otherwise be - // stored verbatim and crash later in BSATN serialization with an - // opaque "Cannot mix BigInt and other types" error. + // Coerce non-string inputs through BigInt(): callers that go via any + // JSON path outside the SDK (custom state caches, hand-rolled HTTP + // clients, etc.) can end up with `number` for a u256 field, which + // would otherwise be stored verbatim and crash later in BSATN + // serialization with an opaque "Cannot mix BigInt and other types" + // error. this.__identity__ = typeof data === 'string' ? hexStringToU256(data) : BigInt(data); }