Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions .changeset/integer-value-coercion.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 133 additions & 0 deletions src/fields.test.ts
Original file line number Diff line number Diff line change
@@ -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<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).toBe('NULL_VALUE');
});
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 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');
});
});
14 changes: 12 additions & 2 deletions src/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading