Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0c95ab0
fix(firestore, android): align pipeline operand coercion with iOS
mikehardy Jul 1, 2026
835ea78
fix(firestore, android): align pipeline integerLiteral constant lower…
mikehardy Jul 2, 2026
cefef45
fix(firestore, android): align pipeline stage option expression field…
mikehardy Jul 2, 2026
7389c1b
fix(firestore, android): align pipeline constant envelope routing wit…
mikehardy Jul 2, 2026
6cd995e
fix(firestore, android): align pipeline timestampTruncate arity valid…
mikehardy Jul 2, 2026
0a22276
docs(firestore): close P-034 operand-mode e2e audit after J1 parity
mikehardy Jul 2, 2026
307f770
test(firestore): expand pipeline TS runtime and expression coverage
mikehardy Jul 2, 2026
b60b0ec
test(firestore): cover pipeline aggregate expression argument lowerin…
mikehardy Jul 3, 2026
3858c62
test(firestore): cover pipeline exit frame and receiver expression lo…
mikehardy Jul 3, 2026
8d0eef1
fix(firestore, ios): align pipeline stage coercion and operand tail l…
mikehardy Jul 3, 2026
d8a41ef
fix(firestore, android): apply arithmetic operand coercion review fixes
mikehardy Jul 3, 2026
3a67452
test(firestore): cover pipeline Android Executor remainder paths
mikehardy Jul 3, 2026
9d5dedd
test(firestore): expand pipeline validate Jest coverage
mikehardy Jul 3, 2026
f56f49a
refactor(firestore, android): remove dead pipeline Executor lowering …
mikehardy Jul 3, 2026
46a8c96
fix(firestore, ios): align pipeline stage option coercion with Android
mikehardy Jul 3, 2026
e41fac8
docs(firestore): close Phase R pre-merge snapshot in work queue
mikehardy Jul 3, 2026
6016d1b
docs(firestore): record Phase R final coverage including iOS lcov
mikehardy Jul 3, 2026
7e92609
docs(firestore): add PR post coverage table and open merge gate
mikehardy Jul 3, 2026
b7b6b4d
docs(firestore): close merge gate in pipeline work queue
mikehardy Jul 3, 2026
a860457
docs(firestore): remove stale merge gate open footer in queue
mikehardy Jul 3, 2026
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
176 changes: 117 additions & 59 deletions okf-bundle/packages/firestore/pipeline-coverage-work-queue.md

Large diffs are not rendered by default.

22 changes: 10 additions & 12 deletions okf-bundle/packages/firestore/pipeline-platform-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,15 @@ No permanent `Platform.android` / `Platform.ios` e2e workaround without registry

# Drift registry

**Status:** drift audit complete. `Pipeline.e2e.js`: 31 `Platform.*` sites; 5 bridge gaps; remainder SDK / macOS-js / test-only. Live order: [work queue](pipeline-coverage-work-queue.md).
**Status:** bridge gaps **P-005–P-012** remediated; operand-mode audit **P-034** closed. Live coordination: [work queue](pipeline-coverage-work-queue.md).

**Classification key:** `bridge` | `SDK` | `macOS-js` | `test-only` | `RNFB-JS`

---

## Bridge gaps (must fix)

| ID | Area | Symptom | iOS | Android | macOS | E2e hook (`Pipeline.e2e.js`) | Remediation order |
|----|------|---------|-----|---------|-------|------------------------------|-------------------|
| **P-001** | Operand-mode / numeric coercion | Ordering and arithmetic RHS: bool/scalar coercion differs | `ExpressionCoercionMode.numericOperand` / `.comparisonOperand` in `coerceExpressionTree` | No equivalent; `applyBooleanReceiverConstant` passes raw `Boolean`; arithmetic args use expression-value path only | JS SDK (no native bridge) | L3533–3622 (iOS-only arithmetic + where leg); L3665–3667 (`value: true` vs `1`) | **1** |
| **P-005** | `integerLiteral` wire tag | `constant()` integers emit `integerLiteral: true`; iOS NodeBuilder bool→0/1 before int | Consumes tag in `scalarConstantBridge` | `unwrapConstantValue` returns raw value; no tag handling | N/A | L3533–3559 (indirect); no wire assert | **2** |
| **P-010** | Stage option expression fields | Expression-valued `distanceField` / `indexField` | Parsed/coerced as expression | Parser `optionalString` only; executor `withDistanceField(String)` | Skipped (L3902+) | L3795–3845 (Android-only source rawOptions); findNearest/unnest paths | **3** |
| **P-011** | Parser constant envelope routing | `{ exprType: "constant", value: … }` in value context | `isExpressionLike` true for any `exprType` | `isExpressionLike` excludes `"constant"` — descends as literal map | Same wire | Nested constants (e.g. ref maps) | **4** |
| **P-012** | `timestampTruncate` arity validation | Validates arg count; throws | Sets `box.value = null` when `args.size() != 2` | Same | L3292–3294 (macOS vacuous) | **5** |

