Skip to content

Commit 409b866

Browse files
committed
fix(dynamodb): keep inexact decimal keys exact in pagination cursors
Hold every N value a JS number cannot represent exactly as a NumberValue so the cursor encodes it as {N}; item output keeps the same number/string rule.
1 parent 3b4d028 commit 409b866

2 files changed

Lines changed: 30 additions & 10 deletions

File tree

‎apps/sim/lib/internal/dynamodb/client.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('DynamoDB item unmarshalling', () => {
4444
avatar: { B: new Uint8Array([104, 105]) },
4545
snowflake: { N: '1234567890123456789' },
4646
age: { N: '42' },
47+
ratio: { N: '0.12345678901234567890123' },
4748
},
4849
],
4950
})
@@ -61,6 +62,7 @@ describe('DynamoDB item unmarshalling', () => {
6162
avatar: 'aGk=',
6263
snowflake: '1234567890123456789',
6364
age: 42,
65+
ratio: 0.12345678901234568,
6466
},
6567
],
6668
count: 1,
@@ -78,6 +80,7 @@ describe('DynamoDB item unmarshalling', () => {
7880
sk: { N: '1234567890123456789' },
7981
bin: { B: new Uint8Array([104, 105]) },
8082
age: { N: '42' },
83+
ratio: { N: '0.12345678901234567890123' },
8184
},
8285
})
8386
const firstPage = await queryItems(pageClient, 'Users', 'pk = :pk', {
@@ -89,6 +92,7 @@ describe('DynamoDB item unmarshalling', () => {
8992
sk: { N: '1234567890123456789' },
9093
bin: { B: 'aGk=' },
9194
age: 42,
95+
ratio: { N: '0.12345678901234567890123' },
9296
})
9397

9498
let sentStartKey: unknown
@@ -105,6 +109,7 @@ describe('DynamoDB item unmarshalling', () => {
105109
sk: { N: '1234567890123456789' },
106110
bin: { B: new Uint8Array([104, 105]) },
107111
age: { N: '42' },
112+
ratio: { N: '0.12345678901234567890123' },
108113
})
109114
pageClient.destroy()
110115
client.destroy()

‎apps/sim/lib/internal/dynamodb/client.ts‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,41 @@ import type { DynamoDBConnectionConfig, DynamoDBTableSchema } from '@/tools/dyna
1616
type 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
*/
2324
function 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
*/
3752
function 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
*/
6075
function 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

Comments
 (0)