Detail Bug Report
https://app.detail.dev/org_a5ffdadd-e247-41b5-868a-d0307fa3d4c6/bugs/bug_66565029-b4bc-4cd8-8552-f57debb5423e
Introduced in 8e11e17 by @fwal on Dec 8, 2025
Summary
- Context:
Query.or / Query.and in packages/effect-firebase/src/lib/firestore/query/query.ts build the Or / And composite constraints consumed by the admin, client and mock query builders.
- Bug:
Query.or(...queries) flattens each sub-query with queries.flat(), discarding the grouping of any sub-query that contains more than one constraint, so an And sub-query (which Query.and deliberately returns flat when it has no nested composite) collapses into separate OR operands.
- Actual vs. expected:
Query.or(Query.and(where('a','==',1), where('b','==',2)), where('c','==',3)) yields Or([Where a, Where b, Where c]) and is evaluated as a==1 OR b==2 OR c==3, instead of the intended Or([And([Where a, Where b]), Where c]) evaluating to (a==1 AND b==2) OR c==3.
- Impact: Firestore queries silently widen their filter — rows that the caller did not intend to match are returned. The mock (
applyConstraints), the Admin SDK (buildCompositeFilter) and the Client SDK (toFilterConstraint) all recurse over the children of And/Or faithfully, so the wrong tree is faithfully produced at every backend for the pure-Where case shown here. (Backends additionally throw on any non-filter child such as OrderBy/Limit inside a composite — see "Reach" below — so the silent corruption is limited to composites of Wheres.)
Code with Bug
// packages/effect-firebase/src/lib/firestore/query/query.ts
export const and = <S>(...queries: ReadonlyArray<Query<S>>): Query<S> => {
const constraints = queries.flat();
const hasComposite = constraints.some(
(c) => c._tag === 'And' || c._tag === 'Or',
);
if (hasComposite) {
return [new And({ constraints })] as Query<S>;
}
return constraints as Query<S>; // <-- BUG 🔴 `and` of all-Where args returns them flat (no `And` node), and grouping is forgotten when nested in `or`
};
export const or = <S>(...queries: ReadonlyArray<Query<S>>): Query<S> => {
const constraints = queries.flat(); // <-- BUG 🔴 flattening destroys per-argument grouping of any multi-constraint sub-query
return [new Or({ constraints })] as Query<S>;
};
Explanation
Query.and intentionally returns a flat list when its arguments contain no nested And/Or (common case for multiple Where filters).
Query.or flattens its arguments into a single constraints list. If one argument is a multi-constraint query (e.g., from Query.and(where a, where b) returning [Where a, Where b]), that argument is split into separate operands of the outer Or.
- This changes semantics from
(a AND b) OR c to a OR b OR c, widening results silently for pure-Where composites.
- This is reproducible end-to-end in the mock: intended tree
Or([And([a,b]), c]) returns ['1','2','3'], while Query.or(Query.and(a,b), c) returns ['1','2','3','4'] (includes a doc matching only a==1).
Recommended Fix
Preserve grouping of each or argument by wrapping any argument that contains more than one constraint in an And before flattening:
// packages/effect-firebase/src/lib/firestore/query/query.ts
export const or = <S>(...queries: ReadonlyArray<Query<S>>): Query<S> => {
const constraints = queries
.map((q) => (q.length > 1 ? [new And({ constraints: q })] : q))
.flat(); // <-- FIX 🟢 wrap multi-constraint sub-queries so their AND grouping survives
return [new Or({ constraints })] as Query<S>;
};
History
This bug was introduced in commit 8e11e17. The commit added "provisional support for queries" and authored both Query.and and Query.or from scratch with their current queries.flat() flattening logic — and deliberately returns a flat Where[] when no nested composite is present, and or flattens its arguments into a single Or node with no per-argument grouping preservation.
Detail Bug Report
https://app.detail.dev/org_a5ffdadd-e247-41b5-868a-d0307fa3d4c6/bugs/bug_66565029-b4bc-4cd8-8552-f57debb5423e
Introduced in 8e11e17 by @fwal on Dec 8, 2025
Summary
Query.or/Query.andinpackages/effect-firebase/src/lib/firestore/query/query.tsbuild theOr/Andcomposite constraints consumed by the admin, client and mock query builders.Query.or(...queries)flattens each sub-query withqueries.flat(), discarding the grouping of any sub-query that contains more than one constraint, so anAndsub-query (whichQuery.anddeliberately returns flat when it has no nested composite) collapses into separate OR operands.Query.or(Query.and(where('a','==',1), where('b','==',2)), where('c','==',3))yieldsOr([Where a, Where b, Where c])and is evaluated asa==1 OR b==2 OR c==3, instead of the intendedOr([And([Where a, Where b]), Where c])evaluating to(a==1 AND b==2) OR c==3.applyConstraints), the Admin SDK (buildCompositeFilter) and the Client SDK (toFilterConstraint) all recurse over the children ofAnd/Orfaithfully, so the wrong tree is faithfully produced at every backend for the pure-Wherecase shown here. (Backends additionally throw on any non-filter child such asOrderBy/Limitinside a composite — see "Reach" below — so the silent corruption is limited to composites ofWheres.)Code with Bug
Explanation
Query.andintentionally returns a flat list when its arguments contain no nestedAnd/Or(common case for multipleWherefilters).Query.orflattens its arguments into a single constraints list. If one argument is a multi-constraint query (e.g., fromQuery.and(where a, where b)returning[Where a, Where b]), that argument is split into separate operands of the outerOr.(a AND b) OR ctoa OR b OR c, widening results silently for pure-Wherecomposites.Or([And([a,b]), c])returns['1','2','3'], whileQuery.or(Query.and(a,b), c)returns['1','2','3','4'](includes a doc matching onlya==1).Recommended Fix
Preserve grouping of each
orargument by wrapping any argument that contains more than one constraint in anAndbefore flattening:History
This bug was introduced in commit
8e11e17. The commit added "provisional support for queries" and authored bothQuery.andandQuery.orfrom scratch with their currentqueries.flat()flattening logic —anddeliberately returns a flatWhere[]when no nested composite is present, andorflattens its arguments into a singleOrnode with no per-argument grouping preservation.