Skip to content

Commit 98efacc

Browse files
committed
wip(firestore): phase N iOS operand coercion and stage coercion e2e
1 parent 2b2d9f9 commit 98efacc

8 files changed

Lines changed: 454 additions & 60 deletions

File tree

.github/workflows/scripts/resource-monitor.sh

100644100755
File mode changed.

okf-bundle/packages/firestore/pipeline-coverage-work-queue.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ timestamp: 2026-06-25T12:00:00Z
88

99
# Pipeline coverage and parity — work queue
1010

11-
> **IN PROGRESS:** **N** queued — iOS stage coercion (~293 missed).
11+
> **IN PROGRESS:** **N** `implementation` (**one subagent**, serial e2e only).
1212
> **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).
1313
1414
---
@@ -63,7 +63,7 @@ Gate prerequisites before any `:test-cover` ([host rule](../../testing/change-au
6363
| **K** | TS `pipeline_runtime` + `expressions` | **** | Jest alias/normalization batch; expressions e2e receiver probe |
6464
| **L** | Android parsed-aggregate tail | **** | expression-arg `arrayAgg`/`arrayAggDistinct` e2e; parsed tail 258/408 |
6565
| **M** | Android exit frames + receiver chains | **** | 3 e2e; NodeBuilder 75.03%; exit −29 missed |
66-
| **N** | iOS stage coercion | queued | ~293 missed; operand tail *(was old L)* |
66+
| **N** | iOS stage coercion | **in progress** | `implementation`; operand tail L919–1006 |
6767
| **O** | Android Executor remainder | queued | sub-60% after E *(was old M)* |
6868
| **P** | Jest-only TS paths | queued | validation branches *(was old N)* |
6969
| **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
7676

7777
## Current snapshot
7878

79-
**Label:** `n-queued`; **harness:** full test app (committed)
79+
**Label:** `n-impl-serial-single`; **harness:** HEAD (subagent applies Pattern A locally)
8080

81-
**Next item:** **N**iOS stage coercion (~293 missed).
81+
**Next item:** **N**one subagent: prepare → macOS `:test-cover` → iOS `:test-cover` (serial).
8282

8383
| **L** parsed-aggregate tail | `test(firestore): cover pipeline aggregate expression argument lowering on Android` | **closed** | **closed** | **closed** |||| `arrayAgg`/`arrayAggDistinct` expr args |
8484
| **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 |
85+
| **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 |
8586

8687
| **J2** P-005 `integerLiteral` | `fix(firestore, android): align pipeline integerLiteral constant lowering with iOS` | **closed** | **closed** | **closed** |||| P-005 → Resolved; CFBoolean deferral accepted |
8788
| **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
306307

307308
**Gate for Phase K+:** J0 complete + **J0b** committed + J1–J6 bridge commits + parity **Resolved** updated.
308309

309-
**Current gates:** **N** queued. **L** and **M** complete.
310+
**Current gates:** **N** `implementation_gate` open. **L** and **M** complete.
310311

311312
---
312313

packages/firestore/e2e/Pipeline.e2e.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3473,6 +3473,240 @@ describe('FirestorePipeline', function () {
34733473
});
34743474
});
34753475

