Skip to content

The handler seam's only set read is a bounded id-prefix listing ​

A handler can ask a StateStore for the rows whose declared id starts with a PREFIX, ascending in that id's own order, up to a REQUIRED limit, and for nothing else: no predicate, no caller-supplied ordering, no offset. That is MutationContext.list and its two backend verbs listCurrent / listAsOf. The richer surface @etherfold/state-store-sqlite already has (queryCurrent / queryAsOf, which take caller-supplied SQL) is untouched and stays where it is, above the seam.

Why the surface is this narrow ​

The seam had no set read at all, and the cost is measured rather than imagined. In work/notes/findings/sqlite-in-the-browser.md a real launched processor's ordered bounded array cost THREE entities plus a hand-maintained CSV of positions, kept purely so a cascade delete had something to walk, and one pop() became an O(cells x players) loop of manual deletes; that path ran 100 times on the real stream. The fix the prior art points at is not aggregation but the READ side: The Graph's @derivedFrom is "never actually created during indexing", so children are their own entity keyed by their parent and the collection is derived WHEN READ.

The bound is the decision, not an implementation detail. A handler runs once per event on every backend, including the ones with no query planner, and a key-prefix range with a limit is the one shape that is an indexed range scan everywhere: a primary-key prefix scan in SQLite, an IDBKeyRange.bound([epoch], [epoch, []]) cursor in IndexedDB, a sorted walk in memory. A where or an orderBy would let a handler express, in one line and with no local signal, something that no index can serve on the backend it happens to be deployed against. So an accidental full scan is made impossible to EXPRESS rather than merely discouraged, and the price is that some questions have to be answered by MODELLING (key a child by (blockNumber, logIndex) if arrival order matters) or above the seam.

Consequences ​

  • Ordering is lexicographic over the stringified id, because that is the order a range scan gives for free and ids are already stringified for identity. '10' sorts before '9', so a key that must sort numerically has to be fixed-width or zero-padded. Sorting numerically would have required a type per id column, which the declaration does not carry (fields excludes the id), and would have made the seam's order differ from the index's.
  • The string order is UTF-8 byte order (code point order), on every backend and for every read (amended 2026-09-29, the-listings-id-order-is-decided). "Lexicographic" alone did not say which string order, and the backends disagreed: memory, patch and IndexedDB listed in UTF-16 code units (JavaScript's <, IndexedDB's comparison of a string key), SQLite in UTF-8 bytes (BINARY collation over UTF-8 TEXT), and MutationContext.list re-sorted its merge in UTF-16 whatever order its store had cut in, so on SQLite a listing CUT by its limit inside a block kept a different SET of rows than the same listing outside it. The two orders differ only for an id above U+FFFF against one from U+E000 to U+FFFF, which no id today (addresses, hashes, decimals) contains, but ADR-0098 treats a declaration meaning different things on a server and in a browser as the failure to avoid, and the accessor seam already promises UTF-8 for text (ADR-0099). So: compareIds compares each id column by its UTF-8 bytes (compareUtf8), which fixes the in-memory stores and the merge in one place, and IndexedDB keys each id column by its UTF-8 BYTES, a binary key it compares bytewise, so its range scan walks the same order with nothing sorted after it. That changed IndexedDB's key layout, and a database written with the old layout is discarded at the version change and rebuilt empty rather than migrated (nobody holds one), so it is never read in the old order; the readable id stays in each record's values. The conformance case bounded id-prefix listing asserts UTF-8 order for every read on every backend with no per-backend override, including a cut inside a block, on memory, patch, SQLite, IndexedDB under fake-indexeddb and IndexedDB on Chromium, Firefox and WebKit. The evidence the decision was taken on is docs/spikes/the-listings-id-order-per-backend/README.md; the engines' answers after it are docs/spikes/the-listings-id-order-is-decided/results/browser-id-order.json.
  • Truncation is REPORTED, not inferred. list answers {rows, truncated} and every backend fetches one row more than the limit to fill it in. rows.length === limit cannot distinguish an exact answer from a cut-off one, and a cascade delete that mistakes the second for the first leaves orphans silently. That is the same rule the rest of this seam follows (ADR-0015, ADR-0019): a plausible wrong answer is worse than a flag or a refusal.
  • The prefix must name at least the first id column. An empty prefix would mean "the whole entity, bounded by the limit", which reads as harmless and is not: unanchored, SQLite stops riding the id index and sorts into a temp b-tree, so the one property this shape exists to guarantee would quietly not hold. "The first N rows of a table" is a query-planner question and lives above the seam.
  • Every backend must implement it, and the conformance suite asks for it (bounded id-prefix listing, plus the read-your-writes cases where a listing has to merge the block's staging area rather than fall through to the store).