Skip to content

The #5869 list-comparand rule is enforced only at the engine seam: a scalar in/nin comparand still reaches a driver, and on mingo >= 7.2.3 it escapes as a raw TypeError #9228

Description

@os-project-manager

Surfaced by the weekly Dependabot production group #9212 (mingo 7.2.2 -> 7.2.4), which is red on this and nothing else. The bump did not break us — it removed the third-party coercion that was hiding a coverage hole in an already declared rule.

Measurement (verified on GitHub, not recalled)

PR #9212 @ 45eaf8db3a03fe30d8798069439aad74e4c219c5, two independent jobs, same single failure:

FAIL packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts
> InMemoryDriver filter vocabulary <-> VALID_AST_OPERATORS > expresses notin without dropping it
AssertionError: promise rejected "TypeError: b.filter is not a function" instead of resolving
> $in node_modules/.pnpm/mingo@7.2.4/.../operators/_predicates.js:95
> $nin node_modules/.pnpm/mingo@7.2.4/.../operators/_predicates.js:99

mingo's own changelog for 7.2.4 names the change: "make $elemMatch with empty field and $in/$nin with regex behave correctly (#613)". 7.2.2 tolerated a non-array $in/$nin operand; 7.2.4 does not.

Why this is our defect, not mingo's

The contract is already declared, and its gate already exists — it just is not on every path in.

  1. FieldOperatorsSchema (packages/spec/src/data/filter.zod.ts) declares $in: z.array(z.any()), $nin: z.array(z.any()), $between: z.tuple([min, max]).
  2. packages/objectql/src/filter-comparand-shape.ts (数据 API:集合算子 not_in/in 的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 / PR fix(objectql): 集合算子的标量比较值答 400 INVALID_FILTER 并点名期望形状,不再 500 (#5869) #6209) enforces it — assertListComparandShapes, called from engine.ts, answers 400 INVALID_FILTER. Its header states the rule verbatim: "parseFilterAST lowers ['status', 'not_in', 'done'] to { status: { $nin: 'done' } } without complaint — so a scalar reached the driver", and it explains why the gate went to the engine seam rather than into each driver (both driver families are under the [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 investment freeze).
  3. The spec-side comparand-type door added by PR feat(spec): comparand-type door — the accepted literal comparand set, enforced once at the shared compile face for all five drivers (#7872) #8234 (driver-memory has no policy for an unsupported comparand TYPE: a BigInt crashes with a raw mingo TypeError, and four other types silently answer zero rows #7872) sits at exactly the seam where a shape check would belong, and deliberately routes past it — packages/spec/src/data/filter-comparand-type.ts:
}elseif(LIST_COMPARAND_OPERATORS.has(op)&&Array.isArray(comparand)){// Each member is a comparand in its own right (#5234). A non-array// comparand here is a SHAPE defect owned by the engine's #5869 gate.

So: parseFilterAST() lowers a scalar membership comparand without complaint, and only a query that goes through the objectql engine meets the gate. A caller that reaches a driver directly — InMemoryDriver.find(), which is what this driver's own conformance suite does and what an embedder does — meets nothing. Until now that path was carried by mingo's coercion; from mingo 7.2.3 on it is an unhandled third-party TypeError with no code and no status, reaching the caller.

That is the same class of escape #7872 closed for comparand types, one branch over: types are judged at the shared door, shape is not.

packages/drivers/driver-memory/src/memory-driver.ts:820-821 is where it lands ({ [field]: { $nin: store(value) } }, no normalization, no refusal). The test only exercises it because valueFor (memory-filter-ast-vocabulary.test.ts:80-87) hands an array to in / nin / not_in but a scalar to the equally-accepted notin and not in spellings — an accident that turned out to be the one probe covering this hole.

Ruling (not open for re-adjudication by the dev)

Premises — verify before you build, and report a fork instead of working around them

  1. Dependency direction allows the sink. The rule has to end up somewhere parseFilterAST can reach, i.e. in packages/spec, with @objectstack/objectql delegating to it. objectql depends on spec, so the reverse import is not available. If the assertion cannot move without dragging engine-only concepts into spec, that is a fork — report it, do not invent a third home.
  2. Nothing in-tree authors a scalar membership comparand. Tightening parseFilterAST refuses input that today survives to the engine gate anyway (which already answers 400), but stored view metadata, examples, docs fixtures and objectui view definitions must be checked, not assumed. Grep for authored in / nin / not_in / notin / not in rules with non-array values across examples/**, content/docs/**, packages/**/fixtures/** and the objectui view metadata. If real authored usage exists, stop and report the fork with the sites: normalizing a scalar to a one-element list is then a live option and that is the maintainer's call, not yours.

If either premise fails: report premise_still_valid: false with the measurement. Do not hard-do it, and do not quietly switch to the other option.

Suggested route (advisory — measurement beats it)

In filter-comparand-type.ts the LIST_COMPARAND_OPERATORS branch already knows the operator is list-shaped; the Array.isArray(comparand) guard silently falls through today. Make the else arm a loud refusal reusing the existing envelope (INVALID_FILTER, 400) and the #5869 wording, then have assertListComparandShapes delegate. Keep the message's front-loaded shape — the 500-char client bound is real (#5423).

Acceptance

  • parseFilterAST([['name', 'notin', 'alpha']]) (and the nin / not_in / not in spellings) answers a coded INVALID_FILTER / 400 refusal, with a test that asserts bothcode and status, not just that it throws.
  • pnpm --filter @objectstack/driver-memory test green, with memory-filter-ast-vocabulary.test.ts exercising every membership spelling on valid input.
  • The engine path keeps its existing behaviour and its existing tests (engine-comparand-type-door.test.ts, the 数据 API:集合算子 not_in/in 的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 suites) — one implementation, unchanged verdicts.
  • Measured, not assumed: run the driver-memory suite once against mingo@7.2.4 via a temporary local override to confirm the escape is gone, then revert the override — report the reading in the PR body, commit no manifest or lockfile change.
  • Local gates for this surface (taken from node scripts/pm/dispatch-gates.mjs at dispatch time, re-take for your actual diff): pnpm check:test-source-alias, pnpm check:type-source-resolution, node scripts/docs-audit/check-affected-docs.mjs; and because a test file moves: pnpm check:query-options-erasure, pnpm check:type-check-coverage, pnpm check:type-check-debt (needs the workspace built first), pnpm check:engine-double-contract, pnpm check:where-matcher.
  • A patch changeset is required (published packages change).

Re-check commands for the premises above

git fetch origin main
git show origin/main:packages/spec/src/data/filter-comparand-type.ts | grep -n "SHAPE defect owned by"
git show origin/main:packages/objectql/src/filter-comparand-shape.ts | grep -n "export function assertListComparandShapes"
git grep -n "notin\|not_in" origin/main -- examples content packages/*/fixtures

Links

Metadata

Metadata

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions