@@ -16,26 +16,41 @@ import type { DynamoDBConnectionConfig, DynamoDBTableSchema } from '@/tools/dyna
1616type DynamoDBReturnValues = 'NONE' | 'ALL_OLD' | 'UPDATED_OLD' | 'ALL_NEW' | 'UPDATED_NEW'
1717
1818/**
19- * Keeps integers beyond `Number.MAX_SAFE_INTEGER` (and other values a JS number cannot hold)
20- * as a `NumberValue` holding the exact DynamoDB string, since the SDK default turns them into a
21- * `BigInt` that the JSON response cannot serialize.
19+ * Keeps any DynamoDB number a JS number cannot hold exactly (integers beyond
20+ * `Number.MAX_SAFE_INTEGER`, high-precision decimals, non-finite values) as a `NumberValue`
21+ * with the exact string, so pagination cursors can round-trip it. The SDK default would turn
22+ * large integers into a `BigInt` that the JSON response cannot serialize.
2223 */
2324function unwrapNumber ( value : string ) : number | NumberValue {
2425 const parsed = Number ( value )
26+ if ( Number . isSafeInteger ( parsed ) ) return parsed
27+ if ( Number . isFinite ( parsed ) && ! Number . isInteger ( parsed ) && String ( parsed ) === value ) {
28+ return parsed
29+ }
30+ return NumberValue . from ( value )
31+ }
32+
33+ /**
34+ * Item output for a number kept as a `NumberValue`: the exact string for integers beyond
35+ * `Number.MAX_SAFE_INTEGER` and non-finite values, otherwise the nearest JS number.
36+ */
37+ function numberValueToJson ( value : NumberValue ) : number | string {
38+ const exact = value . toString ( )
39+ const parsed = Number ( exact )
2540 if ( ! Number . isFinite ( parsed ) || ( Number . isInteger ( parsed ) && ! Number . isSafeInteger ( parsed ) ) ) {
26- return NumberValue . from ( value )
41+ return exact
2742 }
2843 return parsed
2944}
3045
3146/**
3247 * Converts unmarshalled DynamoDB values into JSON-serializable shapes: string, number and
33- * binary sets become arrays, binary values become base64 strings, and numbers a JS number
34- * cannot hold become their exact string. Without this a set serializes as `{}` and a binary
48+ * binary sets become arrays, binary values become base64 strings, and numbers kept as a
49+ * `NumberValue` become a JS number or, when one cannot hold them, their exact string. Without this a set serializes as `{}` and a binary
3550 * value as an object of byte indexes.
3651 */
3752function toJsonSafe ( value : unknown ) : unknown {
38- if ( value instanceof NumberValue ) return value . toString ( )
53+ if ( value instanceof NumberValue ) return numberValueToJson ( value )
3954 if ( value instanceof Set ) return Array . from ( value , toJsonSafe )
4055 if ( value instanceof Uint8Array ) return Buffer . from ( value ) . toString ( 'base64' )
4156 if ( Array . isArray ( value ) ) return value . map ( toJsonSafe )
@@ -53,15 +68,15 @@ function toJsonSafeRecord(
5368
5469/**
5570 * Encodes a `LastEvaluatedKey` so it can be passed back as `exclusiveStartKey`. Key
56- * attributes are always scalar (S, N or B), so strings and safe numbers stay as-is while a
57- * number a JS number cannot hold becomes `{ N }` and a binary key becomes `{ B }` (base64):
71+ * attributes are always scalar (S, N or B), so strings and exact numbers stay as-is while a
72+ * number a JS number cannot hold exactly becomes `{ N }` and a binary key becomes `{ B }` (base64):
5873 * plain strings would otherwise be sent back as S and fail the key type check.
5974 */
6075function toCursor ( key : Record < string , unknown > | undefined ) : Record < string , unknown > | undefined {
6176 if ( ! key ) return undefined
6277 return Object . fromEntries (
6378 Object . entries ( key ) . map ( ( [ name , value ] ) => {
64- if ( value instanceof NumberValue ) return [ name , { N : toJsonSafe ( value ) } ]
79+ if ( value instanceof NumberValue ) return [ name , { N : value . toString ( ) } ]
6580 if ( value instanceof Uint8Array ) return [ name , { B : toJsonSafe ( value ) } ]
6681 return [ name , value ]
6782 } )
0 commit comments