Result Representations That Did Not Work

Problem Statement

After the isolate-transfer measurements, resqlite had a clearer target but not yet a design. This was the stage where several plausible ideas had to be made concrete enough to fail.

The result shape needed to satisfy three constraints at once:

  1. Keep the transferred object graph small.
  2. Keep heavy decode and materialization work off the main isolate.
  3. Preserve a normal row API for callers.

Several plausible designs met one or two of those constraints, but not all three.

Background

The tempting direction was "make the transfer smaller." That was directionally correct, but incomplete. A design that transfers fewer objects can still lose if it makes the main isolate decode strings, build values, or reconstruct rows during normal use. The core project constraint was not "fewest bytes over the port"; it was "least disruptive database API for Flutter rendering."

The rejected experiments were useful because they clarified which costs mattered and which costs merely moved around. This became a pattern for the project: rejected work still counted if it narrowed the design space and left behind numbers future experiments could trust.

Two Dart runtime details are useful context here. TransferableTypedData is designed for efficiently moving byte sequences between isolates, but creating it is still proportional to the number of bytes. SendPort.send can move ordinary Dart objects between isolates, but mutable object graphs may be copied and the cost is tied to the graph being sent. These APIs are powerful, but neither removes the need to choose a representation that matches where work should happen.

Hypotheses

Three hypotheses were tested:

What We Tried

Experiment 005 encoded rows into bytes on the worker, transferred the bytes, and decoded maps afterward. It tested the most obvious "make it bytes" answer.

Experiment 006 deduplicated repeated strings so the graph would contain fewer distinct string objects. It tested whether the current map shape could be kept by shaving off repeated values.

Experiment 008b packed rows into a compact byte buffer and exposed each row as a lazy map that decoded values on access. It tested whether row materialization could be delayed instead of eliminated.

Results

The Dart binary codec was consistently slower than the VM serializer:

Rows Object transport Binary codec Result
100 29 us 240 us object 8x faster
1,000 166 us 851 us object 5x faster
5,000 724 us 5,088 us object 7x faster
10,000 1,402 us 8,304 us object 6x faster

String interning looked promising in an isolated transfer benchmark, but failed in the full query path:

Implementation (5,000 rows) Wall vs baseline
Without value interning 3.22 ms -
With value interning 5.30 ms 64% slower

The byte-backed result set showed the core trade-off:

Path (20,000 rows) Wall Main
Direct maps + Isolate.exit 27.45 ms 2.12 ms
Byte-backed full iteration 11.92 ms 7.29 ms
Byte-backed first 20 rows 4.50 ms 0.01 ms

For partial access, byte-backed rows were excellent. For full access, they moved UTF-8 decode and value materialization onto the main isolate, which violated the original constraint.

Outcome

The rejected designs narrowed the acceptable shape. The solution could be lazy only if the deferred work was cheap. It could use bytes only if callers did not have to decode them on the UI isolate. It could reduce object count only if it did not add data-shape assumptions such as low-cardinality strings.

That led directly to the flat-list ResultSet: materialize values on the worker, transfer a compact graph, and defer only lightweight row facade creation. The next accepted design was less exotic than the failed byte-backed versions, but it matched the product constraint better.

Reference Docs