diff --git a/lib/backend.ts b/lib/backend.ts index 09cb785..cfd42b0 100644 --- a/lib/backend.ts +++ b/lib/backend.ts @@ -1,10 +1,26 @@ import path from 'node:path'; import express, { type Express } from 'express'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { NumberValue } from '@aws-sdk/lib-dynamodb'; import { createAwsConfig } from './config'; import { setupRoutes } from './routes'; import { DynamoApiController } from './dynamoDbApi'; +// Fix: DynamoDB number attributes that exceed Number.MAX_SAFE_INTEGER are +// returned as BigInt by the AWS SDK v3. With wrapNumbers enabled in the +// DynamoDBDocumentClient, all numbers are wrapped in NumberValue objects. +// This toJSON method ensures they serialise correctly: safe numbers become +// JSON numbers, and values that would lose precision become strings. +(NumberValue.prototype as unknown as { toJSON: () => number | string }).toJSON = function (this: NumberValue) { + const str = this.toString(); + const num = Number(str); + // Preserve as a number if the conversion is lossless (i.e. Number -> String round-trips) + if (String(num) === str) { + return num; + } + return str; +}; + export type CreateServerOptions = { dynamoDbClient?: DynamoDBClient; expressInstance?: Express; diff --git a/lib/dynamoDbApi.ts b/lib/dynamoDbApi.ts index e8d5d4d..0a0bb92 100644 --- a/lib/dynamoDbApi.ts +++ b/lib/dynamoDbApi.ts @@ -42,7 +42,9 @@ export class DynamoApiController { constructor(dynamodb: DynamoDBClient) { this.dynamodb = dynamodb; - this.docClient = DynamoDBDocumentClient.from(dynamodb); + this.docClient = DynamoDBDocumentClient.from(dynamodb, { + unmarshallOptions: { wrapNumbers: true }, + }); } async batchWriteItem(input: BatchWriteCommandInput): Promise { diff --git a/lib/numberValueSerialization.test.ts b/lib/numberValueSerialization.test.ts new file mode 100644 index 0000000..499aebd --- /dev/null +++ b/lib/numberValueSerialization.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { NumberValue } from '@aws-sdk/lib-dynamodb'; + +// Apply the toJSON patch as the backend module does at import time. +beforeAll(async () => { + await import('./backend'); +}); + +describe('NumberValue JSON serialization', () => { + it('serialises small integers as numbers', () => { + expect(JSON.parse(JSON.stringify(new NumberValue('42')))).toBe(42); + expect(JSON.parse(JSON.stringify(new NumberValue('0')))).toBe(0); + expect(JSON.parse(JSON.stringify(new NumberValue('-5')))).toBe(-5); + }); + + it('serialises decimal values as numbers', () => { + expect(JSON.parse(JSON.stringify(new NumberValue('3.14')))).toBe(3.14); + expect(JSON.parse(JSON.stringify(new NumberValue('0.001')))).toBe(0.001); + }); + + it('serialises MAX_SAFE_INTEGER as a number', () => { + const maxSafe = String(Number.MAX_SAFE_INTEGER); + expect(JSON.parse(JSON.stringify(new NumberValue(maxSafe)))).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('serialises values exceeding safe integer range as strings', () => { + const large = '1773962769160000125'; + expect(JSON.parse(JSON.stringify(new NumberValue(large)))).toBe(large); + }); + + it('serialises negative large values as strings', () => { + const largeNeg = '-9007199254740999'; + expect(JSON.parse(JSON.stringify(new NumberValue(largeNeg)))).toBe(largeNeg); + }); + + it('preserves precision for values just beyond MAX_SAFE_INTEGER', () => { + // 9007199254740993 cannot be exactly represented as a double + const beyondSafe = '9007199254740993'; + expect(JSON.parse(JSON.stringify(new NumberValue(beyondSafe)))).toBe(beyondSafe); + }); + + it('works inside a nested object mimicking a DynamoDB item', () => { + const item = { + pk: 'user-123', + insertedAt: new NumberValue('1773962769160000125'), + version: new NumberValue('1'), + score: new NumberValue('99.5'), + }; + const parsed = JSON.parse(JSON.stringify(item)); + expect(parsed).toEqual({ + pk: 'user-123', + insertedAt: '1773962769160000125', + version: 1, + score: 99.5, + }); + }); +});