**After P-001:** **P-034** extend Android operand-mode e2e to match iOS; remove where-filter `if (!Platform.ios) return`.
*None open* — remediated rows in [Resolved](#resolved). SDK / macOS-js / test-only rows below remain documented.

---

Expand Down Expand Up @@ -73,8 +65,8 @@ No permanent `Platform.android` / `Platform.ios` e2e workaround without registry
| **P-018** | Constant-wrapped array lowering | L3236–3246 | Reduced select on macOS |
| **P-019** | Map passthrough lowering | L3266–3268 | Vacuous pass |
| **P-020** | `timestampTruncate` arity validation | L3292–3294 | Vacuous pass |
| **P-021** | Operand-mode equality subset | L3511–3530 | Reduced select (4 fields); mirrors Android reduced path |
| **P-022** | Raw where operand-mode | L3626–3628 | Vacuous pass |
| **P-021** | Operand-mode equality subset | L3206–3225 | Reduced select (4 equality fields); arithmetic rhs + fluent-where subtest native-only — firebase-js-sdk rejects `add(..., BOOLEAN)` |
| **P-022** | Raw where operand-mode | L3296–3298 | Vacuous pass; raw `{ operator, fieldPath, value }` where throws `_readUserData` on macOS JS path |
| **P-023** | Source rawOptions / index hints | L3796–3798 | Vacuous pass (with iOS) |
| **P-024** | findNearest execute | L3902–3904 | Vacuous pass |
| **P-025** | unnest options-object | L3954–3956 | Vacuous pass |
Expand Down Expand Up @@ -120,5 +112,11 @@ For each **bridge** row:

| ID | Fix | Verified |
|----|-----|----------|
| **P-001** | Android NodeBuilder: `ExpressionCoercionMode` with numeric/comparison operand constant lowering aligned with iOS `coerceExpressionTree`; unified cross-platform operand-mode e2e (ordering/arithmetic RHS and raw-where bool coercion). **Remainder:** wire `COMPARISON_OPERAND` at non-ordering comparison call sites on Android. | 3-platform `Pipeline.e2e.js` |
| **P-005** | Android NodeBuilder: `unwrapConstantValue` consumes `integerLiteral: true`; `coerceIntegerLiteralConstantValue` bool→0/1 and whole-number int coercion aligned with iOS `scalarConstantBridge` / `unwrapConstantValue`. CFBoolean/NSNumber bool deferral documented (Android RN uses `ReadableType.Boolean`). | 3-platform `Pipeline.e2e.js` |
| **P-010** | Android Parser `optionalExpressionNode` for `distanceField`/`indexField`; Executor expression coercion via `coerceExpression`/`coerceStageOptionFieldName`. SDK Field/String lowering asymmetry documented. | 3-platform `Pipeline.e2e.js` |
| **P-011** | Android Parser `isExpressionLike` treats `exprType: "constant"` as expression-like (matching iOS); nested constant envelope e2e added. | 3-platform `Pipeline.e2e.js` |
| **P-012** | Android NodeBuilder: `timestampTruncate` arity via `requireArgumentCount` + receiver-frame throw (no silent null/NPE). iOS explicit arity guard deferred (e2e green via SDK path). | 3-platform `Pipeline.e2e.js` |
| **P-034** | Operand-mode e2e audit post-J1: no further `Platform.*` trims — iOS/Android share full assertion block; macOS-js reduced select (P-021) and raw-where vacuous skip (P-022) confirmed by runtime probe. | `Pipeline.e2e.js` operand-mode describe |
| P-002 | Android parser/node-builder: `{ path: "col/doc" }` reference constants no longer treated as field paths | Verified on Android e2e after parser fix |
| P-006 | MacOS e2e count delta | **Closed** — app `utils*` tests are skipped by platform; Pipeline registration is not the cause |
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as pipelines from '../lib/pipelines';
import * as pipelineExpressions from '../lib/pipelines/expressions';

/** Keep in sync with EXPRESSION_METHOD_NAMES in lib/pipelines/expressions.ts */
export const EXPRESSION_METHOD_NAMES = [
Expand Down Expand Up @@ -225,6 +226,20 @@ function unaryField(method: string, target = scores): FluentParityCase {
});
}

function resolveGlobalHelper(method: string): (...args: unknown[]) => unknown {
const fromPipelines = (pipelines as Record<string, unknown>)[method];
if (typeof fromPipelines === 'function') {
return fromPipelines as (...args: unknown[]) => unknown;
}

const fromExpressions = (pipelineExpressions as Record<string, unknown>)[method];
if (typeof fromExpressions === 'function') {
return fromExpressions as (...args: unknown[]) => unknown;
}

throw new Error(`Missing global helper for ${method}`);
}