3476+
describe('iOS NodeBuilder stage coercion and operand tail coverage', function () {
3477+
it('exercises stage option coercion for findNearest dot product and transform stages', async function () {
3478+
if (Platform.other) {
3479+
return;
3480+
}
3481+
3482+
const { execute, field, constant, add, countAll } = firestorePipelinesModular;
3483+
const { getFirestore, collection, doc, setDoc, vector } = firestoreModular;
3484+
const db = getFirestore(DATABASE_ID);
3485+
const coll = collection(
3486+
db,
3487+
`${COLLECTION}/${Utils.randString(12, '#aA')}/stage-coercion-options`,
3488+
);
3489+
3490+
await Promise.all([
3491+
setDoc(doc(coll, 'v1'), {
3492+
name: 'alpha',
3493+
region: 'EU',
3494+
score: 40,
3495+
embedding: vector([1.0, 0.0, 0.0]),
3496+
}),
3497+
setDoc(doc(coll, 'v2'), {
3498+
name: 'beta',
3499+
region: 'EU',
3500+
score: 70,
3501+
embedding: vector([0.0, 1.0, 0.0]),
3502+
}),
3503+
setDoc(doc(coll, 'v3'), {
3504+
name: 'gamma',
3505+
region: 'US',
3506+
score: 55,
3507+
embedding: vector([0.0, 0.0, 1.0]),
3508+
}),
3509+
]);
3510+
3511+
const dotSnapshot = await execute(
3512+
db
3513+
.pipeline()
3514+
.collection(coll)
3515+
.findNearest({
3516+
field: 'embedding',
3517+
vectorValue: [1.0, 0.0, 0.0],
3518+
distanceMeasure: 'DOT_PRODUCT',
3519+
limit: 2,
3520+
})
3521+
.select('name'),
3522+
);
3523+
3524+
dotSnapshot.results.should.have.length(2);
3525+
dotSnapshot.results[0].data().name.should.equal('alpha');
3526+
3527+
const transformSnapshot = await execute(
3528+
db
3529+
.pipeline()
3530+
.collection(coll)
3531+
.where(field('region').equal('EU'))
3532+
.addFields(add(field('score'), constant(5)).as('scorePlusFive'))
3533+
.sort(field('scorePlusFive').descending(), field('name').ascending())
3534+
.offset(1)
3535+
.limit(1)
3536+
.select('name', 'scorePlusFive'),
3537+
);
3538+
3539+
transformSnapshot.results.should.have.length(1);
3540+
transformSnapshot.results[0].data().name.should.equal('alpha');
3541+
transformSnapshot.results[0].data().scorePlusFive.should.equal(45);
3542+
3543+
const groupedSnapshot = await execute(
3544+
db
3545+
.pipeline()
3546+
.collection(coll)
3547+
.aggregate({
3548+
accumulators: [countAll().as('rows')],
3549+
groups: [field('region').as('regionKey')],
3550+
})
3551+
.sort(field('regionKey').ascending()),
3552+
);
3553+
3554+
groupedSnapshot.results.should.have.length(2);
3555+
groupedSnapshot.results[0].data().regionKey.should.equal('EU');
3556+
groupedSnapshot.results[0].data().rows.should.equal(2);
3557+
});
3558+
3559+
it('covers remaining comparison and numeric operand rhs coercion tails', async function () {
3560+
const {
3561+
execute,
3562+
field,
3563+
subtract,
3564+
divide,
3565+
pow,
3566+
equal,
3567+
notEqual,
3568+
lessThanOrEqual,
3569+
greaterThanOrEqual,
3570+
greaterThan,
3571+
and,
3572+
} = firestorePipelinesModular;
3573+
const { getFirestore, collection, doc, setDoc } = firestoreModular;
3574+
const db = getFirestore(DATABASE_ID);
3575+
const docPath = `${COLLECTION}/${Utils.randString(12, '#aA')}`;
3576+
3577+
await setDoc(doc(db, docPath), {
3578+
base: 20,
3579+
divisor: 4,
3580+
exp: 3,
3581+
tag: 'alpha',
3582+
mirrorTag: 'alpha',
3583+
score: 42,
3584+
cap: '50',
3585+
status: 'active',
3586+
});
3587+
3588+
if (Platform.other) {
3589+
const macSnapshot = await execute(
3590+
db
3591+
.pipeline()
3592+
.documents([docPath])
3593+
.select(
3594+
subtract(field('base'), true).as('subTrue'),
3595+
divide(field('base'), false).as('divFalse'),
3596+
pow(field('exp'), true).as('powTrue'),
3597+
equal(field('tag'), field('mirrorTag')).as('tagsMatch'),
3598+
equal(field('tag'), 'alpha').as('tagStringRhs'),
3599+
lessThanOrEqual(field('score'), '50').as('scoreLteString'),
3600+
),
3601+
);
3602+
3603+
macSnapshot.results.should.have.length(1);
3604+
const macData = macSnapshot.results[0].data();
3605+
macData.subTrue.should.equal(19);
3606+
macData.divFalse.should.equal(0);
3607+
macData.powTrue.should.equal(3);
3608+
macData.tagsMatch.should.equal(true);
3609+
macData.tagStringRhs.should.equal(true);
3610+
macData.scoreLteString.should.equal(true);
3611+
return;
3612+
}
3613+
3614+
const snapshot = await execute(
3615+
db
3616+
.pipeline()
3617+
.documents([docPath])
3618+
.select(
3619+
subtract(field('base'), true).as('subTrue'),
3620+
subtract(field('base'), '5').as('subString'),
3621+
divide(field('base'), field('divisor')).as('divField'),
3622+
pow(field('exp'), true).as('powTrue'),
3623+
equal(field('tag'), field('mirrorTag')).as('tagsMatch'),
3624+
equal(field('tag'), 'alpha').as('tagStringRhs'),
3625+
notEqual(field('status'), 'inactive').as('statusActive'),
3626+
lessThanOrEqual(field('score'), '50').as('scoreLteString'),
3627+
greaterThanOrEqual(field('base'), false).as('baseGteFalse'),
3628+
),
3629+
);
3630+
3631+
snapshot.results.should.have.length(1);
3632+
const data = snapshot.results[0].data();
3633+
data.subTrue.should.equal(19);
3634+
data.subString.should.equal(15);
3635+
data.divField.should.equal(5);
3636+
data.powTrue.should.equal(3);
3637+
data.tagsMatch.should.equal(true);
3638+
data.tagStringRhs.should.equal(true);
3639+
data.statusActive.should.equal(true);
3640+
data.scoreLteString.should.equal(true);
3641+
data.baseGteFalse.should.equal(true);
3642+
3643+
const coll = collection(
3644+
db,
3645+
`${COLLECTION}/${Utils.randString(12, '#aA')}/operand-tail-raw-where`,
3646+
);
3647+
3648+
await Promise.all([
3649+
setDoc(doc(coll, 'match'), {
3650+
tag: 'alpha',
3651+
score: 42,
3652+
bump: true,
3653+
status: 'active',
3654+
}),
3655+
setDoc(doc(coll, 'skip-tag'), {
3656+
tag: 'beta',
3657+
score: 42,
3658+
bump: true,
3659+
status: 'active',
3660+
}),
3661+
setDoc(doc(coll, 'skip-score'), {
3662+
tag: 'alpha',
3663+
score: 10,
3664+
bump: true,
3665+
status: 'active',
3666+
}),
3667+
]);
3668+
3669+
const filtered = await execute(
3670+
db
3671+
.pipeline()
3672+
.collection(coll)
3673+
.where({
3674+
condition: {
3675+
operator: 'AND',
3676+
queries: [
3677+
{ operator: '==', fieldPath: 'tag', value: 'alpha' },
3678+
{ operator: '==', fieldPath: 'bump', value: true },
3679+
{ operator: '==', fieldPath: 'status', value: 'active' },
3680+
],
3681+
},
3682+
})
3683+
.select('tag', 'score', 'status'),
3684+
);
3685+
3686+
filtered.results.should.have.length(1);
3687+
filtered.results[0].data().tag.should.equal('alpha');
3688+
filtered.results[0].data().score.should.equal(42);
3689+
3690+
const whereSnapshot = await execute(
3691+
db
3692+
.pipeline()
3693+
.collection(coll)
3694+
.where(
3695+
and(
3696+
equal(field('tag'), 'alpha'),
3697+
equal(field('status'), 'active'),
3698+
greaterThanOrEqual(field('score'), false),
3699+
greaterThan(field('score'), 20),
3700+
),
3701+
)
3702+
.select('tag', 'score'),
3703+
);
3704+
3705+
whereSnapshot.results.should.have.length(1);
3706+
whereSnapshot.results[0].data().score.should.equal(42);
3707+
});
3708+
});
3709+
34763710
describe('aggregate expression argument lowering', function () {
34773711
it('evaluates aggregates with computed expression arguments', async function () {
34783712
const {

packages/firestore/ios/RNFBFirestore/RNFBFirestorePipelineNodeBuilder.swift

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,30 +1595,35 @@ final class RNFBFirestorePipelineNodeBuilder {
15951595
if let map = value as? [String: Any],
15961596
let kind = (map["exprType"] as? String)?.lowercased(), kind == "constant" {
15971597
if let boolValue = map["value"] as? Bool {
1598-
box.value = ConstantBridge(boolValue ? 1 : 0)
1598+
box.value = ConstantBridge(boolValue ? 1.0 : 0.0)
15991599
continue
16001600
}
1601-
box.value = try scalarConstantBridge(fromConstantMap: map, fieldName: currentFieldName)
1601+
if let number = map["value"] as? NSNumber, isBooleanNSNumber(number) {
1602+
box.value = ConstantBridge(number.boolValue ? 1.0 : 0.0)
1603+
continue
1604+
}
1605+
let constantValue = try constantExpressionValue(fromConstantMap: map, fieldName: currentFieldName)
1606+
box.value = try numericOperandConstantBridge(from: constantValue)
16021607
continue
16031608
}
16041609
if let values = value as? [Any] {
16051610
box.value = ConstantBridge(try resolveConstantValue(values, fieldName: currentFieldName))
16061611
continue
16071612
}
16081613
if let stringValue = value as? String {
1609-
box.value = ConstantBridge(stringValue)
1614+
box.value = try numericOperandConstantBridge(from: stringValue)
16101615
continue
16111616
}
16121617
if let boolValue = value as? Bool {
1613-
box.value = ConstantBridge(boolValue ? 1 : 0)
1618+
box.value = ConstantBridge(boolValue ? 1.0 : 0.0)
16141619
continue
16151620
}
16161621
if let number = value as? NSNumber, isBooleanNSNumber(number) {
1617-
box.value = ConstantBridge(number.boolValue ? 1 : 0)
1622+
box.value = ConstantBridge(number.boolValue ? 1.0 : 0.0)
16181623
continue
16191624
}
16201625
if isImmediateExpressionConstant(value) {
1621-
box.value = scalarConstantBridge(from: value)
1626+
box.value = try numericOperandConstantBridge(from: value)
16221627
continue
16231628
}
16241629
stack.append(.enter(value, currentFieldName, .expression, box))
@@ -1726,14 +1731,20 @@ final class RNFBFirestorePipelineNodeBuilder {
17261731
let leftFieldPath = try coerceFieldPath(fieldValue, fieldName: "\(currentField).fieldPath")
17271732
let right = map["value"] ?? map["right"] ?? map["operand"] ?? NSNull()
17281733
let rightBox = ExprBridgeBox()
1734+
let comparisonFunction = mapOperatorToFunction(normalizedOperator)
1735+
let normalizedComparisonFunction = canonicalizeFunctionName(comparisonFunction)
1736+
let rawWhereRightOperandMode: ExpressionCoercionMode =
1737+
orderingComparisonFunctions.contains(normalizedComparisonFunction)
1738+
? .numericOperand
1739+
: .comparisonOperand
17291740
stack.append(.binaryOperatorExit(
17301741
box,
1731-
mapOperatorToFunction(normalizedOperator),
1742+
comparisonFunction,
17321743
leftFieldPath,
17331744
rightBox,
17341745
currentField
17351746
))
1736-
stack.append(.enter(right, "\(currentField).value", .comparisonOperand, rightBox))
1747+
stack.append(.enter(right, "\(currentField).value", rawWhereRightOperandMode, rightBox))
17371748
break expressionLoop
17381749
}
17391750

@@ -2085,12 +2096,10 @@ final class RNFBFirestorePipelineNodeBuilder {
20852096
}
20862097
}
20872098
case let .functionExit(box, name, argBoxes, currentFieldName):
2088-
box.value = FunctionExprBridge(
2089-
name: name,
2090-
args: try argBoxes.enumerated().map { index, argBox in
2091-
try requireExpressionValue(argBox, fieldName: "\(currentFieldName).args[\(index)]")
2092-
}
2093-
)
2099+
let args = try argBoxes.enumerated().map { index, argBox in
2100+
try requireExpressionValue(argBox, fieldName: "\(currentFieldName).args[\(index)]")
2101+
}
2102+
box.value = FunctionExprBridge(name: name, args: args)
20942103
if RNFBFirestorePipelineDebug.enabled,
20952104
name == "add" || name.contains("array") || name.contains("equal") || name.contains("greater") {
20962105
RNFBFirestorePipelineDebug.log(
@@ -2199,6 +2208,27 @@ final class RNFBFirestorePipelineNodeBuilder {
21992208
value is GeoPoint || value is DocumentReference || value is VectorValue
22002209
}
22012210

2211+
2212+
private func numericOperandConstantBridge(from value: Any) throws -> ExprBridge {
2213+
if let boolValue = value as? Bool {
2214+
return ConstantBridge(boolValue ? 1.0 : 0.0)
2215+
}
2216+
if let number = value as? NSNumber, isBooleanNSNumber(number) {
2217+
return ConstantBridge(number.boolValue ? 1.0 : 0.0)
2218+
}
2219+
if let stringValue = value as? String {
2220+
let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
2221+
if !trimmed.isEmpty, let parsed = Double(trimmed), parsed.isFinite {
2222+
if let intValue = wholeNumberInt(from: NSNumber(value: parsed)) {
2223+
return ConstantBridge(intValue)
2224+
}
2225+
return ConstantBridge(parsed)
2226+
}
2227+
return ConstantBridge(stringValue)
2228+
}
2229+
return scalarConstantBridge(from: value)
2230+
}
2231+
22022232
private func tryIntegerLiteral(from value: Any) -> Int? {
22032233
if let number = value as? NSNumber {
22042234
return wholeNumberInt(from: number)

0 commit comments

Comments
 (0)