You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
LuaJIT's number type is a double; integers above 2⁵³ lose precision when
returned as a Lua number. JSON payloads with 64-bit IDs (Snowflake IDs, DB
row IDs, unsigned bigint) are silently truncated.
Current state of the code (this is the key reframing vs the original report):
Rust already parses integers directly.parse_i64 in src/decode/number.rs accumulates digits with checked_mul/add and never
touches f64.
The C/FFI boundary is already lossless.qjson_get_i64 writes a full int64_t into the caller's box.
The precision loss is one line of Lua.Doc:get_i64 / Cursor:get_i64
call tonumber(i64_box[0]) (lua/qjson.lua:93, :143), collapsing the
cdata int64_t down to a double.
There is no unsigned path at all — no parse_u64, no qjson_get_u64.
So the work is mostly Lua-side, plus a greenfield (but mechanical) unsigned
mirror in Rust.
Supersedes #22, which predates the qjd → qjson rename and assumed work that
is already done (see Notes).
Goal
get_i64 returns a lossless cdata int64_t; a new get_u64 returns a lossless cdata uint64_t; get_f64 is unchanged and remains the Lua-number path.
A >2⁵³ integer round-trips through decode and encode without precision loss.
Naming follows simdjson's typed-accessor convention (method name == exact
returned type).
Non-goals
Arbitrary-precision / bignum beyond 64 bits.
Changing get_f64 semantics (it already returns a lossless Lua double).
Acceptance Criteria
Doc:get_i64 / Cursor:get_i64 return a cdata int64_t (no tonumber), preserving full precision for |v| > 2⁵³
New Doc:get_u64 / Cursor:get_u64 return a cdata uint64_t, lossless up to u64::MAX
get_f64 unchanged — still returns a Lua number
A JSON int > 2⁵³ (e.g. 9007199254740993) round-trips losslessly via get_i64; a u64 > i64::MAX (e.g. 18446744073709551615) round-trips via get_u64
get_i64 on a value overflowing i64 → OUT_OF_RANGE; get_u64 on a negative value or value > u64::MAX → OUT_OF_RANGE
get_i64 / get_u64 on a float/bool/string/null → TYPE_MISMATCH
qjson.encode accepts cdata int64_t / uint64_t and emits them as decimal JSON integers (no LL/ULL suffix, no precision loss)
include/qjson.h, src/error.rs numbering, and the lua/qjson/lib.lua cdef stay in sync (new qjson_get_u64 / qjson_cursor_get_u64)
Task Checklist
1. Rust: unsigned parse + FFI
1.1 Add parse_u64 in src/decode/number.rs (reject leading -; u64 accumulation with checked ops → OUT_OF_RANGE on overflow; reject ./e/E → TYPE_MISMATCH)
4.4 Update docs (cjson migration guide / README): get_i64 now returns cdata; get_f64 is the Lua-number escape hatch
Notes / Decisions
Supersedes feat: lossless 64-bit integer mode — return cdata int64_t to LuaJIT #22. That issue predates the qjd → qjson rename and assumed
(a) integers were parsed via f64 and (b) a new C function was needed. Both
are already false: parse_i64 parses i64 directly with checked arithmetic,
and qjson_get_i64 already returns a lossless int64_t across FFI. The only
precision loss is the Lua tonumber() call.
Naming follows simdjson's typed accessors (get_int64/get_uint64/ get_double): the method name equals the exact returned type. get_f64
already returns a Lua number (== double) and serves as the convenient path,
so no separate get_number twin is needed. lua-cjson, by contrast, has no
typed getter and silently truncates on LuaJIT — the baseline we're fixing.
get_i64 returning cdata is a breaking change vs today (returns a Lua
number). Acceptable pre-1.0; migration = get_f64(path) or tonumber(doc:get_i64(path)).
Encode fast path already helps:encode_proxy slices the original buffer
bytes for untouched subtrees, so an unmodified big int already round-trips
losslessly. The new cdata branch only matters when a caller explicitly sets a
field to a cdata int.
Background
LuaJIT's number type is a
double; integers above 2⁵³ lose precision whenreturned as a Lua
number. JSON payloads with 64-bit IDs (Snowflake IDs, DBrow IDs,
unsigned bigint) are silently truncated.Current state of the code (this is the key reframing vs the original report):
parse_i64insrc/decode/number.rsaccumulates digits withchecked_mul/addand nevertouches
f64.qjson_get_i64writes a fullint64_tinto the caller's box.Doc:get_i64/Cursor:get_i64call
tonumber(i64_box[0])(lua/qjson.lua:93,:143), collapsing thecdata
int64_tdown to a double.parse_u64, noqjson_get_u64.So the work is mostly Lua-side, plus a greenfield (but mechanical) unsigned
mirror in Rust.
Supersedes #22, which predates the
qjd → qjsonrename and assumed work thatis already done (see Notes).
Goal
get_i64returns a losslesscdata int64_t; a newget_u64returns a losslesscdata uint64_t;get_f64is unchanged and remains the Lua-numberpath.A >2⁵³ integer round-trips through decode and encode without precision loss.
Naming follows simdjson's typed-accessor convention (method name == exact
returned type).
Non-goals
get_f64semantics (it already returns a lossless Lua double).Acceptance Criteria
Doc:get_i64/Cursor:get_i64return acdata int64_t(notonumber), preserving full precision for|v| > 2⁵³Doc:get_u64/Cursor:get_u64return acdata uint64_t, lossless up tou64::MAXget_f64unchanged — still returns a Luanumber9007199254740993) round-trips losslessly viaget_i64; a u64 >i64::MAX(e.g.18446744073709551615) round-trips viaget_u64get_i64on a value overflowingi64→OUT_OF_RANGE;get_u64on a negative value or value >u64::MAX→OUT_OF_RANGEget_i64/get_u64on a float/bool/string/null →TYPE_MISMATCHqjson.encodeacceptscdata int64_t/uint64_tand emits them as decimal JSON integers (noLL/ULLsuffix, no precision loss)include/qjson.h,src/error.rsnumbering, and thelua/qjson/lib.luacdef stay in sync (newqjson_get_u64/qjson_cursor_get_u64)Task Checklist
1. Rust: unsigned parse + FFI
parse_u64insrc/decode/number.rs(reject leading-;u64accumulation with checked ops →OUT_OF_RANGEon overflow; reject./e/E→TYPE_MISMATCH)0,u64::MAX,u64::MAX + 1overflow, negative → error, float/exponent →TYPE_MISMATCHqjson_get_u64insrc/ffi.rs(mirrorqjson_get_i64, keep panic barrier)qjson_cursor_get_u64insrc/ffi.rs(mirrorqjson_cursor_get_i64)include/qjson.h2. Lua wrapper: lossless return
uint64_t[1]box; add the two new cdefs inlua/qjson/lib.luaDoc:get_i64returnsi64_box[0]cdata directly (droptonumber)Cursor:get_i64sameDoc:get_u64/Cursor:get_u64returninguint64_tcdata3. Encode round-trip
cdatabranch in theencodedispatcher (lua/qjson/table.lua:702): detectint64_t/uint64_tviaffi.istype, emit decimal (stripLL/ULL)4. Lua tests + docs
get_i64i64::MAXround-trips viaget_u64get_i64overflow /get_u64negative → error; type-mismatch casesget_i64now returns cdata;get_f64is the Lua-number escape hatchNotes / Decisions
qjd → qjsonrename and assumed(a) integers were parsed via
f64and (b) a new C function was needed. Bothare already false:
parse_i64parsesi64directly with checked arithmetic,and
qjson_get_i64already returns a losslessint64_tacross FFI. The onlyprecision loss is the Lua
tonumber()call.get_int64/get_uint64/get_double): the method name equals the exact returned type.get_f64already returns a Lua
number(== double) and serves as the convenient path,so no separate
get_numbertwin is needed. lua-cjson, by contrast, has notyped getter and silently truncates on LuaJIT — the baseline we're fixing.
get_i64returning cdata is a breaking change vs today (returns a Luanumber). Acceptable pre-1.0; migration =
get_f64(path)ortonumber(doc:get_i64(path)).OUT_OF_RANGE(notPARSE_ERRORas old feat: lossless 64-bit integer mode — return cdata int64_t to LuaJIT #22 suggested) —more precise, and already the current i64 behavior.
encode_proxyslices the original bufferbytes for untouched subtrees, so an unmodified big int already round-trips
losslessly. The new cdata branch only matters when a caller explicitly sets a
field to a cdata int.