function binaryCompare(
category: 'binary-compare' | 'binary-math',
method: string,
Expand All @@ -235,7 +250,7 @@ function binaryCompare(
category,
method,
expectedName: canonicalName(method),
global: () => (pipelines as any)[canonicalName(method)](target, arg),
global: () => resolveGlobalHelper(method)(target, arg),
fluent: () => fluent(target)[method](arg),
});
}
Expand Down
64 changes: 64 additions & 0 deletions packages/firestore/__tests__/pipelines-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ describe('validateSerializedPipeline — failure branches', function () {
).toThrow(
'pipelineExecute() expected pipeline.source.documents to contain only non-empty strings.',
);
expect(() =>
validateSerializedPipeline({
source: { source: 'documents', documents: 'not-an-array' },
stages: [],
}),
).toThrow(
'pipelineExecute() expected pipeline.source.documents to contain at least one value.',
);
});

it('validates the query source shape', function () {
Expand Down Expand Up @@ -143,6 +151,11 @@ describe('validateSerializedPipeline — failure branches', function () {
validateSerializedPipeline(pipelineWithStage({ stage, options: { [key]: [] } })),
).toThrow(`stage.options.${key} to contain at least one value.`);
}
expect(() =>
validateSerializedPipeline(
pipelineWithStage({ stage: 'select', options: { selections: 'not-an-array' } }),
),
).toThrow('stage.options.selections to contain at least one value.');
});

