| type | Reference | ||||||
|---|---|---|---|---|---|---|---|
| title | Firestore Pipelines implementation design | ||||||
| description | Architecture, bridge coercion, emulator setup, and e2e-driven native coverage for RNFB Firestore Pipelines. | ||||||
| tags |
|
||||||
| timestamp | 2026-06-22 00:00:00 UTC |
JS defines a pipeline; RNFB serializes it for native Firestore (Swift/Java), which executes it:
- Serialize — JS turns the pipeline into a plain JSON tree for the bridge.
- Parse + build — native reads JSON and rebuilds Firebase
Pipeline/ expression objects. - Execute — native runs against the cloud
pipelines-e2eEnterprise database and returns JSON results.
A subquery embeds an inner pipeline as PipelineValue inside scalar/array. Rule: parser pass 1 treats PipelineValue as opaque; pass 2 hands it to parsePipelineMap. Never expression-walk it; aggregates use kind, not name.
flowchart LR
JS["packages/firestore/lib/pipelines/*.ts"] --> SER["serialize() / pipelineExecute()"]
SER --> BRIDGE["RN bridge NSDictionary args"]
BRIDGE --> PARSER["RNFBFirestorePipelineParser (Swift / Java)"]
PARSER --> BUILDER["RNFBFirestorePipelineNodeBuilder"]
BUILDER --> FACTORY["RNFBFirestorePipelineBridgeFactory"]
FACTORY --> SDK["Firebase Firestore Pipeline SDK"]
Coverage strategy: native pipeline code is exercised through Detox/Jet e2e (Pipeline.e2e.js), not standalone XCTest/JUnit. See Coverage design.
Pipeline execute() requires Firestore Enterprise; local emulator is Standard.
| Database | Emulator in tests/app.js? |
Used for |
|---|---|---|
(default) |
Yes (:8080) |
Regular Firestore e2e |
second-rnfb |
Yes | Second-database e2e |
pipelines-e2e |
No | Pipeline e2e only (cloud Enterprise) |
CI uses emulator for standard products; pipeline execute hits cloud.
Enterprise emulator mode exists but RNFB does not use it; cloud is authoritative, including vector findNearest.
Emulator multi-DB/rules behavior broke Standard Firestore security-rules e2e when mixed with Enterprise pipeline work. Resolution: pipelines-e2e is cloud-only for execute/rules/indexes.
| Database | Rules file | Indexes file | Emulator | Cloud deploy |
|---|---|---|---|---|
(default) |
firestore.rules |
firestore.indexes.json |
Yes | Yes |
second-rnfb |
(same firestore.rules, database == "second-rnfb" guards) |
(none in repo) | Yes | No firebase.json entry |
pipelines-e2e |
firestore.pipelines-e2e.rules |
firestore.pipelines-e2e.indexes.json |
No | Yes |
Vector indexes live in firestore.pipelines-e2e.indexes.json only. Do not connectFirestoreEmulator(..., 'pipelines-e2e') without an Enterprise-emulator migration.
Deploy/sync: Firebase testing project — ./sync-firestore-indexes.sh, ./deploy-firestore.sh.
- Expression helpers build plain JSON-serializable trees;
execute()sends serialized pipeline to native. pipeline_support.ts— shared source/stage type lists and unsupported-message helper for native fallback paths.
RNFBFirestorePipelineNodeBuilder coerces bridge types, separates literal/expression slots, and builds ExprBridge / stage bridges.
subcollection(path)→ detached pipeline (no Firestore instance; directexecute()throws)..toScalarExpression()/.toArrayExpression()→PipelineValuewrapped inscalar/arrayfunction node.- Aggregates:
{ exprType: 'AggregateFunction', kind: 'countAll', … }— discriminant iskind, notname.
- Parser — bridge map → typed nodes.
- Node builder — typed nodes → raw map →
expressionLoop→ SDK bridges. Nested pipelines:extractNestedPipelineMap→parsePipelineMap( understands aggregatekind).
flowchart TB
RAW["scalar(args:[PipelineValue])"] --> P1["Parser pass 1"]
P1 -->|PipelineValue OPAQUE| P2["NodeBuilder pass 2"]
P2 --> NESTED["parsePipelineMap → parseAggregateStage"]
Why: Expression-walking PipelineValue reaches AggregateFunction nodes (kind, not name). Unhandled exprType + isExpressionLike causes valueEnter↔expressionEnter infinite loop; confirm hang with sample <pid>.
Fix: pass 1 stores PipelineValue raw; pass 2 re-parses via parsePipelineMap. Jest .serialize() does not catch this; need native e2e or sampled run.
Guard rails:
- Never route
PipelineValuethrough the generic expression/value walker. - New
exprTypewithout an explicit handler → opaque or explicit branch (unhandled +isExpressionLike= loop, not error). - Aggregates →
parseAggregateStageonly; scalars/booleans →name. - Regression:
pipelines-pathological.test.ts(JS); e2edescribe('pathological subqueries and recursion')— 111 iOS/Android, 106 macOS after fixes.
toArrayExpressionshape: single-fieldselect()in an array subquery returns scalar values — e.g.[3, 4, 5], not[{rating:3}, …].
Swift lacks reliable TCO; JVM stacks are bounded. Heap frame stacks bound depth by heap, not call stack.
Exception: buildNestedPipelineSubquery re-enters parsePipelineMap once per subquery nesting level — fine in practice; only absurd subquery depth grows the native stack.
Guard rail: never reintroduce recursive expression/value traversal in native parsers.
Probes: packages/firestore/__tests__/pipelines-pathological.test.ts.
| Issue | Cause | Threshold / fix |
|---|---|---|
| Deep expression tree walks | args visited explicitly and via Object.values → O(2^depth) |
Prefer single-visit iterative work-lists |
| Recursive tree walks | Recursive walk on generated depth | Iterative work-list for attacker/generator-controlled depth |
serializeValue still recursive |
Runs every execute() |
~5000 depth in Node (less Hermes) before RangeError; backend rejects deep nesting — low urgency |
Guard rails: visit each tree property once; prefer work-lists for attacker/generator-controlled depth.
Convert serializeValue to iterative if deep generated pipelines matter. Preserve WeakSet; add friendly "too deeply nested" error.
Each platform has a live execute-time lowering path and a dormant sibling.
| Platform | Live path | Dormant path |
|---|---|---|
| iOS | coerceExpressionTree (iterative) |
Recursive cluster — removed ~117 lines (0% coverage) |
| Android | EnterObjectExpressionFrame → scheduleExpressionFunctionLowering |
buildExpressionFunctionFromParsedArgs + coerceExpressionValueNode — interwoven; confirm with coverage before delete |
Lesson: Android scalar/array were implemented only on dormant path; live path treated array as literal and ignored scalar until scheduleExpressionFunctionLowering gained nested-pipeline handling.
Guard rails:
- Change the path that runs at
execute()— dormant-path cases are a trap. - Coverage disambiguates live vs dormant (0% block = suspect dormant code).
- e2e must run on all three platforms; iOS-only green hid the Android subquery gap.
From tests:<platform>:test-cover + post-processing; refresh with bash scripts/map-pipeline-coverage-gaps.sh <label>:
Use e2e coverage to distinguish live paths from dormant lowering code; current numbers/deltas live in work queue.
Live-path holes concentrate in expression lowering and stage coercion, not dormant parsed-node clusters. Remaining high-value gaps:
- Android: expression-frame lowering, parsed aggregate tails, executor branches.
- iOS: stage coercion, operand modes, map passthrough success paths.
- TS: runtime/validation branches reachable by direct Jest or e2e tamper tests.
Quantified tables/priorities: work queue. Script: bash scripts/map-pipeline-coverage-gaps.sh <label>.
Durable inventory of where live-path holes concentrate (live status and quantified baselines belong in the work queue, not here):
- E2e per live-but-untested operator/stage in
coerceExpressionTree(iOS) and live Android lowering, plus remaining executor error branches. - Android parsed-aggregate tail — partially live (
coerceAliasedAggregatefrom Executor); target with e2e, not deletion.
Completion standard: coverage expectations — touched sources reach 100% or an evidence-backed intractable limit; no cost/convenience deferral.
Compare-types exports: tracked separately from coverage expansion (work queue).
RN delivers JS booleans as CFBoolean NSNumber; integers also NSNumber, so 0/1 and bools collide.
Decision — scalarConstantBridge check order:
- Swift
Bool→ bool constant NSNumber+CFBooleanGetTypeID()→ bool before integer coercionwholeNumberInt(non-bool) → int- Swift
Int→ int - Fallback (string, double, nested)
xor / nor / count_if use boolean-expression coercion like and/or.
E2e mitigation: avoid ambiguous constant(0)/constant(1) in heterogeneous array([…]); use constant(2+) or non-numeric sentinel. Integer tagging deferred.
- Database:
pipelines-e2e(DATABASE_IDinPipeline.e2e.js); random collection suffixes; nohelpers.wipe()(emulator REST only). - CI: emulator for Standard modules;
Pipeline.e2e.jsexecute → cloud. Commands: Running e2e tests.
Cross-platform behavior is co-equal with coverage. Differences must be documented SDK limitations or closed bridge fixes, not silent e2e workarounds.
| Topic | Document |
|---|---|
| Parity policy, drift registry | Pipeline platform parity |
| SDK unsupported-function audit (repeatable) | Pipeline SDK support audit |
| Coverage + parity work queue | pipeline-coverage-work-queue.md |
Native bridge lowering gaps and SDK-only limitations are tracked in Pipeline platform parity (P-013–P-015 and bridge rows). There is no JS pre-execute function blocklist on iOS.
Running e2e tests; compare-types snapshots: Pipeline implementation workflow, Coverage design.