Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/integer-value-coercion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'fireworkers': minor
---

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) 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 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.
136 changes: 136 additions & 0 deletions src/fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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<void> => {
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;
Comment thread
besart-finsweet marked this conversation as resolved.
}>(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).toBeNull();
});
Comment thread
besart-finsweet marked this conversation as resolved.

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 primitive sentinels seeded via raw REST (emulator)', () => {
let db: DB;

beforeAll(async () => {
db = await initDb();
});
beforeEach(clearFirestore);

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);
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');
expect(doc.fields.missing).toBeNull();
});
});
23 changes: 20 additions & 3 deletions src/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import type * as Firestore from './types';
* Types
*/
export type PrimitiveValues = Omit<Firestore.Value, 'mapValue' | 'arrayValue'>;
export type PrimitiveMappedValue = PrimitiveValues[keyof PrimitiveValues];
export type PrimitiveMappedValue = Exclude<
PrimitiveValues[keyof PrimitiveValues],
Firestore.ValueNullValue
> | null;
export type ArrayMappedValue = Array<PrimitiveMappedValue | ArrayMappedValue | MapMappedValue>;
export interface MapMappedValue {
[key: string]: PrimitiveMappedValue | MapMappedValue | ArrayMappedValue;
Expand Down Expand Up @@ -115,8 +118,22 @@ 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);

// 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;
};

/**
* Extracts an array field.
Expand Down