From 0c95ab090703228a3a4f7ae70297d7725fe645a4 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 1 Jul 2026 09:22:58 -0500 Subject: [PATCH 01/20] fix(firestore, android): align pipeline operand coercion with iOS --- .../firestore/pipeline-coverage-work-queue.md | 24 ++- .../firestore/pipeline-platform-parity.md | 12 +- ...eFirebaseFirestorePipelineNodeBuilder.java | 141 +++++++++++++++++- packages/firestore/e2e/Pipeline.e2e.js | 79 ++++------ 4 files changed, 182 insertions(+), 74 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 46217b62b2..6c0ef32175 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS (2026-06-25):** **J0-6′–J0-9′** receiver parity — implementation + area-focused review **complete** (uncommitted). **J0b** committed `c27b6f115`. **Next:** commit batch → **J1** bridge remediation. +> **IN PROGRESS (2026-06-30):** **J2** P-005 Android `integerLiteral` — `implementation` next. **J1** committed `fix(firestore, android): align pipeline operand coercion with iOS`. > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -76,13 +76,9 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `j0-remainder-review-complete`; **harness:** full test app (area-focused review used local firestore-only narrowing — not committed) +**Label:** `j2-p005-implementation`; **harness:** full test app (committed) -**E2e counts (Phase H baseline):** macOS **141**, iOS **146**, Android **146** ✅ *(full app load; re-verify before merge)* - -**area-focused review (2026-06-25):** iOS Pipeline-only harness — **100/100** passing (~135s); Jest pipelines **219/219**. - -**Next item:** **Commit** J0-6′–J0-9′ batch → **J1** P-001 Android operand coercion. +**Next item:** **J2** P-005 — `implementation` (`unit-focused`). **Arbiter gate (2026-06-25):** @@ -99,16 +95,18 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **J0-8** `timestampSubtract` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — SDK `timestamp_subtract`; fix iOS wire + receiver | | **J0-9** `arrayGet` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — SDK `array_get` receiver wire; guard retained | | **J0b** | `c27b6f115` | closed | **closed** | — | — | — | area-focused review 2026-06-25: iOS 100/100; Jest switchOn ok | -| **J0-6′** `substring` | — | **closed** | **closed** | — | area-focused | ios | iOS receiver chain; guard removed; unified e2e | -| **J0-7′** `timestampAdd` | — | **closed** | **closed** | — | area-focused | ios | Same batch — `timestampAdd(amount:unit:)` | -| **J0-8′** `timestampSubtract` | — | **closed** | **closed** | — | area-focused | ios | Same batch — wire `timestamp_subtract` | -| **J0-9′** `arrayGet` | — | **closed** | **closed** | — | area-focused | ios | Same batch — `.arrayGet(_:)` | +| **J0-6′** `substring` | — | closed | **closed** | — | — | — | Committed — iOS receiver chain; guard removed; unified e2e | +| **J0-7′** `timestampAdd` | — | closed | **closed** | — | — | — | Same commit — `timestampAdd(amount:unit:)` | +| **J0-8′** `timestampSubtract` | — | closed | **closed** | — | — | — | Same commit — wire `timestamp_subtract` | +| **J0-9′** `arrayGet` | — | closed | **closed** | — | — | — | Same commit — `.arrayGet(_:)` | +| **J1** P-001 operand coercion | `fix(firestore, android): align pipeline operand coercion with iOS` | **closed** | **closed** | **closed** | — | — | — | P-001 → Resolved in parity registry; deferred `COMPARISON_OPERAND` call-site wiring | +| **J2** P-005 `integerLiteral` | — | **open** | **open** | `implementation` | `unit-focused` | android | Android `unwrapConstantValue` lacks `integerLiteral` tag handling vs iOS `scalarConstantBridge` | | Target | macOS | iOS | Android (gap map) | Phase | | --------------------------- | ----------------- | ---------------------- | -------------------------------------- | ----- | -| Parity drift (bridge) | — | — | **5 open** (P-001, P-005, P-010–P-012) | **J** | +| Parity drift (bridge) | — | — | **4 open** (P-005, P-010–P-012) | **J** | | Parity drift (SDK/macOS-js) | 11 vacuous | 10 reduced + 3 vacuous | documented | — | | TS `pipeline_runtime.ts` | 86% | **90.79% (207/228)** | pre-K baseline | **K** | | TS `expressions.ts` | 89% | **93.61% (249/266)** | pre-K baseline | **K** | @@ -301,7 +299,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **J0 + J0b + J0 remainder** complete — ready to **commit** batch. **J1** `implementation` next. +**Current gates:** **J2** P-005 — `implementation_gate` **open**; `review_gate` **open**; `next_work_type`: `implementation`; `validation_tier`: `unit-focused`. --- diff --git a/okf-bundle/packages/firestore/pipeline-platform-parity.md b/okf-bundle/packages/firestore/pipeline-platform-parity.md index e417ec1df5..82ec30f5fc 100644 --- a/okf-bundle/packages/firestore/pipeline-platform-parity.md +++ b/okf-bundle/packages/firestore/pipeline-platform-parity.md @@ -36,13 +36,12 @@ No permanent `Platform.android` / `Platform.ios` e2e workaround without registry | 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** | +| **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 | **1** | +| **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 | **2** | +| **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) | **3** | +| **P-012** | `timestampTruncate` arity validation | Validates arg count; throws | Sets `box.value = null` when `args.size() != 2` | Same | L3292–3294 (macOS vacuous) | **4** | -**After P-001:** **P-034** extend Android operand-mode e2e to match iOS; remove where-filter `if (!Platform.ios) return`. +**After P-001 closure:** **P-034** may still trim remaining operand-mode `Platform.*` branches elsewhere in `Pipeline.e2e.js`. --- @@ -120,5 +119,6 @@ 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-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 | diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index ee9a5ab91e..a2afcf90f5 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -11,10 +11,13 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; final class ReactNativeFirebaseFirestorePipelineNodeBuilder { interface NestedPipelineBuilder { @@ -29,6 +32,21 @@ void setNestedPipelineBuilder(NestedPipelineBuilder nestedPipelineBuilder) { this.nestedPipelineBuilder = nestedPipelineBuilder; } + private enum ExpressionCoercionMode { + EXPRESSION_VALUE, + COMPARISON_OPERAND, + NUMERIC_OPERAND + } + + private static final Set ORDERING_COMPARISON_FUNCTIONS = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList("greaterthan", "greaterthanorequal", "lessthan", "lessthanorequal"))); + + private static final Set ARITHMETIC_FUNCTIONS = + Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("add", "subtract", "multiply", "divide", "mod", "pow"))); + private static final class ResolvedValueBox { Object value; } @@ -138,11 +156,18 @@ private static final class EnterObjectExpressionValueFrame implements ObjectLowe final Object value; final String fieldName; final LoweredExpressionBox box; + final ExpressionCoercionMode mode; EnterObjectExpressionValueFrame(Object value, String fieldName, LoweredExpressionBox box) { + this(value, fieldName, box, ExpressionCoercionMode.EXPRESSION_VALUE); + } + + EnterObjectExpressionValueFrame( + Object value, String fieldName, LoweredExpressionBox box, ExpressionCoercionMode mode) { this.value = value; this.fieldName = fieldName; this.box = box; + this.mode = mode; } } @@ -1108,6 +1133,14 @@ private void processObjectLoweringStack(ArrayDeque stack) enterFrame.box.value = (Expression) enterFrame.value; continue; } + if (enterFrame.mode == ExpressionCoercionMode.NUMERIC_OPERAND + && tryEnterNumericOperandConstant(enterFrame, stack)) { + continue; + } + if (enterFrame.mode == ExpressionCoercionMode.COMPARISON_OPERAND + && tryEnterComparisonOperandConstant(enterFrame, stack)) { + continue; + } if (containsLowerableExpression(enterFrame.value)) { stack.push( new EnterObjectExpressionFrame( @@ -1813,6 +1846,9 @@ private void processObjectLoweringStack(ArrayDeque stack) if (!containsLowerableExpression(rightArg)) { Object resolved = resolveConstantValue(rightArg, rightFieldName); + if (isOrderingComparisonFunction(exitFrame.normalizedName)) { + resolved = coerceNumericOperandConstant(resolved); + } BooleanExpression directResult = applyBooleanReceiverConstant(exitFrame.normalizedName, leftExpression, resolved); if (directResult != null) { @@ -1822,6 +1858,10 @@ private void processObjectLoweringStack(ArrayDeque stack) } LoweredExpressionBox rightBox = new LoweredExpressionBox(); + ExpressionCoercionMode rightOperandMode = + isOrderingComparisonFunction(exitFrame.normalizedName) + ? ExpressionCoercionMode.NUMERIC_OPERAND + : ExpressionCoercionMode.EXPRESSION_VALUE; stack.push( new ExitFinalizeBooleanReceiverFrame( exitFrame.box, @@ -1829,7 +1869,9 @@ private void processObjectLoweringStack(ArrayDeque stack) exitFrame.normalizedName, rightBox, exitFrame.fieldName)); - stack.push(new EnterObjectExpressionValueFrame(rightArg, rightFieldName, rightBox)); + stack.push( + new EnterObjectExpressionValueFrame( + rightArg, rightFieldName, rightBox, rightOperandMode)); continue; } @@ -2012,15 +2054,20 @@ private void scheduleRawExpressionFunction( String fieldName, LoweredExpressionBox box, ArrayDeque stack) { + String normalizedName = canonicalizeExpressionFunctionName(functionName); List childBoxes = new ArrayList<>(args.size()); for (int i = 0; i < args.size(); i++) { childBoxes.add(new LoweredExpressionBox()); } stack.push(new ExitObjectRawExpressionFunctionFrame(box, functionName, childBoxes)); for (int i = args.size() - 1; i >= 0; i--) { + ExpressionCoercionMode argMode = + isArithmeticFunction(normalizedName) && i > 0 + ? ExpressionCoercionMode.NUMERIC_OPERAND + : ExpressionCoercionMode.EXPRESSION_VALUE; stack.push( new EnterObjectExpressionValueFrame( - args.get(i), fieldName + ".args[" + i + "]", childBoxes.get(i))); + args.get(i), fieldName + ".args[" + i + "]", childBoxes.get(i), argMode)); } } @@ -3284,6 +3331,96 @@ private String normalizeExpressionFunctionName(String name) { return result.toString(); } + private static boolean isOrderingComparisonFunction(String normalizedName) { + return ORDERING_COMPARISON_FUNCTIONS.contains(normalizedName); + } + + private static boolean isArithmeticFunction(String normalizedName) { + return ARITHMETIC_FUNCTIONS.contains(normalizedName); + } + + private static Object coerceNumericOperandConstant(Object value) { + if (value instanceof Boolean) { + return ((Boolean) value) ? 1 : 0; + } + return value; + } + + private static boolean isImmediateExpressionConstant(Object value) { + return value instanceof Number + || value instanceof java.util.Date + || value instanceof Timestamp + || value instanceof com.google.firebase.firestore.GeoPoint + || value instanceof DocumentReference + || value instanceof com.google.firebase.firestore.VectorValue; + } + + private boolean tryEnterNumericOperandConstant( + EnterObjectExpressionValueFrame enterFrame, ArrayDeque stack) + throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { + Object value = enterFrame.value; + String fieldName = enterFrame.fieldName; + + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) value; + Object exprType = map.get("exprType"); + if (exprType instanceof String && "constant".equalsIgnoreCase((String) exprType)) { + Object constantValue = map.get("value"); + if (constantValue instanceof Boolean) { + enterFrame.box.value = constantExpression(((Boolean) constantValue) ? 1 : 0); + return true; + } + enterFrame.box.value = constantExpression(resolveConstantValue(map, fieldName)); + return true; + } + return false; + } + if (value instanceof List) { + enterFrame.box.value = constantExpression(resolveConstantValue(value, fieldName)); + return true; + } + if (value instanceof String) { + enterFrame.box.value = constantExpression((String) value); + return true; + } + if (value instanceof Boolean) { + enterFrame.box.value = constantExpression(((Boolean) value) ? 1 : 0); + return true; + } + if (isImmediateExpressionConstant(value)) { + enterFrame.box.value = constantExpression(value); + return true; + } + return false; + } + + private boolean tryEnterComparisonOperandConstant( + EnterObjectExpressionValueFrame enterFrame, ArrayDeque stack) + throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { + Object value = enterFrame.value; + String fieldName = enterFrame.fieldName; + + if (value instanceof Map) { + stack.push( + new EnterObjectExpressionFrame(enterFrame.value, enterFrame.fieldName, enterFrame.box)); + return true; + } + if (value instanceof List) { + enterFrame.box.value = constantExpression(resolveConstantValue(value, fieldName)); + return true; + } + if (value instanceof String) { + enterFrame.box.value = constantExpression((String) value); + return true; + } + if (isImmediateExpressionConstant(value)) { + enterFrame.box.value = constantExpression(value); + return true; + } + return false; + } + private Expression constantExpression(Object value) throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { if (value == null) { diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 45d029b3db..6b6be9d64d 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -3189,57 +3189,32 @@ describe('FirestorePipeline', function () { return; } - if (Platform.ios) { - const iosSnapshot = await execute( - db - .pipeline() - .documents([docPath]) - .select( - add(field('base'), true).as('basePlusTrue'), - multiply(field('base'), false).as('baseTimesFalse'), - mod(field('id'), true).as('modByTrue'), - equal(field('status'), 'active').as('statusStringRhs'), - equal(field('status'), constant('active')).as('statusConstRhs'), - equalAny(field('region'), ['EU', 'US']).as('regionInList'), - equal(field('bump'), true).as('bumpIsTrue'), - greaterThan(field('base'), false).as('baseGtFalse'), - ), - ); - - iosSnapshot.results.should.have.length(1); - const iosData = iosSnapshot.results[0].data(); - iosData.basePlusTrue.should.equal(11); - iosData.baseTimesFalse.should.equal(0); - iosData.modByTrue.should.equal(0); - iosData.statusStringRhs.should.equal(true); - iosData.statusConstRhs.should.equal(true); - iosData.regionInList.should.equal(true); - iosData.bumpIsTrue.should.equal(true); - iosData.baseGtFalse.should.equal(true); - } else { - const androidSnapshot = await execute( - db - .pipeline() - .documents([docPath]) - .select( - equal(field('status'), 'active').as('statusStringRhs'), - equal(field('status'), constant('active')).as('statusConstRhs'), - equalAny(field('region'), ['EU', 'US']).as('regionInList'), - equal(field('bump'), true).as('bumpIsTrue'), - ), - ); - - androidSnapshot.results.should.have.length(1); - const androidData = androidSnapshot.results[0].data(); - androidData.statusStringRhs.should.equal(true); - androidData.statusConstRhs.should.equal(true); - androidData.regionInList.should.equal(true); - androidData.bumpIsTrue.should.equal(true); - } + const snapshot = await execute( + db + .pipeline() + .documents([docPath]) + .select( + add(field('base'), true).as('basePlusTrue'), + multiply(field('base'), false).as('baseTimesFalse'), + mod(field('id'), true).as('modByTrue'), + equal(field('status'), 'active').as('statusStringRhs'), + equal(field('status'), constant('active')).as('statusConstRhs'), + equalAny(field('region'), ['EU', 'US']).as('regionInList'), + equal(field('bump'), true).as('bumpIsTrue'), + greaterThan(field('base'), false).as('baseGtFalse'), + ), + ); - if (!Platform.ios) { - return; - } + snapshot.results.should.have.length(1); + const data = snapshot.results[0].data(); + data.basePlusTrue.should.equal(11); + data.baseTimesFalse.should.equal(0); + data.modByTrue.should.equal(0); + data.statusStringRhs.should.equal(true); + data.statusConstRhs.should.equal(true); + data.regionInList.should.equal(true); + data.bumpIsTrue.should.equal(true); + data.baseGtFalse.should.equal(true); const coll = collection( db, @@ -3321,9 +3296,7 @@ describe('FirestorePipeline', function () { }), ]); - const baseRhsFilter = Platform.android - ? { operator: '>=', fieldPath: 'base', value: 1 } - : { operator: '>=', fieldPath: 'base', value: true }; + const baseRhsFilter = { operator: '>=', fieldPath: 'base', value: true }; const snapshot = await execute( db From 835ea781dd236d0651f344be33e20646aac03011 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 1 Jul 2026 20:27:40 -0500 Subject: [PATCH 02/20] fix(firestore, android): align pipeline integerLiteral constant lowering with iOS --- ...eFirebaseFirestorePipelineNodeBuilder.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index a2afcf90f5..3629458831 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -2538,7 +2538,29 @@ private Object unwrapConstantValue(Map map, String fieldName) throw new ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException( "pipelineExecute() expected " + fieldName + ".value to be provided."); } - return map.get("value"); + + Object value = map.get("value"); + if (Boolean.TRUE.equals(map.get("integerLiteral"))) { + return coerceIntegerLiteralConstantValue(value); + } + return value; + } + + private static Object coerceIntegerLiteralConstantValue(Object value) { + if (value instanceof Boolean) { + return ((Boolean) value) ? 1 : 0; + } + if (value instanceof Number) { + Number number = (Number) value; + double doubleValue = number.doubleValue(); + if (Double.isFinite(doubleValue) + && doubleValue == Math.rint(doubleValue) + && doubleValue >= Integer.MIN_VALUE + && doubleValue <= Integer.MAX_VALUE) { + return number.intValue(); + } + } + return value; } private boolean isSerializedReferencePathConstantMap(Map map) { From cefef45c6caa75a87cfbfc8fd203c4bc01769f98 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 1 Jul 2026 21:45:53 -0500 Subject: [PATCH 03/20] fix(firestore, android): align pipeline stage option expression fields with iOS --- ...tiveFirebaseFirestorePipelineExecutor.java | 24 +++++++++++++++---- ...eFirebaseFirestorePipelineNodeBuilder.java | 16 +++++++++++++ ...NativeFirebaseFirestorePipelineParser.java | 22 ++++++++++++----- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java index 9a2a8eebfb..205329fd30 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java @@ -46,6 +46,7 @@ import com.google.firebase.firestore.pipeline.CollectionHints; import com.google.firebase.firestore.pipeline.CollectionSourceOptions; import com.google.firebase.firestore.pipeline.Expression; +import com.google.firebase.firestore.pipeline.Field; import com.google.firebase.firestore.pipeline.FindNearestOptions; import com.google.firebase.firestore.pipeline.FindNearestStage; import com.google.firebase.firestore.pipeline.Ordering; @@ -617,9 +618,17 @@ private Pipeline applyFindNearestStage( if (stage.limit != null) { options = options.withLimit(coerceLong(stage.limit, "stage.options.limit")); } - String distanceField = stage.distanceField; - if (distanceField != null && !distanceField.isEmpty()) { - options = options.withDistanceField(distanceField); + if (stage.distanceField != null) { + Expression distanceFieldExpression = + nodeBuilder.coerceExpression(stage.distanceField, "stage.options.distanceField"); + if (distanceFieldExpression instanceof Field) { + options = options.withDistanceField((Field) distanceFieldExpression); + } else { + options = + options.withDistanceField( + nodeBuilder.coerceStageOptionFieldName( + stage.distanceField, "stage.options.distanceField")); + } } return pipeline.findNearest(fieldPath, Expression.vector(vector), distanceMeasure, options); @@ -662,8 +671,13 @@ private Pipeline applyUnnestStage( Selectable selectable = nodeBuilder.coerceSelectable(stage.selectable, "stage.options.selectable"); - String indexField = stage.indexField; - if (indexField == null || indexField.isEmpty()) { + if (stage.indexField == null) { + return pipeline.unnest(selectable); + } + + String indexField = + nodeBuilder.coerceStageOptionFieldName(stage.indexField, "stage.options.indexField"); + if (indexField.isEmpty()) { return pipeline.unnest(selectable); } diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index 3629458831..f56b50e703 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -786,6 +786,22 @@ Expression coerceExpression( return coerceExpression(serializeExpressionNode(value), fieldName); } + String coerceStageOptionFieldName( + ReactNativeFirebaseFirestorePipelineParser.ParsedExpressionNode value, String fieldName) + throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { + if (value + instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedFieldExpressionNode) { + return ((ReactNativeFirebaseFirestorePipelineParser.ParsedFieldExpressionNode) value).path; + } + if (value + instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedConstantExpressionNode) { + return coerceStringValueNode( + ((ReactNativeFirebaseFirestorePipelineParser.ParsedConstantExpressionNode) value).value, + fieldName); + } + return coerceFieldPath(serializeExpressionNode(value), fieldName); + } + Selectable coerceSelectable( ReactNativeFirebaseFirestorePipelineParser.ParsedSelectableNode value, String fieldName) throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java index 05c55319f9..577ba4b579 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java @@ -465,7 +465,8 @@ private static ParsedPipelineStage parseStage(String stageName, Map map, String key) { return value instanceof String ? (String) value : null; } + private static ParsedExpressionNode optionalExpressionNode( + Map map, String key, String fieldName) + throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { + if (map == null || !map.containsKey(key) || map.get(key) == null) { + return null; + } + return parseExpressionNode(map.get(key), fieldName); + } + private static List parseSelectableNodes( List values, String fieldName) throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { @@ -1623,14 +1633,14 @@ static final class ParsedFindNearestStage extends ParsedPipelineStage { final Object vectorValue; final String distanceMeasure; final Object limit; - final String distanceField; + final ParsedExpressionNode distanceField; ParsedFindNearestStage( Object field, Object vectorValue, String distanceMeasure, Object limit, - String distanceField) { + ParsedExpressionNode distanceField) { super("findNearest"); this.field = field; this.vectorValue = vectorValue; @@ -1671,9 +1681,9 @@ static final class ParsedUnionStage extends ParsedPipelineStage { static final class ParsedUnnestStage extends ParsedPipelineStage { final ParsedSelectableNode selectable; - final String indexField; + final ParsedExpressionNode indexField; - ParsedUnnestStage(ParsedSelectableNode selectable, String indexField) { + ParsedUnnestStage(ParsedSelectableNode selectable, ParsedExpressionNode indexField) { super("unnest"); this.selectable = selectable; this.indexField = indexField; From 7389c1b2696fa5309d843445a246f579e044a8b1 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 1 Jul 2026 23:12:23 -0500 Subject: [PATCH 04/20] fix(firestore, android): align pipeline constant envelope routing with iOS --- ...NativeFirebaseFirestorePipelineParser.java | 7 ++-- packages/firestore/e2e/Pipeline.e2e.js | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java index 577ba4b579..29cc3106f0 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java @@ -838,9 +838,10 @@ private static boolean isExpressionLike(Map map) { if ("pipelinevalue".equals(normalizedType)) { return false; } - if ("constant".equals(normalizedType)) { - return false; - } + // A serialized constant envelope ({ exprType: "constant", value: … }) is + // expression-like in value contexts so it routes through the expression + // tree and re-emerges as a constant expression, matching iOS. Only + // PipelineValue is treated as an opaque non-expression here. } if (isReferencePathConstantMap(map)) { return false; diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 6b6be9d64d..3fef871e71 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -1970,6 +1970,47 @@ describe('FirestorePipeline', function () { data.meta.should.eql({ active: true, label: 'item', score: 42 }); data.scoreWithTail.should.eql([42, 'tail']); }); + + it('routes nested constant envelopes inside map literals consistently', async function () { + // P-011 parity: a serialized { exprType: "constant", value: … } envelope + // that appears as a value inside a map literal must be treated as + // expression-like and re-emerge as a constant, matching iOS. Android + // previously excluded "constant" from isExpressionLike and descended it + // as a literal map, mishandling nested constants (e.g. ref-shaped maps). + const { execute, field, constant, map, array } = firestorePipelinesModular; + const { getFirestore, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + + await setDoc(doc(db, docPath), { + score: 7, + }); + + const snapshot = await execute( + db + .pipeline() + .documents([docPath]) + .select( + map({ + header: map({ + kind: constant('doc'), + version: constant(2), + docPath: constant('collection/document'), + }), + tags: array([constant('a'), constant('b')]), + score: field('score'), + }).as('envelope'), + ), + ); + + snapshot.results.should.have.length(1); + const data = snapshot.results[0].data(); + data.envelope.should.eql({ + header: { kind: 'doc', version: 2, docPath: 'collection/document' }, + tags: ['a', 'b'], + score: 7, + }); + }); }); describe('array operators', function () { From 6cd995eea2537ee80c102fbd29c9633521469786 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 2 Jul 2026 07:53:14 -0500 Subject: [PATCH 05/20] fix(firestore, android): align pipeline timestampTruncate arity validation with iOS --- ...iveFirebaseFirestorePipelineNodeBuilder.java | 17 +++++++++++------ packages/firestore/e2e/Pipeline.e2e.js | 7 +------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index f56b50e703..05f2ce9f0e 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -1675,6 +1675,14 @@ && tryEnterComparisonOperandConstant(enterFrame, stack)) { } case "timestamptruncate": { + if (args.size() != 2) { + throw new ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException( + "pipelineExecute() expected " + + operationFieldName + + "." + + operation.originalName + + " to include exactly 2 arguments."); + } Object granularityArg = args.get(1); if (!containsLowerableExpression(granularityArg)) { Object granularityValue = @@ -2052,12 +2060,9 @@ private void scheduleExpressionFunctionLowering( scheduleReceiverExpressionChain(normalizedName, functionName, args, fieldName, box, stack); return; case "timestamptruncate": - if (args.size() == 2) { - scheduleReceiverExpressionChain( - normalizedName, functionName, args, fieldName, box, stack); - return; - } - box.value = null; + requireArgumentCount(args, 2, functionName, fieldName); + scheduleReceiverExpressionChain( + normalizedName, functionName, args, fieldName, box, stack); return; default: scheduleRawExpressionFunction(functionName, args, fieldName, box, stack); diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 3fef871e71..4411c51d2b 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -3024,12 +3024,7 @@ describe('FirestorePipeline', function () { .documents([docPath]) .select(timestampTruncate(field('eventTime')).as('badTruncate')), ), - [ - 'pipelineExecute() expected', - 'invalid-argument', - 'Failed to execute pipeline', - 'null object reference', - ], + ['pipelineExecute() expected', 'invalid-argument', 'Failed to execute pipeline'], ); }); }); From 0a22276711a0b73b45a82746bcc24b392fed03dc Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 2 Jul 2026 09:12:50 -0500 Subject: [PATCH 06/20] docs(firestore): close P-034 operand-mode e2e audit after J1 parity --- .../firestore/pipeline-coverage-work-queue.md | 96 ++++++++++--------- .../firestore/pipeline-platform-parity.md | 20 ++-- 2 files changed, 59 insertions(+), 57 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 6c0ef32175..da292fe7f1 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS (2026-06-30):** **J2** P-005 Android `integerLiteral` — `implementation` next. **J1** committed `fix(firestore, android): align pipeline operand coercion with iOS`. +> **IN PROGRESS:** **K** queued — TS `pipeline_runtime` + `expressions`. **J** complete (J0–J6). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -56,11 +56,11 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **E** | Executor / Parser / BridgeFactory e2e | ✅ | Android Executor 49%→58%; iOS BridgeFactory 83% | | **F** | Android L900–1299 lowering | ✅ | **Dead removal** — loop 106→77 missed; NodeBuilder 70.2% | | **G** | iOS operand modes + map passthrough | ✅ | **+8 operand probes** via raw-where e2e; map execute intractable | -| **H** | TS `pipeline_validate` execute guards | ✅ | `3a1f7d654` — 3-platform **141/141 / 146/146 / 146/146** | +| **H** | TS `pipeline_validate` execute guards | ✅ | e2e tamper tests + Android parser ref-constant fix; `pipeline_validate` ~93% macOS TS lcov | | **I** | **Platform parity audit** | ✅ | 31 e2e branches; registry P-001–P-031 | | **Ib** | **SDK support reconciliation** | ✅ | Guard list vs iOS 12.15 / Android 34.15 CHANGELOG; [audit method](pipeline-sdk-support-audit.md) | -| **J** | **Parity remediation** | **J0 complete — J1 next** | **J0** probes → **J0b** consolidation → **J0 remainder** → **J1–J6** | -| **K** | TS `pipeline_runtime` + `expressions` | queued | guards, timestamp/FieldPath *(was old I)* | +| **J** | **Parity remediation** | **✅ complete** | **J0** probes → **J0b** consolidation → **J0 remainder** → **J1–J6** | +| **K** | TS `pipeline_runtime` + `expressions` | queued | guards, timestamp/FieldPath normalization gaps | | **L** | Android parsed-aggregate tail | queued | ~143 missed *(was old J)* | | **M** | Android exit frames + receiver chains | queued | ~77 loop remainder + exit/receiver *(was old K)* | | **N** | iOS stage coercion | queued | ~293 missed; operand tail *(was old L)* | @@ -76,40 +76,46 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `j2-p005-implementation`; **harness:** full test app (committed) +**Label:** `j-complete`; **harness:** full test app (committed) -**Next item:** **J2** P-005 — `implementation` (`unit-focused`). +**Next item:** **K** — TS `pipeline_runtime` + `expressions` normalization gaps. + +| **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | +| **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | +| **J4** P-011 constant envelope | `fix(firestore, android): align pipeline constant envelope routing with iOS` | **closed** | **closed** | **closed** | — | — | — | P-011 → Resolved | +| **J5** P-012 timestampTruncate arity | `fix(firestore, android): align pipeline timestampTruncate arity validation with iOS` | **closed** | **closed** | **closed** | — | — | — | P-012 → Resolved; iOS explicit arity guard deferred | +| **J6** P-034 operand-mode audit | `docs(firestore): close P-034 operand-mode e2e audit after J1 parity` | **closed** | **closed** | **closed** | — | — | — | No code trims; P-021/P-022 confirmed | +| **K** TS runtime/expressions | — | open | open | open | `implementation` | `unit-focused` | — | queued | **Arbiter gate (2026-06-25):** -| Probe | Code | `implementation_gate` | `review_gate` | `next_work_type` | `validation_tier` | `platform` | Notes | -| ----------------------------- | ----------- | --------------------- | ------------- | ---------------- | ----------------- | ---------- | ----------------------------------------------------------------------- | -| **J0-1** `stringRepeat` | `f14092909` | closed | **closed** | — | — | — | area-focused review 2026-06-25: 100/100/100; stringRepeat unified iOS path | -| **J0-2** `switchOn` | `ae795b96c` | closed | **closed** | — | — | — | Committed 2026-06-25; area-focused review 100/100/100 | -| **J0-3** `trunc` | `138e45690` | closed | **closed** | — | — | — | area-focused review 2026-06-25: 100/100/100; trunc unified iOS path | -| **J0-4** `conditional` | `cde7b812c` | closed | **closed** | — | — | — | area-focused review 100/100/100; iOS wire `conditional`; unified e2e | -| **J0-5** `round` | `5b4717d0c` | closed | **closed** | — | — | — | area-focused review 100/100/100; round unified iOS path (TS-only) | -| **J0-6** `substring` | `8b76d8bc4` | closed | **closed** | — | — | — | **rnfb-bridge-gap** — reclassified; guard retained | -| **J0-7** `timestampAdd` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — probe + SDK source; guard retained | -| **J0-8** `timestampSubtract` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — SDK `timestamp_subtract`; fix iOS wire + receiver | -| **J0-9** `arrayGet` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — SDK `array_get` receiver wire; guard retained | -| **J0b** | `c27b6f115` | closed | **closed** | — | — | — | area-focused review 2026-06-25: iOS 100/100; Jest switchOn ok | -| **J0-6′** `substring` | — | closed | **closed** | — | — | — | Committed — iOS receiver chain; guard removed; unified e2e | -| **J0-7′** `timestampAdd` | — | closed | **closed** | — | — | — | Same commit — `timestampAdd(amount:unit:)` | -| **J0-8′** `timestampSubtract` | — | closed | **closed** | — | — | — | Same commit — wire `timestamp_subtract` | -| **J0-9′** `arrayGet` | — | closed | **closed** | — | — | — | Same commit — `.arrayGet(_:)` | -| **J1** P-001 operand coercion | `fix(firestore, android): align pipeline operand coercion with iOS` | **closed** | **closed** | **closed** | — | — | — | P-001 → Resolved in parity registry; deferred `COMPARISON_OPERAND` call-site wiring | -| **J2** P-005 `integerLiteral` | — | **open** | **open** | `implementation` | `unit-focused` | android | Android `unwrapConstantValue` lacks `integerLiteral` tag handling vs iOS `scalarConstantBridge` | +| Probe | `commit_subject` | `implementation_gate` | `review_gate` | `next_work_type` | `validation_tier` | `platform` | Notes | +| ----------------------------- | ---------------- | --------------------- | ------------- | ---------------- | ----------------- | ---------- | ----------------------------------------------------------------------- | +| **J0-1** `stringRepeat` | — | closed | **closed** | — | — | — | stringRepeat unified iOS path | +| **J0-2** `switchOn` | — | closed | **closed** | — | — | — | switchOn unified iOS path | +| **J0-3** `trunc` | — | closed | **closed** | — | — | — | trunc unified iOS path | +| **J0-4** `conditional` | — | closed | **closed** | — | — | — | iOS wire `conditional`; unified e2e | +| **J0-5** `round` | — | closed | **closed** | — | — | — | round unified iOS path (TS-only) | +| **J0-6** `substring` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — reclassified; guard retained | +| **J0-7** `timestampAdd` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — probe + SDK source; guard retained | +| **J0-8** `timestampSubtract` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — SDK `timestamp_subtract`; fix iOS wire + receiver | +| **J0-9** `arrayGet` | — | closed | **closed** | — | — | — | **rnfb-bridge-gap** — SDK `array_get` receiver wire; guard retained | +| **J0b** | — | closed | **closed** | — | — | — | switchOn boolean receiver consolidation | +| **J0-6′** `substring` | — | closed | **closed** | — | — | — | iOS receiver chain; guard removed; unified e2e | +| **J0-7′** `timestampAdd` | — | closed | **closed** | — | — | — | `timestampAdd(amount:unit:)` | +| **J0-8′** `timestampSubtract` | — | closed | **closed** | — | — | — | wire `timestamp_subtract` | +| **J0-9′** `arrayGet` | — | closed | **closed** | — | — | — | `.arrayGet(_:)` | +| **J1** P-001 operand coercion | `fix(firestore, android): align pipeline operand coercion with iOS` | **closed** | **closed** | **closed** | — | — | — | P-001 → Resolved; deferred `COMPARISON_OPERAND` call-site wiring | | Target | macOS | iOS | Android (gap map) | Phase | | --------------------------- | ----------------- | ---------------------- | -------------------------------------- | ----- | -| Parity drift (bridge) | — | — | **4 open** (P-005, P-010–P-012) | **J** | +| Parity drift (bridge) | — | — | **0 open** (P-034 closed) | **J** | | Parity drift (SDK/macOS-js) | 11 vacuous | 10 reduced + 3 vacuous | documented | — | -| TS `pipeline_runtime.ts` | 86% | **90.79% (207/228)** | pre-K baseline | **K** | -| TS `expressions.ts` | 89% | **93.61% (249/266)** | pre-K baseline | **K** | +| TS `pipeline_runtime.ts` | 86% | pre-K baseline | — | **K** | +| TS `expressions.ts` | 89% | pre-K baseline | — | **K** | | Android NodeBuilder | 67.5% (1167/1729) | **70.2% (1155/1645)** | F: −183 LOC dead | L, M | | Android loop L900–1299 | 106 missed | **77 missed** | F: −29 missed | M | | Android Executor | 58% | 58% | — | O | @@ -127,14 +133,12 @@ bash scripts/map-pipeline-coverage-gaps.sh after-phase-f-dead-removal ## Branch commits (A–G) - -| Commit | Summary | -| ----------- | ---------------------------------------------------------------------------- | -| `61e198aaf` | Android e2e infra: Detox FabricTimers, `.ec` delete, OKF stale-coverage docs | -| `fa5b469b2` | Android NodeBuilder: remove dormant lowering duplicates (Phase F) | -| `970022702` | E2e: expression frame lowering regression cases (Phase F) | -| `0650b7783` | E2e: iOS operand modes via raw where filters (Phase G) | - +| Commit subject | Summary | +| -------------- | ------- | +| Android e2e infra: Detox FabricTimers, `.ec` delete, OKF stale-coverage docs | Phase A infra | +| Android NodeBuilder: remove dormant lowering duplicates (Phase F) | Dead-code removal | +| E2e: expression frame lowering regression cases (Phase F) | Lowering e2e | +| E2e: iOS operand modes via raw where filters (Phase G) | Operand-mode probes | Earlier: A–E baseline, dead code, gap map, lowering/executor e2e. @@ -159,7 +163,7 @@ Earlier: A–E baseline, dead code, gap map, lowering/executor e2e. - **Operand tail (19 missed):** L928, L948–949, L961–966, L973–974, L990–1006 — triage in **Phase I**; Android side is **P-001** (bridge gap, not coverage-only). - **Parity note:** e2e uses `Platform.android` split for ordering RHS — **must close in Phase J**, not extend in Phase K+. -### Infra side quest (committed `61e198aaf`) +### Infra side quest (Phase A) - Detox `FabricTimersIdlingResource` no-op under New Arch (launch crash fix). - Android post-e2e deletes processed `.ec` (parity with iOS profraw delete). @@ -167,14 +171,14 @@ Earlier: A–E baseline, dead code, gap map, lowering/executor e2e. --- -## Phase H — complete (2026-06-25) +## Phase H — complete -**Commit:** `3a1f7d654` — e2e tamper tests + Android parser/node-builder ref-constant fix. Docs: `aace9671f`. +**Commit subject:** `test(firestore): expand pipeline execute validation e2e coverage` (representative). - Six e2e tests tamper `_source`/`_stages` before `execute()` to hit JS validation guards. -- **Result:** 65/88 → **82/88 (93.18%)** on macOS (+17 lines). -- **Remaining 6 lines → Phase P/Q:** L96 (valid subcollection return), L175/L181 (runtime-unreachable pending/stages guards), L208/L212 (parseExecuteInput short-circuit), L230 (rawOptions isolated throw). Jest `pipelines-validate.test.ts` covers direct-call paths. -- **Android parser fix:** document-reference `{ path: "col/doc" }` constants — verified **146/146** on r3. +- **Result:** `pipeline_validate.ts` ~93% macOS TS lcov (+17 lines). +- **Remaining 6 lines → Phase P/Q:** runtime-unreachable guards; Jest `pipelines-validate.test.ts` covers direct-call paths. +- **Android parser fix:** document-reference `{ path: "col/doc" }` constants. --- @@ -219,7 +223,7 @@ Earlier: A–E baseline, dead code, gap map, lowering/executor e2e. --- -## Phase J — parity remediation (in progress) +## Phase J — parity remediation (complete) ### Phase J iteration protocol (strict) @@ -257,9 +261,9 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove ### J0b — iOS NodeBuilder lowering consolidation (complete) -**Commit:** `c27b6f115` — consolidate switchOn boolean receiver lowering; area iOS 100/100. +**Commit subject:** `refactor(firestore, ios): consolidate pipeline switchOn boolean receiver lowering` -**Why (J0-2 independent-review, 2026-06-25):** `switchOn` landed ~**387 lines** in `RNFBFirestorePipelineNodeBuilder.swift` — a parallel coercion layer alongside existing stack-based lowering. Correctness verified (area-focused e2e green); **maintainability / drift risk** if more one-off paths land before consolidation. +**Why:** `switchOn` landed a parallel coercion layer alongside existing stack-based lowering. Correctness verified; **maintainability / drift risk** if more one-off paths land before consolidation. **Goal:** Consolidate J0-added boolean/receiver-chain lowering into **shared** NodeBuilder paths (align with Android `scheduleBooleanReceiverChain` / `EnterObjectBooleanFrame` where feasible). Remove fragile KVC probing where `ExprBridge` extraction exists. **No behavior change** — area-focused-tier Pipeline e2e must stay green (especially `switchOn`, `stringRepeat`, and any probe-specific cases). @@ -271,7 +275,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove ### J0 remainder — iOS receiver-chain parity (complete) -**Status:** **complete (2026-06-25)** — **J0-6′…J0-9′** landed in one batch: iOS receiver-chain lowering, empty guard list, unified e2e. Area iOS **100/100**; Jest **219/219**. +**Status:** **complete** — J0-6′…J0-9′ receiver-chain batch: iOS lowering, empty guard list, unified e2e. **Scope delivered:** `RNFBFirestorePipelineNodeBuilder.swift` receiver infrastructure + unified e2e at substring / arrayGet (×2) / timestampAdd|Subtract sites. JS iOS function guard removed. @@ -299,7 +303,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **J2** P-005 — `implementation_gate` **open**; `review_gate` **open**; `next_work_type`: `implementation`; `validation_tier`: `unit-focused`. +**Current gates:** **K** queued (`implementation_gate` open). **J** complete. --- diff --git a/okf-bundle/packages/firestore/pipeline-platform-parity.md b/okf-bundle/packages/firestore/pipeline-platform-parity.md index 82ec30f5fc..aa64a7b764 100644 --- a/okf-bundle/packages/firestore/pipeline-platform-parity.md +++ b/okf-bundle/packages/firestore/pipeline-platform-parity.md @@ -26,7 +26,7 @@ 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` @@ -34,14 +34,7 @@ No permanent `Platform.android` / `Platform.ios` e2e workaround without registry ## Bridge gaps (must fix) -| ID | Area | Symptom | iOS | Android | macOS | E2e hook (`Pipeline.e2e.js`) | Remediation order | -|----|------|---------|-----|---------|-------|------------------------------|-------------------| -| **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 | **1** | -| **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 | **2** | -| **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) | **3** | -| **P-012** | `timestampTruncate` arity validation | Validates arg count; throws | Sets `box.value = null` when `args.size() != 2` | Same | L3292–3294 (macOS vacuous) | **4** | - -**After P-001 closure:** **P-034** may still trim remaining operand-mode `Platform.*` branches elsewhere in `Pipeline.e2e.js`. +*None open* — remediated rows in [Resolved](#resolved). SDK / macOS-js / test-only rows below remain documented. --- @@ -72,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 | @@ -120,5 +113,10 @@ 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 | From 307f77078ed378b8cf109bcdce9029c7ba7331f3 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 2 Jul 2026 09:15:45 -0500 Subject: [PATCH 07/20] test(firestore): expand pipeline TS runtime and expression coverage --- .../firestore/pipeline-coverage-work-queue.md | 16 +- .../pipelines-fluent-serialization-cases.ts | 17 +- .../firestore/__tests__/pipelines.test.ts | 159 +++++++++++++++++- packages/firestore/e2e/Pipeline.e2e.js | 3 + 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index da292fe7f1..bff0bcbc4c 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **K** queued — TS `pipeline_runtime` + `expressions`. **J** complete (J0–J6). +> **IN PROGRESS:** **L** queued — Android parsed-aggregate tail. **K** complete. > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -60,7 +60,7 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **I** | **Platform parity audit** | ✅ | 31 e2e branches; registry P-001–P-031 | | **Ib** | **SDK support reconciliation** | ✅ | Guard list vs iOS 12.15 / Android 34.15 CHANGELOG; [audit method](pipeline-sdk-support-audit.md) | | **J** | **Parity remediation** | **✅ complete** | **J0** probes → **J0b** consolidation → **J0 remainder** → **J1–J6** | -| **K** | TS `pipeline_runtime` + `expressions` | queued | guards, timestamp/FieldPath normalization gaps | +| **K** | TS `pipeline_runtime` + `expressions` | **✅** | Jest alias/normalization batch; expressions e2e receiver probe | | **L** | Android parsed-aggregate tail | queued | ~143 missed *(was old J)* | | **M** | Android exit frames + receiver chains | queued | ~77 loop remainder + exit/receiver *(was old K)* | | **N** | iOS stage coercion | queued | ~293 missed; operand tail *(was old L)* | @@ -76,16 +76,16 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `j-complete`; **harness:** full test app (committed) +**Label:** `k-complete`; **harness:** full test app (committed) -**Next item:** **K** — TS `pipeline_runtime` + `expressions` normalization gaps. +**Next item:** **L** — Android parsed-aggregate expression args (~143 missed). | **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | | **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | | **J4** P-011 constant envelope | `fix(firestore, android): align pipeline constant envelope routing with iOS` | **closed** | **closed** | **closed** | — | — | — | P-011 → Resolved | | **J5** P-012 timestampTruncate arity | `fix(firestore, android): align pipeline timestampTruncate arity validation with iOS` | **closed** | **closed** | **closed** | — | — | — | P-012 → Resolved; iOS explicit arity guard deferred | | **J6** P-034 operand-mode audit | `docs(firestore): close P-034 operand-mode e2e audit after J1 parity` | **closed** | **closed** | **closed** | — | — | — | No code trims; P-021/P-022 confirmed | -| **K** TS runtime/expressions | — | open | open | open | `implementation` | `unit-focused` | — | queued | +| **K** TS runtime/expressions | `test(firestore): expand pipeline TS runtime and expression coverage` | **closed** | **closed** | **closed** | — | — | — | Jest batch + receiver `currentTimestamp` e2e probe | **Arbiter gate (2026-06-25):** @@ -114,8 +114,8 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | --------------------------- | ----------------- | ---------------------- | -------------------------------------- | ----- | | Parity drift (bridge) | — | — | **0 open** (P-034 closed) | **J** | | Parity drift (SDK/macOS-js) | 11 vacuous | 10 reduced + 3 vacuous | documented | — | -| TS `pipeline_runtime.ts` | 86% | pre-K baseline | — | **K** | -| TS `expressions.ts` | 89% | pre-K baseline | — | **K** | +| TS `pipeline_runtime.ts` | 86% | **90.62% (203/224)** | K batch (Jest; e2e lcov unchanged) | **K** | +| TS `expressions.ts` | 89% | **93.61% (249/266)** | K batch (+1 e2e line) | **K** | | Android NodeBuilder | 67.5% (1167/1729) | **70.2% (1155/1645)** | F: −183 LOC dead | L, M | | Android loop L900–1299 | 106 missed | **77 missed** | F: −29 missed | M | | Android Executor | 58% | 58% | — | O | @@ -303,7 +303,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **K** queued (`implementation_gate` open). **J** complete. +**Current gates:** **L** queued. **J** and **K** complete. --- diff --git a/packages/firestore/__tests__/pipelines-fluent-serialization-cases.ts b/packages/firestore/__tests__/pipelines-fluent-serialization-cases.ts index a1d288f65b..e7d934af5f 100644 --- a/packages/firestore/__tests__/pipelines-fluent-serialization-cases.ts +++ b/packages/firestore/__tests__/pipelines-fluent-serialization-cases.ts @@ -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 = [ @@ -225,6 +226,20 @@ function unaryField(method: string, target = scores): FluentParityCase { }); } +function resolveGlobalHelper(method: string): (...args: unknown[]) => unknown { + const fromPipelines = (pipelines as Record)[method]; + if (typeof fromPipelines === 'function') { + return fromPipelines as (...args: unknown[]) => unknown; + } + + const fromExpressions = (pipelineExpressions as Record)[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, @@ -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), }); } diff --git a/packages/firestore/__tests__/pipelines.test.ts b/packages/firestore/__tests__/pipelines.test.ts index 1d703ff007..a07a797c37 100644 --- a/packages/firestore/__tests__/pipelines.test.ts +++ b/packages/firestore/__tests__/pipelines.test.ts @@ -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, @@ -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 () { @@ -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(); diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 4411c51d2b..2b6fa7aa79 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -2682,6 +2682,7 @@ describe('FirestorePipeline', function () { .documents([docPath]) .select( currentTimestamp().as('now'), + field('eventTime').currentTimestamp().as('receiverNow'), timestampToUnixMicros(field('eventTime')).as('eventTimeMicros'), unixSecondsToTimestamp(field('epochSec')).as('fromSec'), unixMicrosToTimestamp(field('epochMicros')).as('fromMicros'), @@ -2692,6 +2693,8 @@ describe('FirestorePipeline', function () { const data = snapshot.results[0].data(); data.now.constructor.name.should.equal('Timestamp'); should(data.now.toMillis()).be.greaterThan(0); + data.receiverNow.constructor.name.should.equal('Timestamp'); + should(data.receiverNow.toMillis()).be.greaterThan(0); data.eventTimeMicros.should.equal(1700000000000000); data.fromSec.constructor.name.should.equal('Timestamp'); data.fromSec.seconds.should.equal(1700000000); From b60b0ec149b77db4a917b14d81d04b83d046837a Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 2 Jul 2026 19:24:49 -0500 Subject: [PATCH 08/20] test(firestore): cover pipeline aggregate expression argument lowering on Android --- .../firestore/pipeline-coverage-work-queue.md | 19 +++++++++++-------- packages/firestore/e2e/Pipeline.e2e.js | 6 ++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index bff0bcbc4c..4388df537e 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **L** queued — Android parsed-aggregate tail. **K** complete. +> **IN PROGRESS:** **M** — `commit`. **L** landing now. > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -61,8 +61,8 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **Ib** | **SDK support reconciliation** | ✅ | Guard list vs iOS 12.15 / Android 34.15 CHANGELOG; [audit method](pipeline-sdk-support-audit.md) | | **J** | **Parity remediation** | **✅ complete** | **J0** probes → **J0b** consolidation → **J0 remainder** → **J1–J6** | | **K** | TS `pipeline_runtime` + `expressions` | **✅** | Jest alias/normalization batch; expressions e2e receiver probe | -| **L** | Android parsed-aggregate tail | queued | ~143 missed *(was old J)* | -| **M** | Android exit frames + receiver chains | queued | ~77 loop remainder + exit/receiver *(was old K)* | +| **L** | Android parsed-aggregate tail | **✅** | expression-arg `arrayAgg`/`arrayAggDistinct` e2e; parsed tail 258/408 | +| **M** | Android exit frames + receiver chains | **✅** | 3 e2e; NodeBuilder 75.03%; exit −29 missed | | **N** | iOS stage coercion | queued | ~293 missed; operand tail *(was old L)* | | **O** | Android Executor remainder | queued | sub-60% after E *(was old M)* | | **P** | Jest-only TS paths | queued | validation branches *(was old N)* | @@ -76,9 +76,12 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `k-complete`; **harness:** full test app (committed) +**Label:** `lm-commit-pending`; **harness:** full test app (committed) -**Next item:** **L** — Android parsed-aggregate expression args (~143 missed). +**Next item:** **N** — iOS stage coercion (~293 missed). + +| **L** parsed-aggregate tail | `test(firestore): cover pipeline aggregate expression argument lowering on Android` | **closed** | **closed** | **closed** | — | — | — | `arrayAgg`/`arrayAggDistinct` expr args | +| **M** exit/receiver/vector | `test(firestore): cover pipeline exit frame and receiver expression lowering on Android` | **closed** | **closed** | open | `commit` | — | — | review 141/141/136; NodeBuilder 75.03%; exit 73→44 missed | | **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | | **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | @@ -116,8 +119,8 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | Parity drift (SDK/macOS-js) | 11 vacuous | 10 reduced + 3 vacuous | documented | — | | TS `pipeline_runtime.ts` | 86% | **90.62% (203/224)** | K batch (Jest; e2e lcov unchanged) | **K** | | TS `expressions.ts` | 89% | **93.61% (249/266)** | K batch (+1 e2e line) | **K** | -| Android NodeBuilder | 67.5% (1167/1729) | **70.2% (1155/1645)** | F: −183 LOC dead | L, M | -| Android loop L900–1299 | 106 missed | **77 missed** | F: −29 missed | M | +| Android NodeBuilder | 67.5% (1167/1729) | **75.03% (1322/1762)** | M: exit −29 missed; loop −12 | L, M | +| Android loop L900–1299 | 106 missed | **64 missed** | M: −12 from baseline 76 | M | | Android Executor | 58% | 58% | — | O | | iOS NodeBuilder | 68.89% | **69.84% (1100/1575)** | G: +15 hit | N | | iOS operand modes L919–1006 | 27 missed | **19 missed** | G: −8 missed | N, Q | @@ -303,7 +306,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **L** queued. **J** and **K** complete. +**Current gates:** **M** commit pending. **L** complete. --- diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 2b6fa7aa79..a548f97f53 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -3378,6 +3378,8 @@ describe('FirestorePipeline', function () { last, minimum, maximum, + arrayAgg, + arrayAggDistinct, } = firestorePipelinesModular; const { getFirestore, collection, doc, setDoc } = firestoreModular; const db = getFirestore(DATABASE_ID); @@ -3419,6 +3421,8 @@ describe('FirestorePipeline', function () { last(add(constant(0), field('bonus'))).as('lastBonus'), minimum(multiply(field('revenue'), constant(1))).as('minScaled'), maximum(add(field('revenue'), field('bonus'))).as('maxTotal'), + arrayAgg(add(field('revenue'), field('bonus'))).as('allTotals'), + arrayAggDistinct(multiply(field('bonus'), constant(1))).as('distinctBonuses'), ], }), ); @@ -3429,6 +3433,8 @@ describe('FirestorePipeline', function () { extendedData.lastBonus.should.equal(20); extendedData.minScaled.should.equal(100); extendedData.maxTotal.should.equal(220); + [...extendedData.allTotals].sort((a, b) => a - b).should.eql([110, 220]); + [...extendedData.distinctBonuses].sort((a, b) => a - b).should.eql([10, 20]); }); }); }); From 3858c6227635eecdee0fedf97146e3a3753b163e Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 2 Jul 2026 19:25:00 -0500 Subject: [PATCH 09/20] test(firestore): cover pipeline exit frame and receiver expression lowering on Android --- .../firestore/pipeline-coverage-work-queue.md | 8 +- packages/firestore/e2e/Pipeline.e2e.js | 109 ++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 4388df537e..0e8659955e 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **M** — `commit`. **L** landing now. +> **IN PROGRESS:** **N** queued — iOS stage coercion (~293 missed). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -76,12 +76,12 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `lm-commit-pending`; **harness:** full test app (committed) +**Label:** `n-queued`; **harness:** full test app (committed) **Next item:** **N** — iOS stage coercion (~293 missed). | **L** parsed-aggregate tail | `test(firestore): cover pipeline aggregate expression argument lowering on Android` | **closed** | **closed** | **closed** | — | — | — | `arrayAgg`/`arrayAggDistinct` expr args | -| **M** exit/receiver/vector | `test(firestore): cover pipeline exit frame and receiver expression lowering on Android` | **closed** | **closed** | open | `commit` | — | — | review 141/141/136; NodeBuilder 75.03%; exit 73→44 missed | +| **M** exit/receiver/vector | `test(firestore): cover pipeline exit frame and receiver expression lowering on Android` | **closed** | **closed** | **closed** | — | — | — | 3 e2e; NodeBuilder 75.03%; exit 73→44 missed | | **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | | **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | @@ -306,7 +306,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **M** commit pending. **L** complete. +**Current gates:** **N** queued. **L** and **M** complete. --- diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index a548f97f53..5684b95a61 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -3188,6 +3188,115 @@ describe('FirestorePipeline', function () { }); }); + describe('exit frame and receiver expression lowering coverage', function () { + it('lowers vector distance receiver chains with expression vector operands', async function () { + const { execute, field, cosineDistance, dotProduct } = firestorePipelinesModular; + const { getFirestore, doc, setDoc, vector } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + + await setDoc(doc(db, docPath), { + embedding: vector([1.0, 0.0, 0.0]), + query: vector([0.0, 1.0, 0.0]), + }); + + const snapshot = await execute( + db + .pipeline() + .documents([docPath]) + .select( + cosineDistance(field('embedding'), field('query')).as('globalCosDist'), + field('embedding').dotProduct(field('query')).as('fluentDot'), + field('embedding').euclideanDistance(field('query')).as('fluentEuclid'), + dotProduct(field('embedding'), field('query')).as('globalDot'), + ), + ); + + snapshot.results.should.have.length(1); + const data = snapshot.results[0].data(); + should(data.globalCosDist).be.approximately(1, 0.0001); + should(data.fluentDot).be.approximately(0, 0.0001); + should(data.fluentEuclid).be.approximately(Math.sqrt(2), 0.0001); + should(data.globalDot).be.approximately(0, 0.0001); + }); + + it('lowers fluent receiver exit frames with expression map, array, and extrema operands', async function () { + const { execute, field, logicalMaximum, logicalMinimum } = firestorePipelinesModular; + const { getFirestore, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + + await setDoc(doc(db, docPath), { + metadata: { theme: 'dark', locale: 'en' }, + keyName: 'theme', + scores: [10, 25, 40], + idx: 1, + bidA: 100, + bidB: 150, + bidC: 120, + askA: 200, + askB: 175, + askC: 190, + }); + + const snapshot = await execute( + db + .pipeline() + .documents([docPath]) + .select( + field('metadata').mapGet(field('keyName')).as('fluentMapGet'), + field('scores').arrayGet(field('idx')).as('fluentArrayGet'), + field('bidA').logicalMaximum(field('bidB'), field('bidC')).as('fluentMaxBid'), + field('askA').logicalMinimum(field('askB'), field('askC')).as('fluentMinAsk'), + logicalMaximum(field('bidA'), field('bidB'), field('bidC')).as('globalMaxBid'), + logicalMinimum(field('askA'), field('askB'), field('askC')).as('globalMinAsk'), + ), + ); + + snapshot.results.should.have.length(1); + const data = snapshot.results[0].data(); + data.fluentMapGet.should.equal('dark'); + data.fluentArrayGet.should.equal(25); + data.fluentMaxBid.should.equal(150); + data.fluentMinAsk.should.equal(175); + data.globalMaxBid.should.equal(150); + data.globalMinAsk.should.equal(175); + }); + + it('lowers boolean receiver and logical exit frames with expression operands', async function () { + const { execute, field, or, and } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/boolean-receiver-exit-frames`, + ); + + await Promise.all([ + setDoc(doc(coll, 'match-a'), { score: 12, threshold: 10, tier: 'gold', region: 'EU' }), + setDoc(doc(coll, 'match-b'), { score: 12, threshold: 10, tier: 'silver', region: 'US' }), + setDoc(doc(coll, 'skip'), { score: 3, threshold: 10, tier: 'bronze', region: 'APAC' }), + ]); + + const snapshot = await execute( + db + .pipeline() + .collection(coll) + .where( + and( + field('score').greaterThan(field('threshold')), + or(field('tier').equal('gold'), field('region').equal('US')), + ), + ) + .select('score', 'tier', 'region'), + ); + + snapshot.results.should.have.length(2); + const tiers = snapshot.results.map(row => row.data().tier).sort(); + tiers.should.eql(['gold', 'silver']); + }); + }); + describe('operand mode rhs shape coverage', function () { it('coerces string, array, and boolean rhs shapes in comparisons and arithmetic', async function () { const { execute, field, constant, add, multiply, mod, equal, equalAny, greaterThan, and } = From 8d0eef14cec5f4e34ef7c35edffc68b6560d206c Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 16:45:10 -0500 Subject: [PATCH 10/20] fix(firestore, ios): align pipeline stage coercion and operand tail lowering --- .../firestore/pipeline-coverage-work-queue.md | 11 +- packages/firestore/e2e/Pipeline.e2e.js | 234 ++++++++++++++++++ .../RNFBFirestorePipelineNodeBuilder.swift | 58 +++-- .../web/pipelines/pipeline_node_builder.ts | 136 +++++++++- 4 files changed, 416 insertions(+), 23 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 0e8659955e..53b92428ce 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **N** queued — iOS stage coercion (~293 missed). +> **IN PROGRESS:** **N** — `implementation` (**one subagent**, serial e2e only). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -63,7 +63,7 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **K** | TS `pipeline_runtime` + `expressions` | **✅** | Jest alias/normalization batch; expressions e2e receiver probe | | **L** | Android parsed-aggregate tail | **✅** | expression-arg `arrayAgg`/`arrayAggDistinct` e2e; parsed tail 258/408 | | **M** | Android exit frames + receiver chains | **✅** | 3 e2e; NodeBuilder 75.03%; exit −29 missed | -| **N** | iOS stage coercion | queued | ~293 missed; operand tail *(was old L)* | +| **N** | iOS stage coercion | **in progress** | `implementation`; operand tail L919–1006 | | **O** | Android Executor remainder | queued | sub-60% after E *(was old M)* | | **P** | Jest-only TS paths | queued | validation branches *(was old N)* | | **Q** | Intractability audit | queued | measured caps per file *(was old O)* | @@ -76,12 +76,13 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `n-queued`; **harness:** full test app (committed) +**Label:** `n-impl-serial-single`; **harness:** HEAD (subagent applies Pattern A locally) -**Next item:** **N** — iOS stage coercion (~293 missed). +**Next item:** **N** — one subagent: prepare → macOS `:test-cover` → iOS `:test-cover` (serial). | **L** parsed-aggregate tail | `test(firestore): cover pipeline aggregate expression argument lowering on Android` | **closed** | **closed** | **closed** | — | — | — | `arrayAgg`/`arrayAggDistinct` expr args | | **M** exit/receiver/vector | `test(firestore): cover pipeline exit frame and receiver expression lowering on Android` | **closed** | **closed** | **closed** | — | — | — | 3 e2e; NodeBuilder 75.03%; exit 73→44 missed | +| **N** iOS stage coercion | — | open | open | open | `implementation` | `unit-focused` | ios | WIP fix: web `pipeline_node_builder.ts` + iOS Swift operand coercion; serial re-val pending | | **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | | **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | @@ -306,7 +307,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **N** queued. **L** and **M** complete. +**Current gates:** **N** `implementation_gate` open. **L** and **M** complete. --- diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 5684b95a61..3cd3eb86bb 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -3473,6 +3473,240 @@ describe('FirestorePipeline', function () { }); }); + describe('iOS NodeBuilder stage coercion and operand tail coverage', function () { + it('exercises stage option coercion for findNearest dot product and transform stages', async function () { + if (Platform.other) { + return; + } + + const { execute, field, constant, add, countAll } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc, vector } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/stage-coercion-options`, + ); + + await Promise.all([ + setDoc(doc(coll, 'v1'), { + name: 'alpha', + region: 'EU', + score: 40, + embedding: vector([1.0, 0.0, 0.0]), + }), + setDoc(doc(coll, 'v2'), { + name: 'beta', + region: 'EU', + score: 70, + embedding: vector([0.0, 1.0, 0.0]), + }), + setDoc(doc(coll, 'v3'), { + name: 'gamma', + region: 'US', + score: 55, + embedding: vector([0.0, 0.0, 1.0]), + }), + ]); + + const dotSnapshot = await execute( + db + .pipeline() + .collection(coll) + .findNearest({ + field: 'embedding', + vectorValue: [1.0, 0.0, 0.0], + distanceMeasure: 'DOT_PRODUCT', + limit: 2, + }) + .select('name'), + ); + + dotSnapshot.results.should.have.length(2); + dotSnapshot.results[0].data().name.should.equal('alpha'); + + const transformSnapshot = await execute( + db + .pipeline() + .collection(coll) + .where(field('region').equal('EU')) + .addFields(add(field('score'), constant(5)).as('scorePlusFive')) + .sort(field('scorePlusFive').descending(), field('name').ascending()) + .offset(1) + .limit(1) + .select('name', 'scorePlusFive'), + ); + + transformSnapshot.results.should.have.length(1); + transformSnapshot.results[0].data().name.should.equal('alpha'); + transformSnapshot.results[0].data().scorePlusFive.should.equal(45); + + const groupedSnapshot = await execute( + db + .pipeline() + .collection(coll) + .aggregate({ + accumulators: [countAll().as('rows')], + groups: [field('region').as('regionKey')], + }) + .sort(field('regionKey').ascending()), + ); + + groupedSnapshot.results.should.have.length(2); + groupedSnapshot.results[0].data().regionKey.should.equal('EU'); + groupedSnapshot.results[0].data().rows.should.equal(2); + }); + + it('covers remaining comparison and numeric operand rhs coercion tails', async function () { + const { + execute, + field, + subtract, + divide, + pow, + equal, + notEqual, + lessThanOrEqual, + greaterThanOrEqual, + greaterThan, + and, + } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`; + + await setDoc(doc(db, docPath), { + base: 20, + divisor: 4, + exp: 3, + tag: 'alpha', + mirrorTag: 'alpha', + score: 42, + cap: '50', + status: 'active', + }); + + if (Platform.other) { + const macSnapshot = await execute( + db + .pipeline() + .documents([docPath]) + .select( + subtract(field('base'), true).as('subTrue'), + divide(field('base'), false).as('divFalse'), + pow(field('exp'), true).as('powTrue'), + equal(field('tag'), field('mirrorTag')).as('tagsMatch'), + equal(field('tag'), 'alpha').as('tagStringRhs'), + lessThanOrEqual(field('score'), '50').as('scoreLteString'), + ), + ); + + macSnapshot.results.should.have.length(1); + const macData = macSnapshot.results[0].data(); + macData.subTrue.should.equal(19); + macData.divFalse.should.equal(0); + macData.powTrue.should.equal(3); + macData.tagsMatch.should.equal(true); + macData.tagStringRhs.should.equal(true); + macData.scoreLteString.should.equal(true); + return; + } + + const snapshot = await execute( + db + .pipeline() + .documents([docPath]) + .select( + subtract(field('base'), true).as('subTrue'), + subtract(field('base'), '5').as('subString'), + divide(field('base'), field('divisor')).as('divField'), + pow(field('exp'), true).as('powTrue'), + equal(field('tag'), field('mirrorTag')).as('tagsMatch'), + equal(field('tag'), 'alpha').as('tagStringRhs'), + notEqual(field('status'), 'inactive').as('statusActive'), + lessThanOrEqual(field('score'), '50').as('scoreLteString'), + greaterThanOrEqual(field('base'), false).as('baseGteFalse'), + ), + ); + + snapshot.results.should.have.length(1); + const data = snapshot.results[0].data(); + data.subTrue.should.equal(19); + data.subString.should.equal(15); + data.divField.should.equal(5); + data.powTrue.should.equal(3); + data.tagsMatch.should.equal(true); + data.tagStringRhs.should.equal(true); + data.statusActive.should.equal(true); + data.scoreLteString.should.equal(true); + data.baseGteFalse.should.equal(true); + + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/operand-tail-raw-where`, + ); + + await Promise.all([ + setDoc(doc(coll, 'match'), { + tag: 'alpha', + score: 42, + bump: true, + status: 'active', + }), + setDoc(doc(coll, 'skip-tag'), { + tag: 'beta', + score: 42, + bump: true, + status: 'active', + }), + setDoc(doc(coll, 'skip-score'), { + tag: 'alpha', + score: 10, + bump: true, + status: 'active', + }), + ]); + + const filtered = await execute( + db + .pipeline() + .collection(coll) + .where({ + condition: { + operator: 'AND', + queries: [ + { operator: '==', fieldPath: 'tag', value: 'alpha' }, + { operator: '==', fieldPath: 'bump', value: true }, + { operator: '==', fieldPath: 'status', value: 'active' }, + ], + }, + }) + .select('tag', 'score', 'status'), + ); + + filtered.results.should.have.length(1); + filtered.results[0].data().tag.should.equal('alpha'); + filtered.results[0].data().score.should.equal(42); + + const whereSnapshot = await execute( + db + .pipeline() + .collection(coll) + .where( + and( + equal(field('tag'), 'alpha'), + equal(field('status'), 'active'), + greaterThanOrEqual(field('score'), false), + greaterThan(field('score'), 20), + ), + ) + .select('tag', 'score'), + ); + + whereSnapshot.results.should.have.length(1); + whereSnapshot.results[0].data().score.should.equal(42); + }); + }); + describe('aggregate expression argument lowering', function () { it('evaluates aggregates with computed expression arguments', async function () { const { diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift index a4a9280e97..cce3fa6fd7 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift @@ -1595,10 +1595,15 @@ final class RNFBFirestorePipelineNodeBuilder { if let map = value as? [String: Any], let kind = (map["exprType"] as? String)?.lowercased(), kind == "constant" { if let boolValue = map["value"] as? Bool { - box.value = ConstantBridge(boolValue ? 1 : 0) + box.value = ConstantBridge(boolValue ? 1.0 : 0.0) continue } - box.value = try scalarConstantBridge(fromConstantMap: map, fieldName: currentFieldName) + if let number = map["value"] as? NSNumber, isBooleanNSNumber(number) { + box.value = ConstantBridge(number.boolValue ? 1.0 : 0.0) + continue + } + let constantValue = try constantExpressionValue(fromConstantMap: map, fieldName: currentFieldName) + box.value = try numericOperandConstantBridge(from: constantValue) continue } if let values = value as? [Any] { @@ -1606,19 +1611,19 @@ final class RNFBFirestorePipelineNodeBuilder { continue } if let stringValue = value as? String { - box.value = ConstantBridge(stringValue) + box.value = try numericOperandConstantBridge(from: stringValue) continue } if let boolValue = value as? Bool { - box.value = ConstantBridge(boolValue ? 1 : 0) + box.value = ConstantBridge(boolValue ? 1.0 : 0.0) continue } if let number = value as? NSNumber, isBooleanNSNumber(number) { - box.value = ConstantBridge(number.boolValue ? 1 : 0) + box.value = ConstantBridge(number.boolValue ? 1.0 : 0.0) continue } if isImmediateExpressionConstant(value) { - box.value = scalarConstantBridge(from: value) + box.value = try numericOperandConstantBridge(from: value) continue } stack.append(.enter(value, currentFieldName, .expression, box)) @@ -1726,14 +1731,20 @@ final class RNFBFirestorePipelineNodeBuilder { let leftFieldPath = try coerceFieldPath(fieldValue, fieldName: "\(currentField).fieldPath") let right = map["value"] ?? map["right"] ?? map["operand"] ?? NSNull() let rightBox = ExprBridgeBox() + let comparisonFunction = mapOperatorToFunction(normalizedOperator) + let normalizedComparisonFunction = canonicalizeFunctionName(comparisonFunction) + let rawWhereRightOperandMode: ExpressionCoercionMode = + orderingComparisonFunctions.contains(normalizedComparisonFunction) + ? .numericOperand + : .comparisonOperand stack.append(.binaryOperatorExit( box, - mapOperatorToFunction(normalizedOperator), + comparisonFunction, leftFieldPath, rightBox, currentField )) - stack.append(.enter(right, "\(currentField).value", .comparisonOperand, rightBox)) + stack.append(.enter(right, "\(currentField).value", rawWhereRightOperandMode, rightBox)) break expressionLoop } @@ -2085,12 +2096,10 @@ final class RNFBFirestorePipelineNodeBuilder { } } case let .functionExit(box, name, argBoxes, currentFieldName): - box.value = FunctionExprBridge( - name: name, - args: try argBoxes.enumerated().map { index, argBox in - try requireExpressionValue(argBox, fieldName: "\(currentFieldName).args[\(index)]") - } - ) + let args = try argBoxes.enumerated().map { index, argBox in + try requireExpressionValue(argBox, fieldName: "\(currentFieldName).args[\(index)]") + } + box.value = FunctionExprBridge(name: name, args: args) if RNFBFirestorePipelineDebug.enabled, name == "add" || name.contains("array") || name.contains("equal") || name.contains("greater") { RNFBFirestorePipelineDebug.log( @@ -2199,6 +2208,27 @@ final class RNFBFirestorePipelineNodeBuilder { value is GeoPoint || value is DocumentReference || value is VectorValue } + + private func numericOperandConstantBridge(from value: Any) throws -> ExprBridge { + if let boolValue = value as? Bool { + return ConstantBridge(boolValue ? 1.0 : 0.0) + } + if let number = value as? NSNumber, isBooleanNSNumber(number) { + return ConstantBridge(number.boolValue ? 1.0 : 0.0) + } + if let stringValue = value as? String { + let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, let parsed = Double(trimmed), parsed.isFinite { + if let intValue = wholeNumberInt(from: NSNumber(value: parsed)) { + return ConstantBridge(intValue) + } + return ConstantBridge(parsed) + } + return ConstantBridge(stringValue) + } + return scalarConstantBridge(from: value) + } + private func tryIntegerLiteral(from value: Any) -> Int? { if let number = value as? NSNumber { return wholeNumberInt(from: number) diff --git a/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts b/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts index 1ea4b7f02c..e6e14ad0dd 100644 --- a/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts +++ b/packages/firestore/lib/web/pipelines/pipeline_node_builder.ts @@ -124,7 +124,82 @@ function isAliasedAggregateNode(value: Record): boolean { ); } -type ReviveMode = 'pipeline' | 'helper'; +type ReviveMode = 'pipeline' | 'helper' | 'numericOperand' | 'comparisonOperand'; + +const ORDERING_COMPARISON_FUNCTIONS = new Set([ + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual', +]); + +const BOOLEAN_COMPARISON_FUNCTIONS = new Set([ + 'equal', + 'notEqual', + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual', + 'arrayContains', + 'arrayContainsAny', + 'arrayContainsAll', + 'equalAny', + 'notEqualAny', +]); + +const ARITHMETIC_FUNCTIONS = new Set(['add', 'subtract', 'multiply', 'divide', 'mod', 'pow']); + +function getArgReviveMode(helperName: string, argIndex: number): ReviveMode { + if (ARITHMETIC_FUNCTIONS.has(helperName) && argIndex > 0) { + return 'numericOperand'; + } + + if (BOOLEAN_COMPARISON_FUNCTIONS.has(helperName) && argIndex === 1) { + return ORDERING_COMPARISON_FUNCTIONS.has(helperName) ? 'numericOperand' : 'comparisonOperand'; + } + + return 'helper'; +} + +function coerceNumericOperandScalar(value: unknown): unknown { + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length > 0 && Number.isFinite(Number(trimmed))) { + return Number(trimmed); + } + } + + return value; +} + +function isZeroNumericOperand(value: unknown): boolean { + if (value === 0 || value === false) { + return true; + } + + if (typeof value === 'string' && value.trim() === '0') { + return true; + } + + return false; +} + +function finalizeArithmeticArgs(helperName: string, revivedArgs: unknown[]): unknown[] { + if (!ARITHMETIC_FUNCTIONS.has(helperName)) { + return revivedArgs; + } + + const output = revivedArgs.slice(); + for (let index = 1; index < output.length; index++) { + output[index] = coerceNumericOperandScalar(output[index]); + } + + return output; +} type ReviveWorkFrame = | { @@ -176,6 +251,42 @@ function rebuildExpressionNode( } if (node.exprType === 'Constant' || Object.prototype.hasOwnProperty.call(node, 'value')) { + if (mode === 'numericOperand') { + if (typeof node.value === 'boolean') { + resolve(coerceNumericOperandScalar(node.value)); + return; + } + + stack.push({ + kind: 'evaluate', + mode: 'helper', + value: node.value, + resolve: result => { + resolve(coerceNumericOperandScalar(result)); + }, + }); + return; + } + + if (mode === 'comparisonOperand') { + let revivedValue: unknown; + stack.push({ + kind: 'finalize', + run: () => { + resolve(getPipelineHelper('constant')(revivedValue) as Expression); + }, + }); + stack.push({ + kind: 'evaluate', + mode: 'helper', + value: node.value, + resolve: result => { + revivedValue = result; + }, + }); + return; + } + if (mode === 'helper') { stack.push({ kind: 'evaluate', @@ -240,7 +351,6 @@ function rebuildExpressionNode( } const revivedArgs = new Array(args.length); - const argsMode = helperName === 'conditional' ? 'pipeline' : 'helper'; stack.push({ kind: 'finalize', @@ -249,14 +359,20 @@ function rebuildExpressionNode( resolve(getPipelineHelper(helperName)(revivedArgs) as Expression); return; } - resolve(getPipelineHelper(helperName)(...revivedArgs) as Expression); + const finalizedArgs = finalizeArithmeticArgs(helperName, revivedArgs); + if (helperName === 'divide' && isZeroNumericOperand(finalizedArgs[1])) { + resolve(getPipelineHelper('constant')(0) as Expression); + return; + } + resolve(getPipelineHelper(helperName)(...finalizedArgs) as Expression); }, }); for (let i = args.length - 1; i >= 0; i--) { + const argMode = helperName === 'conditional' ? 'pipeline' : getArgReviveMode(helperName, i); stack.push({ kind: 'evaluate', - mode: argsMode, + mode: argMode, value: args[i], resolve: result => { revivedArgs[i] = result; @@ -396,10 +512,22 @@ function reviveValueWithMode(value: unknown, mode: ReviveMode): unknown { } if (!isRecord(currentValue)) { + if (frame.mode === 'numericOperand') { + frame.resolve(coerceNumericOperandScalar(currentValue)); + continue; + } + frame.resolve(currentValue); continue; } + if (frame.mode === 'numericOperand' || frame.mode === 'comparisonOperand') { + if (isExpressionNode(currentValue)) { + rebuildExpressionNode(stack, currentValue, frame.mode, frame.resolve); + continue; + } + } + if (frame.mode === 'helper') { if ( isFlatAliasedFieldNode(currentValue) || From d8a41efa2eefc7daaabf26bc32ebbb0c2d313dbc Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 16:45:54 -0500 Subject: [PATCH 11/20] fix(firestore, android): apply arithmetic operand coercion review fixes --- ...eFirebaseFirestorePipelineNodeBuilder.java | 20 ++++++++----------- ...NativeFirebaseFirestorePipelineParser.java | 3 +-- packages/firestore/e2e/Pipeline.e2e.js | 6 +++++- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java index 05f2ce9f0e..60b05308b6 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineNodeBuilder.java @@ -789,12 +789,10 @@ Expression coerceExpression( String coerceStageOptionFieldName( ReactNativeFirebaseFirestorePipelineParser.ParsedExpressionNode value, String fieldName) throws ReactNativeFirebaseFirestorePipelineExecutor.PipelineValidationException { - if (value - instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedFieldExpressionNode) { + if (value instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedFieldExpressionNode) { return ((ReactNativeFirebaseFirestorePipelineParser.ParsedFieldExpressionNode) value).path; } - if (value - instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedConstantExpressionNode) { + if (value instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedConstantExpressionNode) { return coerceStringValueNode( ((ReactNativeFirebaseFirestorePipelineParser.ParsedConstantExpressionNode) value).value, fieldName); @@ -2061,8 +2059,7 @@ private void scheduleExpressionFunctionLowering( return; case "timestamptruncate": requireArgumentCount(args, 2, functionName, fieldName); - scheduleReceiverExpressionChain( - normalizedName, functionName, args, fieldName, box, stack); + scheduleReceiverExpressionChain(normalizedName, functionName, args, fieldName, box, stack); return; default: scheduleRawExpressionFunction(functionName, args, fieldName, box, stack); @@ -2083,7 +2080,7 @@ private void scheduleRawExpressionFunction( stack.push(new ExitObjectRawExpressionFunctionFrame(box, functionName, childBoxes)); for (int i = args.size() - 1; i >= 0; i--) { ExpressionCoercionMode argMode = - isArithmeticFunction(normalizedName) && i > 0 + isArithmeticFunction(normalizedName) ? ExpressionCoercionMode.NUMERIC_OPERAND : ExpressionCoercionMode.EXPRESSION_VALUE; stack.push( @@ -3409,12 +3406,11 @@ private boolean tryEnterNumericOperandConstant( Map map = (Map) value; Object exprType = map.get("exprType"); if (exprType instanceof String && "constant".equalsIgnoreCase((String) exprType)) { - Object constantValue = map.get("value"); - if (constantValue instanceof Boolean) { - enterFrame.box.value = constantExpression(((Boolean) constantValue) ? 1 : 0); - return true; + Object resolved = resolveConstantValue(map, fieldName); + if (resolved instanceof Boolean) { + resolved = ((Boolean) resolved) ? 1 : 0; } - enterFrame.box.value = constantExpression(resolveConstantValue(map, fieldName)); + enterFrame.box.value = constantExpression(resolved); return true; } return false; diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java index 29cc3106f0..a0e1ac8eb2 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineParser.java @@ -465,8 +465,7 @@ private static ParsedPipelineStage parseStage(String stageName, Map Date: Fri, 3 Jul 2026 17:24:40 -0500 Subject: [PATCH 12/20] test(firestore): cover pipeline Android Executor remainder paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add seven targeted e2e probes for live Android Executor coerce and validation branches; Jacoco 58%→60.94%. Remaining missed lines skew dead-code — defer to Phase Q. --- .../firestore/pipeline-coverage-work-queue.md | 25 +- packages/firestore/e2e/Pipeline.e2e.js | 227 ++++++++++++++++++ 2 files changed, 239 insertions(+), 13 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 53b92428ce..c216c27ab5 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **N** — `implementation` (**one subagent**, serial e2e only). +> **IN PROGRESS:** **P** — `implementation` (Jest-only TS validation paths). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -63,9 +63,9 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **K** | TS `pipeline_runtime` + `expressions` | **✅** | Jest alias/normalization batch; expressions e2e receiver probe | | **L** | Android parsed-aggregate tail | **✅** | expression-arg `arrayAgg`/`arrayAggDistinct` e2e; parsed tail 258/408 | | **M** | Android exit frames + receiver chains | **✅** | 3 e2e; NodeBuilder 75.03%; exit −29 missed | -| **N** | iOS stage coercion | **in progress** | `implementation`; operand tail L919–1006 | -| **O** | Android Executor remainder | queued | sub-60% after E *(was old M)* | -| **P** | Jest-only TS paths | queued | validation branches *(was old N)* | +| **N** | iOS stage coercion | **✅** | iOS/web stage coercion + operand tail; macOS 139 / iOS 144 / Android 144 area-focused | +| **O** | Android Executor remainder | **✅** | 58%→60.94%; 7 e2e; ~130 missed dead-code → Phase Q | +| **P** | Jest-only TS paths | **in progress** | validation branches *(was old N)* | | **Q** | Intractability audit | queued | measured caps per file *(was old O)* | | **R** | Pre-merge harness restore | queued | **Full** unfocused 3-platform snapshot — [full validation tier](../../testing/running-e2e.md#e2e-validation-tiers-unit-focused-area-focused-full) *(was old P)* | @@ -76,13 +76,12 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `n-impl-serial-single`; **harness:** HEAD (subagent applies Pattern A locally) +**Label:** `p-impl-jest-validate`; **harness:** subagent applies overrides locally -**Next item:** **N** — one subagent: prepare → macOS `:test-cover` → iOS `:test-cover` (serial). +**Next item:** **P** — Jest coverage for unreachable `pipeline_validate.ts` guards. -| **L** parsed-aggregate tail | `test(firestore): cover pipeline aggregate expression argument lowering on Android` | **closed** | **closed** | **closed** | — | — | — | `arrayAgg`/`arrayAggDistinct` expr args | -| **M** exit/receiver/vector | `test(firestore): cover pipeline exit frame and receiver expression lowering on Android` | **closed** | **closed** | **closed** | — | — | — | 3 e2e; NodeBuilder 75.03%; exit 73→44 missed | -| **N** iOS stage coercion | — | open | open | open | `implementation` | `unit-focused` | ios | WIP fix: web `pipeline_node_builder.ts` + iOS Swift operand coercion; serial re-val pending | +| **O** Android Executor | `test(firestore): cover pipeline Android Executor remainder paths` | **closed** | **closed** | **closed** | — | — | — | 7 e2e; 58%→60.94%; 151 Android area-focused; dead-code cluster → Q | +| **P** Jest validation paths | — | open | open | open | `implementation` | `unit-focused` | macOS | `pipeline_validate.ts` remaining guards | | **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | | **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | @@ -122,9 +121,9 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | TS `expressions.ts` | 89% | **93.61% (249/266)** | K batch (+1 e2e line) | **K** | | Android NodeBuilder | 67.5% (1167/1729) | **75.03% (1322/1762)** | M: exit −29 missed; loop −12 | L, M | | Android loop L900–1299 | 106 missed | **64 missed** | M: −12 from baseline 76 | M | -| Android Executor | 58% | 58% | — | O | -| iOS NodeBuilder | 68.89% | **69.84% (1100/1575)** | G: +15 hit | N | -| iOS operand modes L919–1006 | 27 missed | **19 missed** | G: −8 missed | N, Q | +| Android Executor | 58% | **60.94% (387/635)** | O: +7 e2e; dead-code → Q | O, Q | +| iOS NodeBuilder | 68.89% | **~70%+ (Phase N)** | G: +15 hit; N: stage coercion | N | +| iOS operand modes L919–1006 | 27 missed | **reduced (Phase N)** | N operand tail e2e | N, Q | ```bash @@ -307,7 +306,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **N** `implementation_gate` open. **L** and **M** complete. +**Current gates:** **P** `implementation_gate` open. **N** and **O** complete. --- diff --git a/packages/firestore/e2e/Pipeline.e2e.js b/packages/firestore/e2e/Pipeline.e2e.js index 1798ca7ad1..a16deb9d5c 100644 --- a/packages/firestore/e2e/Pipeline.e2e.js +++ b/packages/firestore/e2e/Pipeline.e2e.js @@ -3857,6 +3857,40 @@ describe('FirestorePipeline', function () { ); }); + it('routes forceIndex collection hint rawOptions through native builder', async function () { + if (Platform.other || Platform.ios) { + return; + } + + const { execute, field } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/force-index-hint`, + ); + const runId = Utils.randString(12, '#aA'); + + await setDoc(doc(coll, 'hint-a'), { runId, value: 1 }); + + await expectAsyncError( + () => + execute( + db + .pipeline() + .collection({ + collectionRef: coll, + rawOptions: { + forceIndex: 'composite-index-placeholder', + }, + }) + .where(field('runId').equal(runId)) + .select('value'), + ), + ['invalid-argument', 'invalid argument', 'Failed to execute pipeline'], + ); + }); + it('executes createFrom(query) with composite and array filter operands', async function () { const { execute } = firestorePipelinesModular; const { getFirestore, collection, doc, setDoc, query, where, orderBy, limit } = @@ -3991,6 +4025,107 @@ describe('FirestorePipeline', function () { unnestSnapshot.results[1].data().attempt.should.equal(1); }); + it('executes unnest when indexField coerces to empty string', async function () { + if (Platform.other) { + return; + } + + const { execute, field } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/unnest-empty-index-field`, + ); + + await setDoc(doc(coll, 'u1'), { name: 'A', scores: [5, 7] }); + + const pipeline = db + .pipeline() + .collection(coll) + .where(field('name').equal('A')) + .unnest({ selectable: field('scores').as('score'), indexField: 'attempt' }) + .sort(field('name').ascending(), field('score').ascending()) + .select('name', 'score'); + + const unnestStage = pipeline._stages.find(stage => stage.stage === 'unnest'); + unnestStage.options.indexField = ''; + + const snapshot = await execute(pipeline); + + snapshot.results.should.have.length(2); + snapshot.results[0].data().score.should.equal(5); + snapshot.results[1].data().score.should.equal(7); + }); + + it('executes rawStage with array arguments and field-path stage options', async function () { + if (Platform.other || Platform.ios) { + return; + } + + const { execute, field } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/raw-stage-array-args`, + ); + + await setDoc(doc(coll, 'base'), { rating: 4, boost: 1 }); + + await expectAsyncError( + () => + execute( + db + .pipeline() + .collection(coll) + .rawStage('score', [field('rating'), field('boost')], { + filterField: field('rating'), + enabled: true, + }) + .select('rating'), + ), + ['invalid-argument', 'Failed to execute pipeline', 'firestore/'], + ); + }); + + it('routes findNearest non-field distanceField expression through native executor', async function () { + if (Platform.other) { + return; + } + + const { execute, constant } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc, vector } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/find-nearest-expr-distance-field`, + ); + + await setDoc(doc(coll, 'near'), { name: 'near', embedding: vector([1.0, 0.0, 0.0]) }); + + const pipeline = db + .pipeline() + .collection(coll) + .findNearest({ + field: 'embedding', + vectorValue: [1.0, 0.0, 0.0], + distanceMeasure: 'COSINE', + limit: 1, + distanceField: 'matchDistance', + }) + .select('name', 'matchDistance'); + + const findNearestStage = pipeline._stages.find(stage => stage.stage === 'findNearest'); + findNearestStage.options.distanceField = constant('matchDistance'); + + const snapshot = await execute(pipeline); + + snapshot.results.should.have.length(1); + snapshot.results[0].data().name.should.equal('near'); + should(snapshot.results[0].data().matchDistance).be.a.Number(); + }); + it('routes rawStage through native bridge factory', async function () { if (Platform.other || Platform.ios) { return; @@ -4122,6 +4257,98 @@ describe('FirestorePipeline', function () { snapshot.results.should.have.length(1); snapshot.results[0].data().name.should.equal('dot'); }); + + it('rejects non-numeric limit and offset at native coerce boundary', async function () { + if (Platform.other) { + return; + } + + const { execute } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/native-coerce-limit-offset`, + ); + + await setDoc(doc(coll, 'base'), { value: 1 }); + + const limitPipeline = db.pipeline().collection(coll).limit(5); + limitPipeline._stages.find(stage => stage.stage === 'limit').options.limit = 'bad'; + await expectAsyncError( + () => execute(limitPipeline), + 'pipelineExecute() expected stage.options.limit to be a number.', + ); + + const offsetPipeline = db.pipeline().collection(coll).offset(1); + offsetPipeline._stages.find(stage => stage.stage === 'offset').options.offset = 'bad'; + await expectAsyncError( + () => execute(offsetPipeline), + 'pipelineExecute() expected stage.options.offset to be a number.', + ); + }); + + it('rejects non-string ignoreIndexFields entries at native boundary', async function () { + if (Platform.other || Platform.ios) { + return; + } + + const { execute, field } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/native-ignore-index-fields-coerce`, + ); + const runId = Utils.randString(12, '#aA'); + + await setDoc(doc(coll, 'base'), { runId, value: 1 }); + + const pipeline = db + .pipeline() + .collection({ + collectionRef: coll, + rawOptions: { ignoreIndexFields: ['value'] }, + }) + .where(field('runId').equal(runId)); + + pipeline._source.rawOptions.ignoreIndexFields = [123]; + + await expectAsyncError( + () => execute(pipeline), + 'pipelineExecute() expected pipeline.source.rawOptions.ignoreIndexFields values to be strings.', + ); + }); + + it('rejects unsupported rawStage option value types at native boundary', async function () { + if (Platform.other || Platform.ios) { + return; + } + + const { execute, field } = firestorePipelinesModular; + const { getFirestore, collection, doc, setDoc } = firestoreModular; + const db = getFirestore(DATABASE_ID); + const coll = collection( + db, + `${COLLECTION}/${Utils.randString(12, '#aA')}/native-raw-stage-option-type`, + ); + + await setDoc(doc(coll, 'base'), { rating: 4 }); + + const pipeline = db + .pipeline() + .collection(coll) + .rawStage('score', { input: field('rating') }, { enabled: true }); + + pipeline._stages.find(stage => stage.stage === 'rawStage').options.options.badOption = [ + 1, 2, + ]; + + await expectAsyncError( + () => execute(pipeline), + 'pipelineExecute() received an unsupported raw stage option value for key: badOption.', + ); + }); }); }); From 9d5deddcfa0c7f4421cd91d671bc24554a16fa62 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 17:41:38 -0500 Subject: [PATCH 13/20] test(firestore): expand pipeline validate Jest coverage Cover remaining pipeline_validate.ts branch guards via direct Jest calls; 100% line coverage. Unreachable validateSource default-param branch deferred to Phase Q audit. --- .../firestore/pipeline-coverage-work-queue.md | 16 ++--- .../__tests__/pipelines-validate.test.ts | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index c216c27ab5..8007c00abb 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **P** — `implementation` (Jest-only TS validation paths). +> **IN PROGRESS:** **Q** — `implementation` (intractability audit + dead-code triage). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -65,8 +65,8 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **M** | Android exit frames + receiver chains | **✅** | 3 e2e; NodeBuilder 75.03%; exit −29 missed | | **N** | iOS stage coercion | **✅** | iOS/web stage coercion + operand tail; macOS 139 / iOS 144 / Android 144 area-focused | | **O** | Android Executor remainder | **✅** | 58%→60.94%; 7 e2e; ~130 missed dead-code → Phase Q | -| **P** | Jest-only TS paths | **in progress** | validation branches *(was old N)* | -| **Q** | Intractability audit | queued | measured caps per file *(was old O)* | +| **P** | Jest-only TS paths | **✅** | 100% lines pipeline_validate; L49 → Q | +| **Q** | Intractability audit | **in progress** | measured caps; Executor dead-code cluster | | **R** | Pre-merge harness restore | queued | **Full** unfocused 3-platform snapshot — [full validation tier](../../testing/running-e2e.md#e2e-validation-tiers-unit-focused-area-focused-full) *(was old P)* | @@ -76,12 +76,12 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `p-impl-jest-validate`; **harness:** subagent applies overrides locally +**Label:** `q-audit-dead-code`; **harness:** per subagent tier -**Next item:** **P** — Jest coverage for unreachable `pipeline_validate.ts` guards. +**Next item:** **Q** — audit intractable/dead paths; remove provably dead Executor helpers if safe. -| **O** Android Executor | `test(firestore): cover pipeline Android Executor remainder paths` | **closed** | **closed** | **closed** | — | — | — | 7 e2e; 58%→60.94%; 151 Android area-focused; dead-code cluster → Q | -| **P** Jest validation paths | — | open | open | open | `implementation` | `unit-focused` | macOS | `pipeline_validate.ts` remaining guards | +| **P** Jest validation paths | `test(firestore): expand pipeline validate Jest coverage` | **closed** | **closed** | **closed** | — | — | — | 100% lines / 98.95% branches; L49 default-param deferred | +| **Q** Intractability audit | — | open | open | open | `implementation` | `unit-focused` | android | Executor dead-code (~130 missed); validateSource L49 | | **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | | **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | @@ -306,7 +306,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **P** `implementation_gate` open. **N** and **O** complete. +**Current gates:** **Q** `implementation_gate` open. **P** complete. --- diff --git a/packages/firestore/__tests__/pipelines-validate.test.ts b/packages/firestore/__tests__/pipelines-validate.test.ts index 39c8c89e1a..911d2939c8 100644 --- a/packages/firestore/__tests__/pipelines-validate.test.ts +++ b/packages/firestore/__tests__/pipelines-validate.test.ts @@ -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 () { @@ -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 () { @@ -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( @@ -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', @@ -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".', + ); + }); }); From f56f49a027826bdf031f9642042b29ab862fc16a Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 17:49:46 -0500 Subject: [PATCH 14/20] refactor(firestore, android): remove dead pipeline Executor lowering code Delete uncalled readableMap helpers, applyPrimitiveRawOptions, and applyUnionStage paths superseded by buildNativePipeline; document remaining intractable coverage caps in the work queue. --- .../firestore/pipeline-coverage-work-queue.md | 40 ++- ...tiveFirebaseFirestorePipelineExecutor.java | 238 ------------------ 2 files changed, 31 insertions(+), 247 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 8007c00abb..54ff11ed70 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **Q** — `implementation` (intractability audit + dead-code triage). +> **IN PROGRESS:** **R** — queued (pre-merge full harness restore + 3-platform snapshot). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -66,8 +66,8 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **N** | iOS stage coercion | **✅** | iOS/web stage coercion + operand tail; macOS 139 / iOS 144 / Android 144 area-focused | | **O** | Android Executor remainder | **✅** | 58%→60.94%; 7 e2e; ~130 missed dead-code → Phase Q | | **P** | Jest-only TS paths | **✅** | 100% lines pipeline_validate; L49 → Q | -| **Q** | Intractability audit | **in progress** | measured caps; Executor dead-code cluster | -| **R** | Pre-merge harness restore | queued | **Full** unfocused 3-platform snapshot — [full validation tier](../../testing/running-e2e.md#e2e-validation-tiers-unit-focused-area-focused-full) *(was old P)* | +| **Q** | Intractability audit | **✅** | −238 Executor dead lines; intractable caps documented | +| **R** | Pre-merge harness restore | **queued** | full 3-platform snapshot | **Compare-types exports:** out of scope until **R**. During **J**, no new `Platform.android` / `Platform.ios` branches for coverage; file drift, fix in **J**, or document SDK limitation. @@ -76,12 +76,11 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `q-audit-dead-code`; **harness:** per subagent tier +**Label:** `r-pre-merge-full`; **harness:** full app (committed defaults) -**Next item:** **Q** — audit intractable/dead paths; remove provably dead Executor helpers if safe. +**Next item:** **R** — revert harness overrides; unfocused 3-platform `:test-cover`. -| **P** Jest validation paths | `test(firestore): expand pipeline validate Jest coverage` | **closed** | **closed** | **closed** | — | — | — | 100% lines / 98.95% branches; L49 default-param deferred | -| **Q** Intractability audit | — | open | open | open | `implementation` | `unit-focused` | android | Executor dead-code (~130 missed); validateSource L49 | +| **Q** Intractability audit | `refactor(firestore, android): remove dead pipeline Executor lowering code` | **closed** | **closed** | **closed** | — | — | — | −238 lines; 151 Android pass; intractable caps in queue | | **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** | — | — | — | P-005 → Resolved; CFBoolean deferral accepted | | **J3** P-010 stage option expressions | `fix(firestore, android): align pipeline stage option expression fields with iOS` | **closed** | **closed** | **closed** | — | — | — | P-010 → Resolved | @@ -121,7 +120,7 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | TS `expressions.ts` | 89% | **93.61% (249/266)** | K batch (+1 e2e line) | **K** | | Android NodeBuilder | 67.5% (1167/1729) | **75.03% (1322/1762)** | M: exit −29 missed; loop −12 | L, M | | Android loop L900–1299 | 106 missed | **64 missed** | M: −12 from baseline 76 | M | -| Android Executor | 58% | **60.94% (387/635)** | O: +7 e2e; dead-code → Q | O, Q | +| Android Executor | 58% | **~97% live (387/~397)** post-Q dead removal | Q: −238 lines dead; jacoco regen at commit | O, Q | | iOS NodeBuilder | 68.89% | **~70%+ (Phase N)** | G: +15 hit; N: stage coercion | N | | iOS operand modes L919–1006 | 27 missed | **reduced (Phase N)** | N operand tail e2e | N, Q | @@ -306,7 +305,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **Q** `implementation_gate` open. **P** complete. +**Current gates:** **R** queued. **K–Q** complete. --- @@ -358,6 +357,29 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove --- +## Phase Q — intractability audit (in progress) + +**Audit method:** caller grep + `buildNativePipeline` stack trace for union routing; Parser owns `readableMapToJava` for execute entry. + +**Removed (Android Executor, zero callers):** + +| Cluster | Lines | Rationale | +| ------- | ----- | --------- | +| `getJavaValue` / `readableMapToJava` / `readableArrayToJava` / `populateReadableContainers` + frame classes | ~200 | Duplicate Parser helpers; no call sites in Executor | +| `applyPrimitiveRawOptions` | ~42 | Superseded by `applyPrimitiveRawStageOptions` (live at `applyRawStage`) | +| `applyUnionStage` + `applyStage` union branch | ~9 | Unions handled in `buildNativePipeline` via `PendingUnionPipelineStage`; `ParsedUnionStage` never reaches `applyStage` | + +**Intractable (document only — no removal):** + +| Cap | Location | Rationale | +| --- | -------- | --------- | +| `validateSource` default param | `pipeline_validate.ts` L49 | Sole call site passes explicit `` `${fieldName}.source` ``; default unreachable; harmless | +| Map passthrough execute success | NodeBuilder L1208–1219 (G) | Firestore rejects `map(field(…))` execute | +| Map execute / task-guard tails | Executor `resolvePipelineTask` empty-exception arm | e2e-only; no stable probe | +| iOS operand-mode tail | NodeBuilder L928–1006 | Bridge parity closed; residual misses are SDK-shaped | + +--- + ## Historical notes (A–E) - **A:** Jet WS coverage transfer; 3-platform TS lcov baseline. diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java index 205329fd30..dc5e704f6d 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestorePipelineExecutor.java @@ -27,8 +27,6 @@ import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.ReadableMapKeySetIterator; -import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.Task; @@ -57,7 +55,6 @@ import com.google.firebase.firestore.pipeline.UnnestOptions; import java.util.ArrayDeque; import java.util.Arrays; -import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -114,28 +111,6 @@ private static final class ExitPipelineBuildFrame extends PipelineBuildFrame { } } - private abstract static class ReadableContainerBuildFrame {} - - private static final class ReadableMapBuildFrame extends ReadableContainerBuildFrame { - final ReadableMap source; - final Map target; - - ReadableMapBuildFrame(ReadableMap source, Map target) { - this.source = source; - this.target = target; - } - } - - private static final class ReadableArrayBuildFrame extends ReadableContainerBuildFrame { - final ReadableArray source; - final List target; - - ReadableArrayBuildFrame(ReadableArray source, List target) { - this.source = source; - this.target = target; - } - } - ReactNativeFirebaseFirestorePipelineExecutor(FirebaseFirestore firestore) { this.firestore = firestore; this.nodeBuilder = new ReactNativeFirebaseFirestorePipelineNodeBuilder(); @@ -441,10 +416,6 @@ private Pipeline applyStage( return applySampleStage( pipeline, (ReactNativeFirebaseFirestorePipelineParser.ParsedSampleStage) stage); } - if (stage instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedUnionStage) { - return applyUnionStage( - pipeline, (ReactNativeFirebaseFirestorePipelineParser.ParsedUnionStage) stage); - } if (stage instanceof ReactNativeFirebaseFirestorePipelineParser.ParsedUnnestStage) { return applyUnnestStage( pipeline, (ReactNativeFirebaseFirestorePipelineParser.ParsedUnnestStage) stage); @@ -658,13 +629,6 @@ private Pipeline applySampleStage( "pipelineExecute() expected sample stage to include documents or percentage."); } - private Pipeline applyUnionStage( - Pipeline pipeline, ReactNativeFirebaseFirestorePipelineParser.ParsedUnionStage stage) - throws PipelineValidationException { - throw new PipelineValidationException( - "pipelineExecute() failed to build nested union pipeline."); - } - private Pipeline applyUnnestStage( Pipeline pipeline, ReactNativeFirebaseFirestorePipelineParser.ParsedUnnestStage stage) throws PipelineValidationException { @@ -707,49 +671,6 @@ private Pipeline applyRawStage( return pipeline.rawStage(rawStage); } - private > - T applyPrimitiveRawOptions(T options, Map rawOptions) - throws PipelineValidationException { - for (Map.Entry entry : rawOptions.entrySet()) { - String key = entry.getKey(); - Object rawValue = entry.getValue(); - if (rawValue == null) { - continue; - } - if (rawValue instanceof Boolean) { - options = options.with(key, (Boolean) rawValue); - continue; - } - if (rawValue instanceof String) { - options = options.with(key, (String) rawValue); - continue; - } - if (rawValue instanceof Number) { - Number numberValue = (Number) rawValue; - if (numberValue instanceof Double || numberValue instanceof Float) { - options = options.with(key, numberValue.doubleValue()); - } else { - options = options.with(key, numberValue.longValue()); - } - continue; - } - if (rawValue instanceof List) { - throw new PipelineValidationException( - "pipelineExecute() received an unsupported raw option array for key: " + key + "."); - } - if (rawValue instanceof Map) { - String fieldPath = nodeBuilder.coerceFieldPath(rawValue, "options.rawOptions." + key); - options = options.with(key, Expression.field(fieldPath)); - continue; - } - - throw new PipelineValidationException( - "pipelineExecute() received an unsupported raw option value for key: " + key + "."); - } - - return options; - } - private Pipeline.ExecuteOptions applyExecuteRawOptions( Pipeline.ExecuteOptions options, Map rawOptions) throws PipelineValidationException { @@ -1005,165 +926,6 @@ private Object unwrapPipelineResultValue(Object value) { return normalized; } - private Object getJavaValue(ReadableMap map, String key) { - if (map == null || !map.hasKey(key)) { - return null; - } - - ReadableType type = map.getType(key); - if (type == ReadableType.Null) { - return null; - } - - switch (type) { - case Boolean: - return map.getBoolean(key); - case Number: - return coerceNumber(map.getDouble(key)); - case String: - return map.getString(key); - case Map: - ReadableMap nestedMap = map.getMap(key); - return nestedMap == null ? null : readableMapToJava(nestedMap); - case Array: - ReadableArray nestedArray = map.getArray(key); - return nestedArray == null ? null : readableArrayToJava(nestedArray); - case Null: - default: - return null; - } - } - - private Map readableMapToJava(ReadableMap readableMap) { - Map output = new HashMap<>(); - ArrayDeque stack = new ArrayDeque<>(); - stack.push(new ReadableMapBuildFrame(readableMap, output)); - populateReadableContainers(stack); - return output; - } - - private List readableArrayToJava(ReadableArray readableArray) { - List output = new java.util.ArrayList<>(); - ArrayDeque stack = new ArrayDeque<>(); - stack.push(new ReadableArrayBuildFrame(readableArray, output)); - populateReadableContainers(stack); - return output; - } - - private void populateReadableContainers(ArrayDeque stack) { - // Use an explicit stack so deeply nested pipeline inputs do not rely on JVM recursion depth. - while (!stack.isEmpty()) { - ReadableContainerBuildFrame frame = stack.pop(); - - if (frame instanceof ReadableMapBuildFrame) { - ReadableMapBuildFrame mapFrame = (ReadableMapBuildFrame) frame; - ReadableMapKeySetIterator iterator = mapFrame.source.keySetIterator(); - - while (iterator.hasNextKey()) { - String key = iterator.nextKey(); - ReadableType type = mapFrame.source.getType(key); - if (type == ReadableType.Null) { - mapFrame.target.put(key, null); - continue; - } - - switch (type) { - case Boolean: - mapFrame.target.put(key, mapFrame.source.getBoolean(key)); - break; - case Number: - mapFrame.target.put(key, coerceNumber(mapFrame.source.getDouble(key))); - break; - case String: - mapFrame.target.put(key, mapFrame.source.getString(key)); - break; - case Map: - ReadableMap nestedMap = mapFrame.source.getMap(key); - if (nestedMap == null) { - mapFrame.target.put(key, null); - break; - } - - Map nestedMapOutput = new HashMap<>(); - mapFrame.target.put(key, nestedMapOutput); - stack.push(new ReadableMapBuildFrame(nestedMap, nestedMapOutput)); - break; - case Array: - ReadableArray nestedArray = mapFrame.source.getArray(key); - if (nestedArray == null) { - mapFrame.target.put(key, null); - break; - } - - List nestedArrayOutput = new java.util.ArrayList<>(); - mapFrame.target.put(key, nestedArrayOutput); - stack.push(new ReadableArrayBuildFrame(nestedArray, nestedArrayOutput)); - break; - case Null: - default: - mapFrame.target.put(key, null); - } - } - - continue; - } - - ReadableArrayBuildFrame arrayFrame = (ReadableArrayBuildFrame) frame; - for (int i = 0; i < arrayFrame.source.size(); i++) { - ReadableType type = arrayFrame.source.getType(i); - if (type == ReadableType.Null) { - arrayFrame.target.add(null); - continue; - } - - switch (type) { - case Boolean: - arrayFrame.target.add(arrayFrame.source.getBoolean(i)); - break; - case Number: - arrayFrame.target.add(coerceNumber(arrayFrame.source.getDouble(i))); - break; - case String: - arrayFrame.target.add(arrayFrame.source.getString(i)); - break; - case Map: - ReadableMap nestedMap = arrayFrame.source.getMap(i); - if (nestedMap == null) { - arrayFrame.target.add(null); - break; - } - - Map nestedMapOutput = new HashMap<>(); - arrayFrame.target.add(nestedMapOutput); - stack.push(new ReadableMapBuildFrame(nestedMap, nestedMapOutput)); - break; - case Array: - ReadableArray nestedArray = arrayFrame.source.getArray(i); - if (nestedArray == null) { - arrayFrame.target.add(null); - break; - } - - List nestedArrayOutput = new java.util.ArrayList<>(); - arrayFrame.target.add(nestedArrayOutput); - stack.push(new ReadableArrayBuildFrame(nestedArray, nestedArrayOutput)); - break; - case Null: - default: - arrayFrame.target.add(null); - break; - } - } - } - } - - private Number coerceNumber(double value) { - if (Math.floor(value) == value && value <= Long.MAX_VALUE && value >= Long.MIN_VALUE) { - return (long) value; - } - return value; - } - private String optionalString(Map map, String key) { if (map == null || !map.containsKey(key) || map.get(key) == null) { return null; From 46a8c96df0c839cc24ed5bc18616aa7b37072c5a Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 18:27:47 -0500 Subject: [PATCH 15/20] fix(firestore, ios): align pipeline stage option coercion with Android Unnest/findNearest use coerceStageOptionFieldName; defer limit/offset numeric validation to bridge factory so Phase R native executor e2e passes. --- .../firestore/pipeline-coverage-work-queue.md | 42 ++++++++++++++++--- .../RNFBFirestorePipelineBridgeFactory.swift | 20 ++++++++- .../RNFBFirestorePipelineNodeBuilder.swift | 22 ++++++++++ .../RNFBFirestorePipelineParser.swift | 8 ++-- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 54ff11ed70..976f37eb45 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **R** — queued (pre-merge full harness restore + 3-platform snapshot). +> **IN PROGRESS:** **R** — R-iOS fix committed pending; full-tier iOS re-run to close **R**. > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -67,7 +67,7 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **O** | Android Executor remainder | **✅** | 58%→60.94%; 7 e2e; ~130 missed dead-code → Phase Q | | **P** | Jest-only TS paths | **✅** | 100% lines pipeline_validate; L49 → Q | | **Q** | Intractability audit | **✅** | −238 Executor dead lines; intractable caps documented | -| **R** | Pre-merge harness restore | **queued** | full 3-platform snapshot | +| **R** | Pre-merge harness restore | **blocked** | macOS 698/0; Android 866/0; **iOS 835/3** — see [Phase R](#phase-r--pre-merge-snapshot) | **Compare-types exports:** out of scope until **R**. During **J**, no new `Platform.android` / `Platform.ios` branches for coverage; file drift, fix in **J**, or document SDK limitation. @@ -76,9 +76,21 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au ## Current snapshot -**Label:** `r-pre-merge-full`; **harness:** full app (committed defaults) +**Label:** `after-phase-r-final`; **harness:** full app (committed defaults; no `harness.overrides.js`) -**Next item:** **R** — revert harness overrides; unfocused 3-platform `:test-cover`. +**Next item:** **R-iOS review** → full-tier iOS `:test-cover` → close **R**. + +**R-iOS fix (uncommitted):** iOS bridge parity — `coerceStageOptionFieldName` for unnest/findNearest; limit/offset defer to bridge factory. Area-focused: **151/0** (`/tmp/rnfb-e2e-ios-area-pipeline-fix.log`). + +| Metric | Baseline (early / post-phase) | Phase R (`after-phase-r-final`) | +| ------ | ----------------------------- | -------------------------------- | +| Android NodeBuilder | ~55% → **67.5%** | **75.18%** (1324/1761) | +| Android Executor | 49% → 58% → ~60.94% (O) → **~97% live** (Q) | **76.59%** jacoco (386/504) — full-tier jacoco below Q live estimate | +| TS `pipeline_runtime.ts` | 86% → **90.62%** | **91.07%** (204/224) | +| TS `expressions.ts` | 89% → **93.61%** | **93.61%** (flat) | +| TS `pipeline_validate.ts` | ~93% → **100% lines** (P Jest) | **88.64%** e2e lcov (78/88) | +| iOS NodeBuilder | ~68.89% → ~70%+ (N) | **n/a** — `coverage/ios-native/lcov.info` missing | +| Android loop L900 band | 106 → **64** missed (M) | **65** missed @ **71.98%** (167/232) | | **Q** Intractability audit | `refactor(firestore, android): remove dead pipeline Executor lowering code` | **closed** | **closed** | **closed** | — | — | — | −238 lines; 151 Android pass; intractable caps in queue | @@ -305,7 +317,27 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **R** queued. **K–Q** complete. +**Current gates:** **R** — `review_gate` **closed** (R-iOS); commit + full-tier iOS pending. **K–Q** complete. + +## Phase R — pre-merge snapshot + +**Status:** partial — preflight recovery required once (stale `:8090` / macOS Jet from prior run); functions `:5001` confirmed up for all runs. + +| Platform | Exit | Pass / Fail / Pending | Log | +| -------- | ---- | --------------------- | --- | +| macOS | 0 | 698 / 0 / 38 | `/tmp/rnfb-e2e-macos-r-full.log` | +| iOS | 1 | 835 / **3** / 87 | `/tmp/rnfb-e2e-ios-r-full.log` | +| Android | 0 | 866 / 0 / 58 | `/tmp/rnfb-e2e-android-r-full.log` | + +**iOS failures** (`executor parser and bridge factory coverage` in `Pipeline.e2e.js`): + +1. `executes unnest when indexField coerces to empty string` — TS runtime throws before native (`pipeline_runtime.ts:854`). +2. `routes findNearest non-field distanceField expression through native executor` — `[firestore/invalid-argument]` at execute. +3. `rejects non-numeric limit and offset at native coerce boundary` — `expectAsyncError` false (error not thrown or wrong message). + +**Arbiter:** `next_work_type: independent-review`; `validation_tier: area-focused`; platform **ios**; frozen tree. After review: commit R-iOS fix → full-tier iOS `:test-cover` (no harness overrides) → close **R**. + +**Gap map:** `bash scripts/map-pipeline-coverage-gaps.sh after-phase-r-final`; snapshot `scripts/snapshot-pipeline-coverage.sh after-phase-r-final`. --- diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift index 6c55271fcd..10d5330ca7 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineBridgeFactory.swift @@ -260,7 +260,11 @@ final class RNFBFirestorePipelineBridgeFactory { let alias = nodeBuilder.coerceAlias(from: parsed.selectable) ?? "_unnest" let indexExpr: ExprBridge? if let indexField = parsed.indexField { - indexExpr = try nodeBuilder.coerceExpression(indexField, fieldName: "stage.options.indexField") + let indexFieldName = try nodeBuilder.coerceStageOptionFieldName( + indexField, + fieldName: "stage.options.indexField" + ) + indexExpr = indexFieldName.isEmpty ? nil : FieldBridge(name: indexFieldName) } else { indexExpr = nil } @@ -300,7 +304,19 @@ final class RNFBFirestorePipelineBridgeFactory { ) let distanceFieldExpr: ExprBridge? if let distanceField = stage.distanceField { - distanceFieldExpr = try nodeBuilder.coerceExpression(distanceField, fieldName: "stage.options.distanceField") + let expression = try nodeBuilder.coerceExpression( + distanceField, + fieldName: "stage.options.distanceField" + ) + if expression is FieldBridge { + distanceFieldExpr = expression + } else { + let fieldName = try nodeBuilder.coerceStageOptionFieldName( + distanceField, + fieldName: "stage.options.distanceField" + ) + distanceFieldExpr = FieldBridge(name: fieldName) + } } else { distanceFieldExpr = nil } diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift index cce3fa6fd7..e5b0f31b11 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift @@ -290,6 +290,20 @@ final class RNFBFirestorePipelineNodeBuilder { try coerceFieldPath(serializeExpressionNode(value), fieldName: fieldName) } + func coerceStageOptionFieldName( + _ value: RNFBFirestoreParsedExpressionNode, + fieldName: String + ) throws -> String { + switch value { + case let .field(path): + return path + case let .constant(constantValue): + return try coerceStringValue(serializeValueNode(constantValue), fieldName: fieldName) + default: + return try coerceFieldPath(serializeExpressionNode(value), fieldName: fieldName) + } + } + func coerceExpression(_ value: Any, fieldName: String) throws -> ExprBridge { try coerceExpressionTree(value, fieldName: fieldName, mode: .expression) } @@ -485,6 +499,14 @@ final class RNFBFirestorePipelineNodeBuilder { return Int(try coerceNumber(value, fieldName: fieldName)) } + private func coerceStringValue(_ value: Any, fieldName: String) throws -> String { + let resolved = try resolveConstantValue(value, fieldName: fieldName) + guard let string = resolved as? String else { + throw PipelineValidationError("pipelineExecute() expected \(fieldName) to resolve to a string.") + } + return string + } + func requireValue( _ map: [String: Any], key: String, diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift index a955346a19..eac4a85611 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineParser.swift @@ -139,11 +139,11 @@ struct RNFBFirestoreParsedSortStage { } struct RNFBFirestoreParsedLimitStage { - let limit: NSNumber + let limit: Any } struct RNFBFirestoreParsedOffsetStage { - let offset: NSNumber + let offset: Any } struct RNFBFirestoreParsedAggregateStage { @@ -554,11 +554,11 @@ enum RNFBFirestorePipelineParser { )) case "limit": return .limitStage(RNFBFirestoreParsedLimitStage( - limit: try requireNumber(options, key: "limit", fieldName: "\(fieldName).options.limit") + limit: try requireValue(options, key: "limit", fieldName: "stage.options.limit") )) case "offset": return .offsetStage(RNFBFirestoreParsedOffsetStage( - offset: try requireNumber(options, key: "offset", fieldName: "\(fieldName).options.offset") + offset: try requireValue(options, key: "offset", fieldName: "stage.options.offset") )) case "aggregate": return .aggregateStage(try parseAggregateStage(options, fieldName: "\(fieldName).options")) From e41fac8330ccbbb10c8e434d33399665dfae56cb Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 18:32:29 -0500 Subject: [PATCH 16/20] docs(firestore): close Phase R pre-merge snapshot in work queue Record 3-platform full-tier green after iOS stage option coercion fix. --- .../firestore/pipeline-coverage-work-queue.md | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 976f37eb45..2740b6ec0b 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **R** — R-iOS fix committed pending; full-tier iOS re-run to close **R**. +> **COMPLETE:** **R** closed — 3-platform full-tier green (698/838/866 passing). **Next:** compare-types / merge readiness (PR 9086). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -67,20 +67,18 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | **O** | Android Executor remainder | **✅** | 58%→60.94%; 7 e2e; ~130 missed dead-code → Phase Q | | **P** | Jest-only TS paths | **✅** | 100% lines pipeline_validate; L49 → Q | | **Q** | Intractability audit | **✅** | −238 Executor dead lines; intractable caps documented | -| **R** | Pre-merge harness restore | **blocked** | macOS 698/0; Android 866/0; **iOS 835/3** — see [Phase R](#phase-r--pre-merge-snapshot) | +| **R** | Pre-merge harness restore | **✅** | 698/0 macOS; 866/0 Android; iOS 838/0 after `38cc8815a` fix | -**Compare-types exports:** out of scope until **R**. During **J**, no new `Platform.android` / `Platform.ios` branches for coverage; file drift, fix in **J**, or document SDK limitation. +**Compare-types exports:** unblocked — **R** complete. --- ## Current snapshot -**Label:** `after-phase-r-final`; **harness:** full app (committed defaults; no `harness.overrides.js`) +**Label:** `after-phase-r-final`; **harness:** full app (committed defaults) -**Next item:** **R-iOS review** → full-tier iOS `:test-cover` → close **R**. - -**R-iOS fix (uncommitted):** iOS bridge parity — `coerceStageOptionFieldName` for unnest/findNearest; limit/offset defer to bridge factory. Area-focused: **151/0** (`/tmp/rnfb-e2e-ios-area-pipeline-fix.log`). +**Next item:** compare-types + PR 9086 merge readiness. | Metric | Baseline (early / post-phase) | Phase R (`after-phase-r-final`) | | ------ | ----------------------------- | -------------------------------- | @@ -317,25 +315,19 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **R** — `review_gate` **closed** (R-iOS); commit + full-tier iOS pending. **K–Q** complete. +**Current gates:** **R** **closed**. **K–R** complete. `next_work_type: pre-merge-validation` (compare-types). ## Phase R — pre-merge snapshot -**Status:** partial — preflight recovery required once (stale `:8090` / macOS Jet from prior run); functions `:5001` confirmed up for all runs. +**Status:** **closed** — full-tier 3-platform; iOS fix `38cc8815a` (`fix(firestore, ios): align pipeline stage option coercion with Android`). | Platform | Exit | Pass / Fail / Pending | Log | | -------- | ---- | --------------------- | --- | | macOS | 0 | 698 / 0 / 38 | `/tmp/rnfb-e2e-macos-r-full.log` | -| iOS | 1 | 835 / **3** / 87 | `/tmp/rnfb-e2e-ios-r-full.log` | +| iOS | 0 | **838** / 0 / 87 | `/tmp/rnfb-e2e-ios-r-full-rerun.log` | | Android | 0 | 866 / 0 / 58 | `/tmp/rnfb-e2e-android-r-full.log` | -**iOS failures** (`executor parser and bridge factory coverage` in `Pipeline.e2e.js`): - -1. `executes unnest when indexField coerces to empty string` — TS runtime throws before native (`pipeline_runtime.ts:854`). -2. `routes findNearest non-field distanceField expression through native executor` — `[firestore/invalid-argument]` at execute. -3. `rejects non-numeric limit and offset at native coerce boundary` — `expectAsyncError` false (error not thrown or wrong message). - -**Arbiter:** `next_work_type: independent-review`; `validation_tier: area-focused`; platform **ios**; frozen tree. After review: commit R-iOS fix → full-tier iOS `:test-cover` (no harness overrides) → close **R**. +**R-iOS fix:** `coerceStageOptionFieldName` for unnest/findNearest; limit/offset defer to bridge factory. Area-focused 151/0; full-tier iOS re-run 838/0. **Gap map:** `bash scripts/map-pipeline-coverage-gaps.sh after-phase-r-final`; snapshot `scripts/snapshot-pipeline-coverage.sh after-phase-r-final`. From 6016d1bb397f556c44d541aecc2de77d098bec9b Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 18:34:41 -0500 Subject: [PATCH 17/20] docs(firestore): record Phase R final coverage including iOS lcov Update snapshot table with iOS NodeBuilder 69.10% and operand-mode delta. --- .../packages/firestore/pipeline-coverage-work-queue.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 2740b6ec0b..625be3d785 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -85,9 +85,10 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au | Android NodeBuilder | ~55% → **67.5%** | **75.18%** (1324/1761) | | Android Executor | 49% → 58% → ~60.94% (O) → **~97% live** (Q) | **76.59%** jacoco (386/504) — full-tier jacoco below Q live estimate | | TS `pipeline_runtime.ts` | 86% → **90.62%** | **91.07%** (204/224) | -| TS `expressions.ts` | 89% → **93.61%** | **93.61%** (flat) | +| TS `expressions.ts` | 89% → **93.61%** | **93.98%** (250/266) | | TS `pipeline_validate.ts` | ~93% → **100% lines** (P Jest) | **88.64%** e2e lcov (78/88) | -| iOS NodeBuilder | ~68.89% → ~70%+ (N) | **n/a** — `coverage/ios-native/lcov.info` missing | +| iOS NodeBuilder | ~68.89% → ~70%+ (N) | **69.10%** (1516/2194) | +| iOS operand modes L919–1006 | **27 missed** | **17 missed** @ **72.58%** (45/62) | | Android loop L900 band | 106 → **64** missed (M) | **65** missed @ **71.98%** (167/232) | | **Q** Intractability audit | `refactor(firestore, android): remove dead pipeline Executor lowering code` | **closed** | **closed** | **closed** | — | — | — | −238 lines; 151 Android pass; intractable caps in queue | @@ -329,7 +330,7 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **R-iOS fix:** `coerceStageOptionFieldName` for unnest/findNearest; limit/offset defer to bridge factory. Area-focused 151/0; full-tier iOS re-run 838/0. -**Gap map:** `bash scripts/map-pipeline-coverage-gaps.sh after-phase-r-final`; snapshot `scripts/snapshot-pipeline-coverage.sh after-phase-r-final`. +**Gap map:** `bash scripts/map-pipeline-coverage-gaps.sh after-phase-r-final`; snapshot `scripts/snapshot-pipeline-coverage.sh after-phase-r-final`. TS + iOS + Android coverage artifacts present (`coverage/lcov.info`, `coverage/ios-native/lcov.info`, Jacoco). --- From 7e92609f861adcd2202d5ead9f5a5f56405c3705 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 18:36:00 -0500 Subject: [PATCH 18/20] docs(firestore): add PR post coverage table and open merge gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record Phase A→R deltas for eventual PR 9086 comment; queue static pre-merge checks. --- .../firestore/pipeline-coverage-work-queue.md | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 625be3d785..74db8da7dc 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **COMPLETE:** **R** closed — 3-platform full-tier green (698/838/866 passing). **Next:** compare-types / merge readiness (PR 9086). +> **IN PROGRESS:** **Merge gate** — static `pre-merge-validation` (compare-types, tsc, lint, Jest). **R** closed. PR [9086](https://github.com/invertase/react-native-firebase/pull/9086). > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -78,9 +78,28 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au **Label:** `after-phase-r-final`; **harness:** full app (committed defaults) -**Next item:** compare-types + PR 9086 merge readiness. +**Next item:** **Merge gate** — `yarn compare:types` + handoff checklist ([validation-checklist](../../testing/validation-checklist.md)); then PR 9086 force-push / CI. -| Metric | Baseline (early / post-phase) | Phase R (`after-phase-r-final`) | +--- + +## PR post draft (coverage table — copy for merge comment) + +**E2e (Phase R full tier):** macOS **698**/0, iOS **838**/0, Android **866**/0 passing. + +| Target | Phase A baseline | Phase R final | Delta | +| ------ | ---------------- | ------------- | ----- | +| TS `pipeline_runtime.ts` | **86%** | **91.07%** (204/224) | **+5.07 pp** | +| TS `expressions.ts` | **89%** | **93.98%** (250/266) | **+4.98 pp** | +| TS `pipeline_validate.ts` | **~93%** | **88.64%** (78/88) | −4.36 pp (e2e lcov vs macOS/Jest mix) | +| Android NodeBuilder | **67.5%** (1167/1729) | **75.18%** (1324/1761) | **+7.68 pp** | +| Android Executor | **~49%** / **58%** (post-E) | **76.59%** (386/504) | **+18.6–27.6 pp** | +| iOS NodeBuilder | **68.89%** | **69.10%** (1516/2194) | **+0.21 pp** | +| Android EnterObject loop (L900 band) | **106 missed** | **65 missed** (71.98%) | **−41 missed** | +| iOS operand modes (L919–1006) | **27 missed** (69.32%) | **17 missed** (72.58%) | **−10 missed** | + +**Notable commits:** parity J0–J6; coverage K–Q (−238 Executor dead lines); R-iOS `38cc8815a`; android coverage upload `94299783a`. + +--- | ------ | ----------------------------- | -------------------------------- | | Android NodeBuilder | ~55% → **67.5%** | **75.18%** (1324/1761) | | Android Executor | 49% → 58% → ~60.94% (O) → **~97% live** (Q) | **76.59%** jacoco (386/504) — full-tier jacoco below Q live estimate | @@ -316,7 +335,9 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **R** **closed**. **K–R** complete. `next_work_type: pre-merge-validation` (compare-types). +**Current gates:** **Merge gate** open — `pre-merge-validation` static checks. **R** closed. **K–R** complete. + +| Metric | Baseline (early / post-phase) | Phase R (`after-phase-r-final`) | ## Phase R — pre-merge snapshot From b7b6b4d1bb7563fc443e385f214b5e21cb28ea00 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 18:38:24 -0500 Subject: [PATCH 19/20] docs(firestore): close merge gate in pipeline work queue Static pre-merge validation green; branch ready for PR 9086 push. --- .../firestore/pipeline-coverage-work-queue.md | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 74db8da7dc..9d6a6188b6 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z # Pipeline coverage and parity — work queue -> **IN PROGRESS:** **Merge gate** — static `pre-merge-validation` (compare-types, tsc, lint, Jest). **R** closed. PR [9086](https://github.com/invertase/react-native-firebase/pull/9086). +> **COMPLETE:** **Merge gate** closed — static pre-merge green; **R** closed. Ready for PR [9086](https://github.com/invertase/react-native-firebase/pull/9086) force-push + CI. > **Goal/order:** platform parity first; then TS/native coverage toward intractable limits. Links: [parity](pipeline-platform-parity.md), [SDK audit](pipeline-sdk-support-audit.md), [coverage](../../testing/coverage-design.md), [e2e](../../testing/running-e2e.md), [architecture](pipelines.md). --- @@ -78,11 +78,9 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au **Label:** `after-phase-r-final`; **harness:** full app (committed defaults) -**Next item:** **Merge gate** — `yarn compare:types` + handoff checklist ([validation-checklist](../../testing/validation-checklist.md)); then PR 9086 force-push / CI. +**Next item:** force-push branch → PR 9086 CI → paste [PR post draft](#pr-post-draft-coverage-table--copy-for-merge-comment). ---- - -## PR post draft (coverage table — copy for merge comment) +**Current gates:** **Merge gate** **closed** (2026-07-03). compare:types 19/19 documented; Jest 1146/1146; tsc + lint green. **K–R** complete. **E2e (Phase R full tier):** macOS **698**/0, iOS **838**/0, Android **866**/0 passing. @@ -100,15 +98,6 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au **Notable commits:** parity J0–J6; coverage K–Q (−238 Executor dead lines); R-iOS `38cc8815a`; android coverage upload `94299783a`. --- -| ------ | ----------------------------- | -------------------------------- | -| Android NodeBuilder | ~55% → **67.5%** | **75.18%** (1324/1761) | -| Android Executor | 49% → 58% → ~60.94% (O) → **~97% live** (Q) | **76.59%** jacoco (386/504) — full-tier jacoco below Q live estimate | -| TS `pipeline_runtime.ts` | 86% → **90.62%** | **91.07%** (204/224) | -| TS `expressions.ts` | 89% → **93.61%** | **93.98%** (250/266) | -| TS `pipeline_validate.ts` | ~93% → **100% lines** (P Jest) | **88.64%** e2e lcov (78/88) | -| iOS NodeBuilder | ~68.89% → ~70%+ (N) | **69.10%** (1516/2194) | -| iOS operand modes L919–1006 | **27 missed** | **17 missed** @ **72.58%** (45/62) | -| Android loop L900 band | 106 → **64** missed (M) | **65** missed @ **71.98%** (167/232) | | **Q** Intractability audit | `refactor(firestore, android): remove dead pipeline Executor lowering code` | **closed** | **closed** | **closed** | — | — | — | −238 lines; 151 Android pass; intractable caps in queue | From a860457bd1cc801e56f5f9c75500b35add8d4926 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Fri, 3 Jul 2026 18:38:33 -0500 Subject: [PATCH 20/20] docs(firestore): remove stale merge gate open footer in queue --- okf-bundle/packages/firestore/pipeline-coverage-work-queue.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md index 9d6a6188b6..8c25a832de 100644 --- a/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md +++ b/okf-bundle/packages/firestore/pipeline-coverage-work-queue.md @@ -324,10 +324,6 @@ Per [SDK audit §6](pipeline-sdk-support-audit.md): one function/commit; remove **Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated. -**Current gates:** **Merge gate** open — `pre-merge-validation` static checks. **R** closed. **K–R** complete. - -| Metric | Baseline (early / post-phase) | Phase R (`after-phase-r-final`) | - ## Phase R — pre-merge snapshot **Status:** **closed** — full-tier 3-platform; iOS fix `38cc8815a` (`fix(firestore, ios): align pipeline stage option coercion with Android`).