From 6a673cd91062d5800b2324b3d7c9f253e07fb6f1 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 7 May 2026 01:42:55 -0400 Subject: [PATCH] relayburn-sdk-node: coerce BigInt to Number in umbrella facade napi-rs serializes Rust u64/i64 as JS BigInt, but the TS 1.x @relayburn/sdk shape (mirrored in src/index.d.ts) emits plain Number for the same fields. PR alpha (#354) made the napi binding shape-conformant; PR beta (#355) flipped the conformance gate in CI and surfaced 6/7 verbs failing because of this BigInt vs Number wire-shape gap (and a separate JSONL->SQLite read-side gap fixed by a sibling PR). Add a coerceBigInts(value) helper that recursively walks a verb's return value and downcasts BigInt to Number when the value fits in [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; values outside that range stay BigInt to avoid silent precision loss. Wrap each verb's return in both the ESM facade (src/index.js) and the CJS mirror (src/index.cjs): summary, sessionCost, overhead, overheadTrim, hotspots, compare, ingest, search, exportLedger, exportStamps. The TS 1.x types already declare number | bigint where this matters, so this is a runtime-shape fix rather than a type-surface change. Local conformance probe (cherry-picking beta's test against this fix): 3/7 verbs now pass (overhead, overheadTrim, ingest) vs the 1/7 beta reported pre-fix (overhead). The remaining 4 failures are the JSONL->SQLite read-side gap, which the sibling alpha-followup addresses. Refs #247, #354, #355. --- packages/sdk-node/CHANGELOG.md | 4 +++ packages/sdk-node/src/index.cjs | 49 +++++++++++++++++++++------ packages/sdk-node/src/index.js | 60 +++++++++++++++++++++++++++------ 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/packages/sdk-node/CHANGELOG.md b/packages/sdk-node/CHANGELOG.md index 916e9c79..98f76995 100644 --- a/packages/sdk-node/CHANGELOG.md +++ b/packages/sdk-node/CHANGELOG.md @@ -16,3 +16,7 @@ until the SDK wires fallback logging). Adds `search`, `exportLedger`, `exportStamps`, `BurnErrorCode`, `OverheadFileKind`, and `HotspotsGroupBy` as 2.x extensions over the 1.x surface. (#247 part c) +- Umbrella facade now coerces napi-rs `BigInt` return values to `Number` + for safe-range integers (`[Number.MIN_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER]`), matching the TS 1.x runtime shape; values + outside that range stay `BigInt` to avoid silent precision loss. diff --git a/packages/sdk-node/src/index.cjs b/packages/sdk-node/src/index.cjs index 2300ad29..b4d9c7a1 100644 --- a/packages/sdk-node/src/index.cjs +++ b/packages/sdk-node/src/index.cjs @@ -11,6 +11,35 @@ const binding = require('./binding.cjs'); +// See `src/index.js` for the rationale: napi-rs serializes Rust `u64` / +// `i64` as JS `BigInt`, while TS 1.x `@relayburn/sdk` emits plain +// `Number`. We downcast safe-range BigInts to keep `deepStrictEqual` +// passing in conformance and to match user expectations from 1.x. +const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER); +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +function coerceBigInts(value) { + if (typeof value === 'bigint') { + return value >= MIN_SAFE && value <= MAX_SAFE ? Number(value) : value; + } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + value[i] = coerceBigInts(value[i]); + } + return value; + } + if (value !== null && typeof value === 'object') { + const proto = Object.getPrototypeOf(value); + if (proto === null || proto === Object.prototype) { + for (const key of Object.keys(value)) { + value[key] = coerceBigInts(value[key]); + } + } + return value; + } + return value; +} + class Ledger { constructor(home) { this.home = home; @@ -23,16 +52,16 @@ class Ledger { module.exports = { Ledger, - ingest: async (opts) => binding.ingest(opts), - summary: async (opts) => binding.summary(opts), - sessionCost: async (opts) => binding.sessionCost(opts), - overhead: async (opts) => binding.overhead(opts), - overheadTrim: async (opts) => binding.overheadTrim(opts), - hotspots: async (opts) => binding.hotspots(opts), - compare: async (opts) => binding.compare(opts), - search: async (opts) => binding.search(opts), - exportLedger: async (opts) => binding.exportLedger(opts), - exportStamps: async (opts) => binding.exportStamps(opts), + ingest: async (opts) => coerceBigInts(await binding.ingest(opts)), + summary: async (opts) => coerceBigInts(await binding.summary(opts)), + sessionCost: async (opts) => coerceBigInts(await binding.sessionCost(opts)), + overhead: async (opts) => coerceBigInts(await binding.overhead(opts)), + overheadTrim: async (opts) => coerceBigInts(await binding.overheadTrim(opts)), + hotspots: async (opts) => coerceBigInts(await binding.hotspots(opts)), + compare: async (opts) => coerceBigInts(await binding.compare(opts)), + search: async (opts) => coerceBigInts(await binding.search(opts)), + exportLedger: async (opts) => coerceBigInts(await binding.exportLedger(opts)), + exportStamps: async (opts) => coerceBigInts(await binding.exportStamps(opts)), BurnErrorCode: binding.BurnErrorCode, OverheadFileKind: binding.OverheadFileKind, HotspotsGroupBy: binding.HotspotsGroupBy, diff --git a/packages/sdk-node/src/index.js b/packages/sdk-node/src/index.js index 30cfbac1..d5766278 100644 --- a/packages/sdk-node/src/index.js +++ b/packages/sdk-node/src/index.js @@ -23,6 +23,46 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const binding = require('./binding.cjs'); +// napi-rs serializes Rust `u64` / `i64` as JS `BigInt`, but the TS 1.x +// `@relayburn/sdk` shape (mirrored in `src/index.d.ts`) emits plain +// `Number` for the same fields. To keep the conformance gate's +// `deepStrictEqual` checks honest — and to match the runtime shape that +// 1.x callers expect (e.g. `result.turnCount === 0`, not `=== 0n`) — we +// downcast every `BigInt` in a verb's return value to `Number` when it +// fits in `[Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]`. Values +// outside that range are left as `BigInt`: realistic burn ledgers won't +// hit 2^53 tokens, but if one ever does, leaking a `BigInt` that crashes +// a `===` check is strictly safer than silently rounding to the nearest +// 1024. The TS shape declares `number | bigint` everywhere this matters +// so the type stays sound either way. +const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER); +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + +function coerceBigInts(value) { + if (typeof value === 'bigint') { + return value >= MIN_SAFE && value <= MAX_SAFE ? Number(value) : value; + } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + value[i] = coerceBigInts(value[i]); + } + return value; + } + if (value !== null && typeof value === 'object') { + // Skip class instances we don't own (Date, Map, Set, Buffer, …) — + // walking their guts would be both wasteful and risky. Plain objects + // produced by napi-rs serde have a null or Object prototype. + const proto = Object.getPrototypeOf(value); + if (proto === null || proto === Object.prototype) { + for (const key of Object.keys(value)) { + value[key] = coerceBigInts(value[key]); + } + } + return value; + } + return value; +} + /** * Stateful ledger handle. Mirrors the TS 1.x `Ledger` class shape from * `packages/sdk/index.d.ts`. The 1.x version only exposes the static @@ -50,31 +90,31 @@ export class Ledger { } export async function ingest(opts) { - return binding.ingest(opts); + return coerceBigInts(await binding.ingest(opts)); } export async function summary(opts) { - return binding.summary(opts); + return coerceBigInts(await binding.summary(opts)); } export async function sessionCost(opts) { - return binding.sessionCost(opts); + return coerceBigInts(await binding.sessionCost(opts)); } export async function overhead(opts) { - return binding.overhead(opts); + return coerceBigInts(await binding.overhead(opts)); } export async function overheadTrim(opts) { - return binding.overheadTrim(opts); + return coerceBigInts(await binding.overheadTrim(opts)); } export async function hotspots(opts) { - return binding.hotspots(opts); + return coerceBigInts(await binding.hotspots(opts)); } export async function compare(opts) { - return binding.compare(opts); + return coerceBigInts(await binding.compare(opts)); } // 2.x extensions — exposed by the Rust SDK but not declared in @@ -83,15 +123,15 @@ export async function compare(opts) { // reach the FTS5 search index and the JSONL export iterators without // dropping into the binding directly. export async function search(opts) { - return binding.search(opts); + return coerceBigInts(await binding.search(opts)); } export async function exportLedger(opts) { - return binding.exportLedger(opts); + return coerceBigInts(await binding.exportLedger(opts)); } export async function exportStamps(opts) { - return binding.exportStamps(opts); + return coerceBigInts(await binding.exportStamps(opts)); } // Re-exported enums from the Rust binding. These come across as plain