Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions lib/backend.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
4 changes: 3 additions & 1 deletion lib/dynamoDbApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BatchWriteCommandOutput> {
Expand Down
57 changes: 57 additions & 0 deletions lib/numberValueSerialization.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});