step 02 — SQL surface and MVCC

Goal

Add exec_update, exec_delete, select_by_tag, and the secondary index. By the end of this step:

  • exec_update must be a no-op (and not bump next_txid) if the row is missing or tombstoned. If present, it keeps the original created_at and only mutates v and tag.
  • exec_delete must be a no-op if the row is missing or tombstoned. If present, it sets deleted_at = next_txid and removes the row from the secondary index. The row stays in the primary.
  • The secondary index tag -> sorted Vec<i64> is maintained on every mutating op. Only live rows are present.
  • select_by_tag(tag) returns the secondary list, or empty.

Tasks

  1. Wire exec_update and exec_delete with the no-op-on-missing rule. Test it by calling each on a key that does not exist and asserting next_txid did not move.
  2. Implement secondary insertion as sorted insert (binary search + shift, or BTreeMap::entry().or_default() + sorted insert).
  3. Implement secondary removal as sorted lookup + erase. If the list becomes empty, drop the tag entirely (otherwise the snapshot will carry empty entries and diverge from the spec).
  4. Add a test that inserts three rows with the same tag in scrambled key order, then asserts select_by_tag returns them in ascending order.
  5. Add a test for the resurrection path: insert, delete, insert again on the same key. The new row must have a fresh created_at and deleted_at == 0.

Pitfalls

  • The most common bug is bumping next_txid in exec_update even on a no-op. The unit tests in one language will pass; the cross-language hash will diverge after the first missing-key update.
  • Forgetting to drop an empty tag from secondary after the last delete will add a zero-length entry to the snapshot dump and break cross-language byte equality.
  • In C++, std::map::operator[] default-constructs missing entries silently — use find for reads and [] only when you intend the insert.