From 93ab784da9aa77083631912a3a19f7d5533e93f9 Mon Sep 17 00:00:00 2001 From: besart-finsweet Date: Mon, 20 Apr 2026 22:52:29 +0200 Subject: [PATCH 1/3] fix: coerce `integerValue` fields to JS `number` when reading documents --- .changeset/integer-value-coercion.md | 9 ++ src/fields.test.ts | 133 +++++++++++++++++++++++++++ src/fields.ts | 14 ++- 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 .changeset/integer-value-coercion.md create mode 100644 src/fields.test.ts diff --git a/.changeset/integer-value-coercion.md b/.changeset/integer-value-coercion.md new file mode 100644 index 0000000..cf279a2 --- /dev/null +++ b/.changeset/integer-value-coercion.md @@ -0,0 +1,9 @@ +--- +'fireworkers': minor +--- + +fix: coerce `integerValue` fields to JS `number` when reading documents. + +Firestore's REST API serializes `integerValue` as a string to preserve int64 precision. Previously, fireworkers returned the raw string; it now coerces to `number` to match the Firebase Admin SDK. + +**BREAKING for consumers relying on `integerValue` fields being returned as strings.** This only affects data written by other clients (Admin SDK, console, other languages) — fireworkers writes all numbers as `doubleValue`, so round-trips within fireworkers were never affected. Values beyond `Number.MAX_SAFE_INTEGER` (2^53 − 1) will lose precision; if you need full int64 support, read the raw document via the REST API directly. diff --git a/src/fields.test.ts b/src/fields.test.ts new file mode 100644 index 0000000..9c380e7 --- /dev/null +++ b/src/fields.test.ts @@ -0,0 +1,133 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { clearFirestore, initDb, TEST_PROJECT_ID } from '../tests/unit/helpers'; +import { extract_fields_from_document } from './fields'; +import { get } from './get'; +import type { DB, Document } from './types'; + +const EMULATOR_HOST = process.env.FIRESTORE_EMULATOR_HOST ?? '127.0.0.1:8080'; + +/** + * Writes a raw Firestore document to the emulator, bypassing fireworkers so + * we can produce an `integerValue` field (fireworkers always writes numbers + * as `doubleValue`). + */ +const writeRawDocument = async ( + collection: string, + documentId: string, + fields: Document['fields'] +): Promise => { + const url = `http://${EMULATOR_HOST}/v1/projects/${TEST_PROJECT_ID}/databases/(default)/documents/${collection}?documentId=${documentId}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fields }), + }); + + if (!response.ok) { + throw new Error(`Failed to seed document: ${response.status} ${await response.text()}`); + } +}; + +describe('extract_fields_from_document', () => { + it('coerces integerValue strings to JS numbers', () => { + const document: Document = { + name: 'projects/test/databases/(default)/documents/todos/abc', + fields: { + updatedTimestamp: { integerValue: '1730404244' }, + }, + }; + + const extracted = extract_fields_from_document<{ updatedTimestamp: number }>(document); + + expect(extracted.fields.updatedTimestamp).toBe(1730404244); + expect(typeof extracted.fields.updatedTimestamp).toBe('number'); + }); + + it('maps each primitive type to the correct JS type', () => { + const document: Document = { + name: 'projects/test/databases/(default)/documents/mix/doc', + fields: { + int: { integerValue: '42' }, + double: { doubleValue: 3.14 }, + str: { stringValue: 'hello' }, + bool: { booleanValue: true }, + missing: { nullValue: 'NULL_VALUE' }, + }, + }; + + const extracted = extract_fields_from_document<{ + int: number; + double: number; + str: string; + bool: boolean; + missing: null; + }>(document); + + expect(extracted.fields.int).toBe(42); + expect(typeof extracted.fields.int).toBe('number'); + expect(extracted.fields.double).toBe(3.14); + expect(extracted.fields.str).toBe('hello'); + expect(extracted.fields.bool).toBe(true); + expect(extracted.fields.missing).toBe('NULL_VALUE'); + }); + + it('coerces integerValue nested inside arrayValue and mapValue', () => { + const document: Document = { + name: 'projects/test/databases/(default)/documents/nested/doc', + fields: { + list: { + arrayValue: { + values: [{ integerValue: '1' }, { integerValue: '2' }], + }, + }, + meta: { + mapValue: { + fields: { + count: { integerValue: '99' }, + }, + }, + }, + }, + }; + + const extracted = extract_fields_from_document<{ + list: number[]; + meta: { count: number }; + }>(document); + + expect(extracted.fields.list).toEqual([1, 2]); + expect(typeof extracted.fields.list[0]).toBe('number'); + expect(extracted.fields.meta.count).toBe(99); + expect(typeof extracted.fields.meta.count).toBe('number'); + }); +}); + +describe('get with integerValue seeded via raw REST (emulator)', () => { + let db: DB; + + beforeAll(async () => { + db = await initDb(); + }); + beforeEach(clearFirestore); + + it('returns integerValue fields as JS numbers', async () => { + await writeRawDocument('todos', 'seeded', { + updatedTimestamp: { integerValue: '1730404244' }, + count: { integerValue: '42' }, + title: { stringValue: 'seeded externally' }, + }); + + const doc = await get<{ + updatedTimestamp: number; + count: number; + title: string; + }>(db, 'todos', 'seeded'); + + expect(doc.fields.updatedTimestamp).toBe(1730404244); + expect(typeof doc.fields.updatedTimestamp).toBe('number'); + expect(doc.fields.count).toBe(42); + expect(typeof doc.fields.count).toBe('number'); + expect(doc.fields.title).toBe('seeded externally'); + }); +}); diff --git a/src/fields.ts b/src/fields.ts index c02497d..d62f406 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -115,8 +115,18 @@ const extract_value = (value: Firestore.Value): MappedValue => { * Extracts a primitive value from a field object. * @param primitiveValues */ -const extract_primitive_value = (primitiveValues: PrimitiveValues) => - Object.values(primitiveValues)[0]; +const extract_primitive_value = (primitiveValues: PrimitiveValues) => { + const entry = Object.entries(primitiveValues)[0]; + if (!entry) return undefined; + + const [key, value] = entry; + + // Firestore's REST API returns integerValue as a string to preserve int64 + // precision. Coerce to number to match the Firebase Admin SDK behavior. + if (key === 'integerValue') return Number(value); + + return value; +}; /** * Extracts an array field. From 57d015133a1f6fdf12b8051d5220a23dd4d68ddb Mon Sep 17 00:00:00 2001 From: besart-finsweet Date: Tue, 21 Apr 2026 14:43:13 +0200 Subject: [PATCH 2/3] fix: `nullValue` fields to native JS types when reading documents --- .changeset/integer-value-coercion.md | 9 ++++++--- src/fields.test.ts | 2 +- src/fields.ts | 9 ++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.changeset/integer-value-coercion.md b/.changeset/integer-value-coercion.md index cf279a2..33ce353 100644 --- a/.changeset/integer-value-coercion.md +++ b/.changeset/integer-value-coercion.md @@ -2,8 +2,11 @@ 'fireworkers': minor --- -fix: coerce `integerValue` fields to JS `number` when reading documents. +fix: coerce `integerValue` and `nullValue` fields to native JS types when reading documents. -Firestore's REST API serializes `integerValue` as a string to preserve int64 precision. Previously, fireworkers returned the raw string; it now coerces to `number` to match the Firebase Admin SDK. +Firestore's REST API serializes `integerValue` as a string (to preserve int64 precision) and `nullValue` as the sentinel string `'NULL_VALUE'`. Previously, fireworkers returned these raw values; it now coerces them to `number` and `null` respectively, to match the Firebase Admin SDK. -**BREAKING for consumers relying on `integerValue` fields being returned as strings.** This only affects data written by other clients (Admin SDK, console, other languages) — fireworkers writes all numbers as `doubleValue`, so round-trips within fireworkers were never affected. Values beyond `Number.MAX_SAFE_INTEGER` (2^53 − 1) will lose precision; if you need full int64 support, read the raw document via the REST API directly. +**BREAKING for consumers relying on the previous raw values:** + +- `integerValue` fields are no longer returned as strings. Values beyond `Number.MAX_SAFE_INTEGER` (2^53 − 1) will lose precision; if you need full int64 support, read the raw document via the REST API directly. This only affects data written by other clients (Admin SDK, console, other languages) — fireworkers writes all numbers as `doubleValue`, so round-trips within fireworkers were never affected. +- `nullValue` fields are no longer returned as the string `'NULL_VALUE'` — they return actual `null`. Any code checking `=== 'NULL_VALUE'` or relying on truthiness (the sentinel string is truthy, `null` is not) must be updated. The exported `PrimitiveMappedValue` / `MappedValue` types reflect this change. diff --git a/src/fields.test.ts b/src/fields.test.ts index 9c380e7..2f67ebd 100644 --- a/src/fields.test.ts +++ b/src/fields.test.ts @@ -69,7 +69,7 @@ describe('extract_fields_from_document', () => { expect(extracted.fields.double).toBe(3.14); expect(extracted.fields.str).toBe('hello'); expect(extracted.fields.bool).toBe(true); - expect(extracted.fields.missing).toBe('NULL_VALUE'); + expect(extracted.fields.missing).toBeNull(); }); it('coerces integerValue nested inside arrayValue and mapValue', () => { diff --git a/src/fields.ts b/src/fields.ts index d62f406..9f974f3 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -14,7 +14,10 @@ import type * as Firestore from './types'; * Types */ export type PrimitiveValues = Omit; -export type PrimitiveMappedValue = PrimitiveValues[keyof PrimitiveValues]; +export type PrimitiveMappedValue = Exclude< + PrimitiveValues[keyof PrimitiveValues], + Firestore.ValueNullValue +> | null; export type ArrayMappedValue = Array; export interface MapMappedValue { [key: string]: PrimitiveMappedValue | MapMappedValue | ArrayMappedValue; @@ -125,6 +128,10 @@ const extract_primitive_value = (primitiveValues: PrimitiveValues) => { // precision. Coerce to number to match the Firebase Admin SDK behavior. if (key === 'integerValue') return Number(value); + // Firestore's REST API returns nullValue as the sentinel string 'NULL_VALUE'. + // Coerce to actual null to match the Firebase Admin SDK behavior. + if (key === 'nullValue') return null; + return value; }; From 2cac6f2d6dc08956f3bfa6fe9c921653f19fc49d Mon Sep 17 00:00:00 2001 From: besart-finsweet Date: Tue, 21 Apr 2026 14:57:18 +0200 Subject: [PATCH 3/3] fix: update test descriptions and include nullValue handling in raw REST document retrieval --- src/fields.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/fields.test.ts b/src/fields.test.ts index 2f67ebd..b597c32 100644 --- a/src/fields.test.ts +++ b/src/fields.test.ts @@ -103,7 +103,7 @@ describe('extract_fields_from_document', () => { }); }); -describe('get with integerValue seeded via raw REST (emulator)', () => { +describe('get with primitive sentinels seeded via raw REST (emulator)', () => { let db: DB; beforeAll(async () => { @@ -111,17 +111,19 @@ describe('get with integerValue seeded via raw REST (emulator)', () => { }); beforeEach(clearFirestore); - it('returns integerValue fields as JS numbers', async () => { + it('returns integerValue fields as JS numbers and nullValue fields as null', async () => { await writeRawDocument('todos', 'seeded', { updatedTimestamp: { integerValue: '1730404244' }, count: { integerValue: '42' }, title: { stringValue: 'seeded externally' }, + missing: { nullValue: 'NULL_VALUE' }, }); const doc = await get<{ updatedTimestamp: number; count: number; title: string; + missing: null; }>(db, 'todos', 'seeded'); expect(doc.fields.updatedTimestamp).toBe(1730404244); @@ -129,5 +131,6 @@ describe('get with integerValue seeded via raw REST (emulator)', () => { expect(doc.fields.count).toBe(42); expect(typeof doc.fields.count).toBe('number'); expect(doc.fields.title).toBe('seeded externally'); + expect(doc.fields.missing).toBeNull(); }); });