CompositeTerm
The key of a composite index: several fields of one element, spelled as one term.
$.items[*] over (sku, qty)
{"sku":"A","qty":5} -> field 0 | text:A | field 1 | numeric:5Stored whole rather than hashed, and that is the decision in this file. Postgres jsonb_path_ops — the precedent this kind is taken from — hashes each path-and-value pair into 32 bits, and the plan that proposed this said "the hash of (sku=A, qty=5)". It is not hashed here, for three reasons in increasing order of weight.
A hash written into a file is permanent, and format-permanence.md lists every one this engine has committed to. A third would be a third thing that can never change, bought to save bytes in a file that already front-codes.
The dictionary is built for this. A term region front-codes against the sorted sequence, and a composite term's leading bytes are the same field header and the same type tag for every element — so the shared prefix a tuple pays for is exactly the part a hash would have destroyed. Hashing would make every term incompressible and fixed-width; a tuple of two short values front-codes to a handful of bytes.
And a hash makes every answer inexact. A collision is a false positive, which the recheck absorbs — soundly, and at the price of a selectivity nobody could then state, since it would depend on a hash quality this project has not measured. Stored whole, a composite lookup is exact: the term says this element carried these values, so a plan may mark the leaf certain and skip the recheck exactly as an inverted equality leaf does. That is the difference between reading fewer documents and reading none.
The cost of the choice is stated rather than hidden: a tuple over long strings is a long term, and one above IndexOptions.maxTermBytes is not keyed at all. That is not a wrong answer — the query side applies the same bound to the same bytes, so a tuple the writer dropped is one the planner declines to look up and the leaf becomes a residual — but it is a real limit, and a corpus of long identifiers is where a hash would win.
What a term commits to
Every declared field, present, in declaration order. An element missing one contributes no term, which is what makes a lookup exact: a term exists only for an element that has the whole tuple, so finding it means finding an element that satisfied every conjunct. An element with extra fields contributes a term all the same — the tuple is over the declared fields and says nothing about the others, which is why a composite index answers the declared conjunction rather than a superset of it.
The field's position is part of the term. (a="x", b="y") and (a="y", b="x") must not share a spelling, so each value is preceded by its declared index. Concatenating the signatures alone would make a query over two text fields find elements with the values swapped — a wrong answer, and a silent one.
Both header fields are fixed-width, unlike the varints everywhere else in this module. The rule there is variable width only where a walk was happening anyway; here the bytes are the comparison key, and a variable-width prefix would give one tuple two spellings the moment a length crossed 128 — which is the canonicality rule IndexBytes.varint enforces, arriving from the other side.