it('validates the sample stage', function () {
Expand All @@ -168,6 +181,28 @@ describe('validateSerializedPipeline — failure branches', function () {
'pipelineExecute() expected stage.options.other to be a serialized pipeline object.',
);

expect(() =>
validateSerializedPipeline(
pipelineWithStage({
stage: 'union',
options: { other: { stages: [] } },
}),
),
).toThrow(
'pipelineExecute() expected stage.options.other to be a serialized pipeline object.',
);

expect(() =>
validateSerializedPipeline(
pipelineWithStage({
stage: 'union',
options: { other: { source: { source: 'database' }, stages: 'not-an-array' } },
}),
),
).toThrow(
'pipelineExecute() expected stage.options.other to be a serialized pipeline object.',
);

// Valid union recurses into the nested pipeline (and surfaces nested errors).
expect(() =>
validateSerializedPipeline(
Expand All @@ -188,6 +223,29 @@ describe('validateSerializedPipeline — failure branches', function () {
).not.toThrow();
});

it('uses nested field paths when validating under a custom root or union child', function () {
expect(() =>
validateSerializedPipeline(
pipelineWithStage({ stage: 'select', options: { selections: [] } }),
'nested',
),
).toThrow('nested.stages[0].options.selections to contain at least one value.');

expect(() =>
validateSerializedPipeline(
pipelineWithStage({
stage: 'union',
options: {
other: {
source: { source: 'database' },
stages: [{ stage: 'select', options: { selections: [] } }],
},
},
}),
),
).toThrow('stage.options.other.stages[0].options.selections to contain at least one value.');
});

it('accepts option-less stages and rejects unknown stages', function () {
for (const stage of [
'where',
Expand Down Expand Up @@ -285,4 +343,10 @@ describe('validatePipelineExecuteRequest', function () {
'pipelineExecute() expected pipeline.source to be an object.',
);
});

it('throws when options are invalid', function () {
expect(() => validatePipelineExecuteRequest(validPipeline(), { indexMode: 'fast' })).toThrow(
'pipelineExecute() expected options.indexMode to equal "recommended".',
);
});
});
159 changes: 158 additions & 1 deletion packages/firestore/__tests__/pipelines.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it, jest } from '@jest/globals';
import { getFirestore } from '../lib';
import { getApp } from '@react-native-firebase/app';
import { FieldPath } from '../lib/FieldPath';
import { Timestamp } from '../lib/FirestoreTimestamp';
import {
arrayFilter,
arrayFirst,
Expand All @@ -27,9 +29,19 @@ import {
countAll,
average,
subcollection,
array,
map,
} from '../lib/pipelines';
import '../lib/pipelines';
import { ConstantExpression, FunctionExpression } from '../lib/pipelines/expressions';
import {
avg,
ConstantExpression,
eq,
FunctionExpression,
gt,
gte,
lt,
} from '../lib/pipelines/expressions';

describe('Firestore pipelines runtime', function () {
it('installs pipeline() and serializes source builders', function () {
Expand Down Expand Up @@ -646,6 +658,151 @@ describe('Firestore pipelines runtime', function () {
});
});

it('serializes comparison alias exports gt, eq, gte, and lt', function () {
expect(gt(field('rating'), constant(4))).toMatchObject({
exprType: 'Function',
name: 'greaterThan',
});
expect(eq(field('sku'), constant('SKU001'))).toMatchObject({
exprType: 'Function',
name: 'equal',
});
expect(gte(field('stock'), constant(50))).toMatchObject({
exprType: 'Function',
name: 'greaterThanOrEqual',
});
expect(lt(field('price'), constant(100))).toMatchObject({
exprType: 'Function',
name: 'lessThan',
});
});

it('serializes avg aggregate alias to average', function () {
expect(avg(field('score'))).toMatchObject({
exprType: 'AggregateFunction',
kind: 'average',
});
});

it('normalizes array and map helpers that embed runtime expression nodes', function () {
const arrayExpr: any = array([field('score'), constant('tail')]);
expect(arrayExpr).toMatchObject({
exprType: 'Function',
name: 'array',
args: [
{ exprType: 'Field', path: 'score' },
{ exprType: 'Constant', value: 'tail' },
],
});

const mapExpr: any = map({ label: field('name'), version: constant(1) });
expect(mapExpr).toMatchObject({
exprType: 'Function',
name: 'map',
});
expect(mapExpr.args[0].exprType).toBe('Constant');
expect(mapExpr.args[0].value.label.exprType).toBe('Field');
expect(mapExpr.args[0].value.version.value).toBe(1);
});

it('preserves non-plain constant values without walking prototype objects', function () {
class Marker {
readonly tag = 'marker';
}
const marker = new Marker();
const expr: any = constant(marker);
expect(expr).toMatchObject({
exprType: 'Constant',
value: marker,
});
});

it('normalizes pipeline execute results across timestamp and field path shapes', async function () {
const db: any = getFirestore();
const existingExecutionTime = new Timestamp(1735689600, 123000000);
const nativeExecute = jest.fn(async () => ({
executionTime: existingExecutionTime,
results: [
{
path: 'books/alpha',
id: 'alpha',
data: { title: 'Alpha', nested: { score: 9 } },
createTime: 1700000000123,
updateTime: [1700000001, 456000000],
},
{
path: 'books/beta',
data: { plain: true },
createTime: { seconds: 2, nanoseconds: 3 },
updateTime: { seconds: 4, nanoseconds: 5 },
},
],
}));

const originalNativeModule = db._nativeModule;
db._nativeModule = { pipelineExecute: nativeExecute };

try {
const snapshot = await execute(db.pipeline().documents(['books/alpha', 'books/beta']));

expect(snapshot.executionTime).toBe(existingExecutionTime);
expect(snapshot.results[0]?.createTime?.toMillis()).toBe(1700000000123);
expect(snapshot.results[0]?.updateTime?.seconds).toBe(1700000001);
expect(snapshot.results[0]?.updateTime?.nanoseconds).toBe(456000000);
expect(snapshot.results[0]?.get('nested.score')).toBe(9);
expect(snapshot.results[0]?.get(new FieldPath('nested', 'score'))).toBe(9);
expect(snapshot.results[0]?.get(field('nested.score'))).toBe(9);
expect(snapshot.results[1]?.data()).toEqual({ plain: true });
expect(snapshot.results[1]?.createTime?.seconds).toBe(2);
expect(snapshot.results[1]?.updateTime?.nanoseconds).toBe(5);
} finally {
db._nativeModule = originalNativeModule;
}
});

it('serializes FieldPath and Timestamp arguments in pipeline stages', function () {
const db: any = getFirestore();
const timestamp = new Timestamp(12, 34);
const fieldPath = new FieldPath('meta', 'createdAt');

const serialized = db
.pipeline()
.collection('books')
.where(greaterThan(field('rating'), timestamp as any))
.sort(Ordering.of(fieldPath as any).descending())
.distinct({ groups: [fieldPath] } as any)
.select(
field('title')
.add(timestamp as any)
.as('createdAt'),
)
.serialize();

expect(serialized.stages[0]?.options?.condition?.args?.[1]).toMatchObject({
exprType: 'Constant',
value: {
seconds: 12,
nanoseconds: 34,
},
});
expect(serialized.stages[1]?.options?.orderings?.[0]?.expr).toMatchObject({
exprType: 'Constant',
value: {
segments: ['meta', 'createdAt'],
},
});
expect(serialized.stages[2]?.options?.groups?.[0]).toEqual({
segments: ['meta', 'createdAt'],
});
expect(serialized.stages[3]?.options?.selections?.[0]?.expr?.args?.[1]).toMatchObject({
exprType: 'Constant',
value: {
seconds: 12,
nanoseconds: 34,
},
});
});

it('supports method-style expression chaining and ordering helper serialization', function () {
const db: any = getFirestore();

Expand Down
Loading
Loading