// Copyright (c) 2026 moonbit-multiindex contributors
// SPDX-License-Identifier: Apache-2.0

// Table core — records, generations, insertion order, and the chokepoints that
// keep the registry (`src/index.mbt`) consistent with them.
//
// Layout (docs/DEVPLAN.md §2):
//   rows    slot storage; `None` marks a vacant slot
//   gens    per-slot generation, bumped once per recycle
//   free    free-slot stack (LIFO); retired slots never re-enter it (I9)
//   order   live slots in insertion order
//   indexes named secondary index registry
//
// Chokepoint discipline (docs/DEVPLAN.md §4, docs/SPEC.md §5): `insert_impl`,
// `remove_impl` and `clear_impl` are the ONLY places allowed to touch
// rows + order + indexes + gens + free together. Everything else either reads,
// or validates first and then calls one of them.
//
// `clear_impl` is the bulk-removal path — the analogue of bimap's `retain` —
// retiring every live slot in one O(n) sweep instead of looping `remove_impl`
// (which would be O(n²) through the order compaction).

///|
/// Largest generation a slot may hold before it is retired for good (I9).
///
/// **MoonBit's `Int` is 32-bit signed** (`@int.max_value == 2147483647`) on every
/// target we ship — do not reason about it as 64-bit. `2^30` is the largest power
/// of two for which `GEN_MAX + 1` still fits, and it is far beyond any recycle
/// count reachable in practice.
///
/// Spelled as an explicit literal on purpose: `1 << 62` is *silently* reduced
/// modulo the 32-bit width to `1 << 30` — no error, no warning — so a shift would
/// change meaning without notice if `Int` ever widened.
const GEN_MAX : Int32 = 1073741824

///|
/// A record store with N secondary indexes over stable generational handles.
///
/// Records live in slots; `RowId` pairs a slot with the slot's generation, so a
/// handle that outlives its record is rejected instead of silently naming the
/// slot's next occupant (the ABA problem). Insertion order is preserved, and any
/// number of named secondary indexes (unique / non-unique, hashed / sorted) can be
/// registered over the records and are kept consistent by the chokepoints.
pub struct Table[K, V] {
  priv rows : Array[V?]
  priv gens : Array[Int32]
  priv free : Array[Int32]
  priv order : Array[Int32]
  priv positions : Array[Int32]
  priv indexes : Map[String, Index[K, V]]
  priv mut len : Int32
  priv mut version : Int32
}

// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------

///|
/// Create an empty table with no registered index.
pub fn[K, V] Table::new() -> Table[K, V] {
  {
    rows: [],
    gens: [],
    free: [],
    order: [],
    positions: [],
    indexes: Map([]),
    len: 0,
    version: 0,
  }
}

///|
/// Create an empty table whose slot storage is already sized for `cap` records.
///
/// The reserved slots are all vacant, so they all go on `free` — pushed
/// high-to-low, so the LIFO stack hands out slot 0 first and the first `cap`
/// inserts never have to grow the slot arrays. `cap <= 0` reserves nothing.
pub fn[K, V] Table::with_capacity(cap : Int32) -> Table[K, V] {
  let t = Table::new()
  let mut i = cap - 1
  while i >= 0 {
    t.rows.push(None)
    t.gens.push(0)
    t.positions.push(-1)
    t.free.push(i)
    i = i - 1
  }
  t
}

// ---------------------------------------------------------------------------
// Reads (no bounds: none of these touches an index)
// ---------------------------------------------------------------------------

///|
/// Number of live records.
pub fn[K, V] Table::len(self : Table[K, V]) -> Int32 {
  self.len
}

///|
/// Whether the table holds no live records.
pub fn[K, V] Table::is_empty(self : Table[K, V]) -> Bool {
  self.len == 0
}

///|
/// The record named by `id`, or `None` if `id` is stale.
///
/// A handle is stale when its generation no longer matches the slot's — the slot
/// was recycled (or the table cleared), so `id` names a record that is gone.
/// A stale handle never resolves to the slot's *current* occupant (ABA guard).
pub fn[K, V] Table::get(self : Table[K, V], id : RowId) -> V? {
  let slot = id.slot
  if slot < 0 || slot >= self.rows.length() {
    return None
  }
  if self.gens[slot] != id.gen {
    return None
  }
  self.rows[slot]
}

///|
/// Whether `id` names a live record (a stale handle answers `false`).
pub fn[K, V] Table::contains(self : Table[K, V], id : RowId) -> Bool {
  match self.get(id) {
    Some(_) => true
    None => false
  }
}

///|
/// The record in a live slot as `(handle, value)`; `None` when the slot is empty.
///
/// Reading the row copies the record value — the same shallow copy docs/SPEC.md
/// §3.3 accepts for queries (a record's own containers stay shared). A shared
/// container mutated outside the API does not trigger index maintenance (contract:
/// docs/SPEC.md §3.7); the removal / update paths tolerate and repair stale entries
/// since 0.3.0.
fn[K, V] Table::record_at(self : Table[K, V], slot : Int32) -> (RowId, V)? {
  if slot < 0 || slot >= self.rows.length() {
    return None
  }
  match self.rows[slot] {
    Some(value) => Some(({ slot, gen: self.gens[slot], }, value))
    None => None
  }
}

///|
/// Iterate the live records in insertion order, each as `(handle, value)`.
///
/// Mutating the table while an iteration is in flight aborts it (fail-fast,
/// keyed on the mutation counter `version`) instead of yielding a torn view.
pub fn[K, V] Table::iter(self : Table[K, V]) -> Iter2[RowId, V] {
  let mut pos = 0
  let len = self.order.length()
  let version = self.version
  Iter2::new(
    fn() -> (RowId, V)? {
      if self.version != version {
        abort("Table::iter: table mutated during iteration")
      }
      while pos < len {
        let slot = self.order[pos]
        pos = pos + 1
        match self.rows[slot] {
          Some(value) => return Some(({ slot, gen: self.gens[slot], }, value))
          None => continue
        }
      }
      None
    },
    size_hint=len,
  )
}

// ---------------------------------------------------------------------------
// Index maintenance
// ---------------------------------------------------------------------------

///|
/// The first unique-index collision `value` would cause, or `None`.
///
/// Read-only by construction: it projects `value` into every unique index and
/// asks for the current holder, so the caller can run it *before* any mutation
/// (docs/SPEC.md §5 rule 2) and still return a table that is byte-for-byte what
/// it was.
fn[K : Hash + Eq + Compare, V] Table::find_conflict(
  self : Table[K, V],
  value : V,
) -> Conflict[K]? {
  self.find_conflict_excluding(value, -1)
}

///|
/// The unique-index collision `value` would cause **against some record other than
/// `exclude_slot`** (pass `-1` to exclude nothing), or `None`.
///
/// Excluding self is what makes "write a record back under a key it already holds" a
/// no-op instead of a conflict — docs/SPEC.md §5 rule 1, the bug the original probe
/// caught. Like `find_conflict`, this mutates nothing.
///
/// When several indexes would be violated at once, the one whose **name sorts first**
/// is reported (docs/SPEC.md §7): the registry is a hash map, so returning whichever
/// index the iteration happened to reach first would make the error depend on internal
/// ordering. Collecting the violations costs nothing on the happy path — this function
/// only runs it when it has already found a conflict.
fn[K : Hash + Eq + Compare, V] Table::find_conflict_excluding(
  self : Table[K, V],
  value : V,
  exclude_slot : Int32,
) -> Conflict[K]? {
  let mut best_name : String? = None
  let mut best : Conflict[K]? = None
  for name, index in self.indexes {
    if index.is_unique() {
      let key = index.project()(value)
      match index.holder(key) {
        Some(slot) =>
          if slot != exclude_slot {
            let better = match best_name {
              None => true
              Some(current) => name < current
            }
            if better {
              best_name = Some(name)
              best = Some(
                UniqueViolation(name, key, { slot, gen: self.gens[slot], }),
              )
            }
          }
        None => ()
      }
    }
  }
  best
}

///|
/// What `insert_or_replace` would do with a record, decided **without touching the
/// table** (docs/SPEC.md §3.2.2).
///
/// - `Fresh` — no unique index holds any of the record's keys: it would be inserted.
/// - `Replace(slot)` — every occupied unique index is occupied by the single record
///   in `slot`. That record is the victim, and replacing it keeps its `RowId`.
/// - `Ambiguous(text)` — two **different** slots are hit. No single replacement can
///   honour both, so the call aborts before any mutation. `text` is built here
///   because `test "panic …"` proves only that *something* panicked — a read-only
///   classifier is the only place where the wording itself is testable
///   (src/table_wbtest.mbt).
///
/// Non-unique indexes never contribute: a bucket holds any number of records under
/// one key, so it cannot name a victim — it is rebuilt from the new record instead.
priv enum ReplaceTarget {
  Fresh
  Replace(Int32)
  Ambiguous(String)
}

///|
/// Classify `value` for `insert_or_replace` (docs/SPEC.md §3.2.2): which record, if
/// any, it would displace.
///
/// This is the pre-check half of `update`'s mechanism, written for the case where
/// the victim is not known in advance. Two rules are shared with
/// `find_conflict_excluding`: only unique indexes nominate a victim, and a
/// collision with the victim **itself** is not a collision. The difference is that
/// `update` is told which slot to exclude, whereas here the excluded slot is the
/// one being discovered — hence the grouping by slot rather than a single probe.
///
/// Mutates nothing on any path (it only reads bucket holders and generations), so
/// both the replace path and the abort path leave the table exactly as it was.
fn[K : Hash + Eq + Compare, V] Table::replace_target(
  self : Table[K, V],
  value : V,
) -> ReplaceTarget {
  let hits : Array[(String, Int32)] = []
  for name, index in self.indexes {
    if index.is_unique() {
      let key = index.project()(value)
      match index.holder(key) {
        Some(slot) => hits.push((name, slot))
        None => ()
      }
    }
  }
  if hits.is_empty() {
    return Fresh
  }
  // Name order, not registry order: the reported pair must not depend on how the
  // hash map happens to iterate (the same rule Conflict reporting follows).
  hits.sort_by_key(fn(hit) { hit.0 })
  let target = hits[0].1
  let mut i = 1
  while i < hits.length() {
    if hits[i].1 != target {
      let victim : RowId = { slot: target, gen: self.gens[target], }
      let other : RowId = { slot: hits[i].1, gen: self.gens[hits[i].1], }
      return Ambiguous(
        "Table::insert_or_replace: the new record collides with two different rows, index \"" +
        hits[0].0 +
        "\" holds " +
        Show::to_string(victim) +
        " and index \"" +
        hits[i].0 +
        "\" holds " +
        Show::to_string(other) +
        "; refusing the write, the table is unchanged",
      )
    }
    i = i + 1
  }
  Replace(target)
}

// ---------------------------------------------------------------------------
// Chokepoints (docs/DEVPLAN.md §4)
// ---------------------------------------------------------------------------

///|
/// Allocate a slot: reuse the most recently freed one (LIFO), or grow the slot
/// arrays by one. Retired slots are never in `free`, so they are never handed
/// out again (I9).
fn[K, V] Table::alloc_slot(self : Table[K, V]) -> Int32 {
  match self.free.pop() {
    Some(slot) => slot
    None => {
      let slot = self.rows.length()
      self.rows.push(None)
      self.gens.push(0)
      self.positions.push(-1)
      slot
    }
  }
}

///|
/// Drop `slot` from `order`, keeping the insertion order of the rest (O(n)).
fn[K, V] Table::order_remove(self : Table[K, V], slot : Int32) -> Unit {
  let n = self.order.length()
  let mut i = 0
  while i < n {
    if self.order[i] == slot {
      let mut j = i
      while j + 1 < n {
        let moved = self.order[j + 1]
        self.order[j] = moved
        self.positions[moved] = j
        j = j + 1
      }
      self.order.pop() |> ignore
      self.positions[slot] = -1
      return
    }
    i = i + 1
  }
  abort("Table::order_remove: slot " + slot.to_string() + " is not in `order`")
}

///|
/// Drop `slot` from `order` by moving the last entry into its place (O(1)).
///
/// The moved entry is a **slot number**, so `rows` is never touched: every live
/// `RowId` keeps pointing at the same record. The price is that iteration order
/// stops matching insertion order — that is why this is opt-in via
/// `Table::swap_remove`, next to the order-preserving `Table::remove`.
fn[K, V] Table::order_swap_remove(self : Table[K, V], slot : Int32) -> Unit {
  let pos = self.positions[slot]
  if pos < 0 {
    abort(
      "Table::order_swap_remove: slot " +
      slot.to_string() +
      " has no position in `order`",
    )
  }
  let last = self.order.length() - 1
  if pos != last {
    let moved = self.order[last]
    self.order[pos] = moved
    self.positions[moved] = pos
  }
  self.order.pop() |> ignore
  self.positions[slot] = -1
}

///|
/// CHOKEPOINT (insert): take a slot, store the record, append it to `order`, and
/// add it to every registered index.
///
/// Uniqueness was already checked by `Table::insert` — this function is the
/// commit half, and it cannot fail.
fn[K : Hash + Eq + Compare, V] Table::insert_impl(
  self : Table[K, V],
  value : V,
) -> RowId {
  let slot = self.alloc_slot()
  self.rows[slot] = Some(value)
  self.order.push(slot)
  self.positions[slot] = self.order.length() - 1
  self.len = self.len + 1
  self.version = self.version + 1
  match self.rows[slot] {
    Some(record) =>
      for _, index in self.indexes {
        let key = index.project()(record)
        index.add(slot, key)
      }
    None => abort("Table::insert_impl: the slot it just wrote is empty")
  }
  { slot, gen: self.gens[slot], }
}

///|
/// CHOKEPOINT (remove): drop the record from every index, clear the row, bump the
/// slot generation, recycle the slot — or retire it for good once the generation
/// is exhausted (I9) — and drop the slot from `order`. Returns the record that
/// lived there.
///
/// `swap` picks how the slot leaves `order`: `false` compacts the tail, keeping
/// insertion order (O(n)); `true` moves the last entry into the hole (O(1)), which
/// is what `Table::swap_remove` exposes. Everything else — indexes, generation,
/// free list, `len`, `version` — is identical, so the two entry points can never
/// drift apart.
///
/// Only `Table::remove` / `Table::swap_remove` — after validating the handle — may
/// call this. `clear_impl` performs the same retirement inline, in bulk.
fn[K : Hash + Eq + Compare, V] Table::remove_impl(
  self : Table[K, V],
  slot : Int32,
  swap : Bool,
) -> V {
  let value = match self.rows[slot] {
    Some(v) => v
    None =>
      abort(
        "Table::remove_impl: slot " + slot.to_string() + " holds no live record",
      )
  }
  for _, index in self.indexes {
    let key = index.project()(value)
    // Tolerant removal: a record mutated outside the API no longer projects to
    // the bucket that still holds it. Only then does the slow scan run; the
    // normal path never reaches `detach_any` (docs/SPEC.md §3.2).
    if !index.remove(slot, key) {
      index.detach_any(slot) |> ignore
    }
  }
  self.rows[slot] = None
  if self.gens[slot] < GEN_MAX {
    self.gens[slot] = self.gens[slot] + 1
    self.free.push(slot)
  }
  if swap {
    self.order_swap_remove(slot)
  } else {
    self.order_remove(slot)
  }
  self.len = self.len - 1
  self.version = self.version + 1
  value
}

///|
/// Snapshot every registered index's projection of the live record in `slot`,
/// keyed by index name.
///
/// `modify` takes this **before** running the caller's closure. The closure can
/// mutate a container the record shares with the caller, in which case the record
/// still in the table is no longer a reliable source for the *old* keys — reading
/// them after the closure would retarget the wrong bucket (docs/SPEC.md §3.2).
/// A vacant slot yields an empty map; `modify_impl` aborts on it before consulting
/// the map.
fn[K, V] Table::projection_snapshot(
  self : Table[K, V],
  slot : Int32,
) -> Map[String, K] {
  let snapshot : Map[String, K] = Map([])
  match self.rows[slot] {
    Some(record) =>
      for name, index in self.indexes {
        snapshot.set(name, index.project()(record))
      }
    None => ()
  }
  snapshot
}

///|
/// CHOKEPOINT (update): swap the record in `slot` for `value`, retargeting only
/// the index buckets whose projected key really changes.
///
/// The index work happens **before** the row is overwritten, so no bucket ever
/// points at a slot whose projection has stopped matching (I4), and the equal-key
/// indexes are not touched at all (docs/SPEC.md §5 rule 3). The caller must have
/// completed every uniqueness pre-check — this is the commit half.
///
/// Returns the record it displaced. Handing it back is free here (the caller
/// already had it in hand) and it is what lets `insert_or_replace` report the
/// overwritten value without a second lookup through the index registry.
fn[K : Hash + Eq + Compare, V] Table::modify_impl(
  self : Table[K, V],
  slot : Int32,
  value : V,
  old_keys : Map[String, K],
) -> V {
  let previous = match self.rows[slot] {
    Some(record) => record
    None =>
      abort(
        "Table::modify_impl: slot " + slot.to_string() + " holds no live record",
      )
  }
  for name, index in self.indexes {
    let project = index.project()
    let old_key = match old_keys.get(name) {
      Some(key) => key
      None => abort("Table::modify_impl: no old projection for index " + name)
    }
    index.retarget(slot, old_key, project(value)) |> ignore
  }
  self.rows[slot] = Some(value)
  self.version = self.version + 1
  previous
}

///|
/// CHOKEPOINT (bulk removal): retire every live slot in one O(n) sweep, then drop
/// the insertion order and every index bucket.
///
/// Each live slot is retired exactly the way `remove_impl` retires one — row
/// cleared, generation bumped, slot pushed back on `free` (unless the generation
/// is exhausted, in which case the slot is retired for good). That is what keeps
/// `clear` from resurrecting old handles: a handle issued before the clear can
/// never resolve to a record stored after it (docs/SPEC.md §3.2).
///
/// Index *registrations* survive: `clear` drops records and index entries, not the
/// schema, so the next insert is indexed again.
fn[K, V] Table::clear_impl(self : Table[K, V]) -> Unit {
  let mut i = 0
  while i < self.order.length() {
    let slot = self.order[i]
    self.rows[slot] = None
    self.positions[slot] = -1
    if self.gens[slot] < GEN_MAX {
      self.gens[slot] = self.gens[slot] + 1
      self.free.push(slot)
    }
    i = i + 1
  }
  self.order.clear()
  for _, index in self.indexes {
    index.clear()
  }
  self.len = 0
}

// ---------------------------------------------------------------------------
// Public writes
// ---------------------------------------------------------------------------

///|
/// Store `value` and return its stable handle.
///
/// Every unique index is probed **before anything is written** (docs/SPEC.md §5
/// rule 2). On a collision the table is exactly what it was, and the record comes
/// back inside `Err(Rejected)` — MoonBit passes records by value, so reporting the
/// conflict alone would destroy the caller's data.
pub fn[K : Hash + Eq + Compare, V] Table::insert(
  self : Table[K, V],
  value : V,
) -> Result[RowId, Rejected[K, V]] {
  match self.find_conflict(value) {
    Some(conflict) => Err({ conflict, record: value, })
    None => Ok(self.insert_impl(value))
  }
}

///|
/// Store every record of `values`, or none of them.
///
/// The whole batch is checked **before anything is written** (docs/SPEC.md §11):
/// each record is probed against the live table *and* against the earlier members of
/// the same batch, so a duplicate **inside** the batch fails exactly like one that
/// clashes with an existing record. On `Err` the table is what it was, and the
/// offending record comes back inside `Rejected` (the caller keeps its array too —
/// MoonBit arrays are references).
///
/// The reference implementation's `FromIterator` is **first-wins**; this library
/// deliberately does not copy that, because a silent winning rule contradicts
/// "a conflict is never silently overwritten" (the deviation is registered in
/// `tools/diffgen/README.md`).
///
/// `holder` in the conflict: against a live record it is that record's handle; for a
/// clash **inside the batch** it is the handle the earlier member would receive once
/// the batch is accepted, since no record is live there yet (see CHANGELOG Notes).
pub fn[K : Hash + Eq + Compare, V] Table::insert_many(
  self : Table[K, V],
  values : Array[V],
) -> Result[Array[RowId], Rejected[K, V]] {
  // Per unique index: the keys this batch has already claimed, and by which member.
  let claimed : Map[String, Map[K, Int32]] = Map([])
  let mut i = 0
  while i < values.length() {
    let value = values[i]
    // (a) Collect every violation this member hits — against a live record or against
    //     an earlier member — and report the one whose index name sorts first
    //     (docs/SPEC.md §7).
    let mut best_name : String? = None
    let mut best : Conflict[K]? = None
    for name, index in self.indexes {
      if index.is_unique() {
        let key = index.project()(value)
        let mut candidate : Conflict[K]? = None
        match index.holder(key) {
          Some(slot) =>
            candidate = Some(
              UniqueViolation(name, key, { slot, gen: self.gens[slot], }),
            )
          None =>
            match claimed.get(name) {
              Some(seen) =>
                match seen.get(key) {
                  // Not UniqueViolation: nothing is live at that key, and the batch
                  // position says exactly which member got there first.
                  Some(at) => candidate = Some(DuplicateInBatch(name, key, at))
                  None => ()
                }
              None => ()
            }
        }
        match candidate {
          Some(conflict) => {
            let better = match best_name {
              None => true
              Some(current) => name < current
            }
            if better {
              best_name = Some(name)
              best = Some(conflict)
            }
          }
          None => ()
        }
      }
    }
    match best {
      Some(conflict) => return Err({ conflict, record: value, })
      None => ()
    }
    // (b) No violation: claim this member's keys, in a second pass so that a refused
    //     member leaves no trace in `claimed`.
    for name, index in self.indexes {
      if index.is_unique() {
        let key = index.project()(value)
        match claimed.get(name) {
          Some(seen) => seen.set(key, i)
          None => {
            let seen : Map[K, Int32] = Map([])
            seen.set(key, i)
            claimed.set(name, seen)
          }
        }
      }
    }
    i = i + 1
  }
  let ids : Array[RowId] = []
  let mut j = 0
  while j < values.length() {
    ids.push(self.insert_impl(values[j]))
    j = j + 1
  }
  Ok(ids)
}

///|
/// Insert `value`, or **replace the record it collides with — keeping that record's
/// `RowId`** (docs/SPEC.md §3.2.2).
///
/// Keeping the `RowId` is the point of the method: `remove` + `insert` would retire
/// the old handle and hand out a fresh one, silently stranding every handle the
/// caller already holds. The logical record survives the write, so its identity
/// must survive with it.
///
/// - No unique key is taken: exactly `insert`, `Ok((new_row, None))`.
/// - Exactly one record holds one or more of the new keys: it is replaced in place,
///   `Ok((its_row, Some(displaced_value)))`. Non-unique indexes are rebuilt from the
///   new record — a multi bucket never decides *who* is replaced.
/// - **Two different records** are hit, one per unique index: no single replacement
///   can honour both, and arbitrarily dropping one would corrupt the caller's data
///   model. The call **aborts**, naming both indexes and both `RowId`s. No new
///   `Conflict` variant carries this: adding one would break exhaustive matches at
///   every existing `Err` site (docs/SPEC.md §3.2.2).
///
/// Atomicity: `replace_target` decides the victim with a read-only scan, then
/// `update`'s pre-check runs with the victim excluded, and only then does
/// `modify_impl` — `update`'s own commit chokepoint — touch the table. The replace
/// path shares one pre-check and one commit path with `update`, and every refusal
/// path leaves the table byte-for-byte unchanged.
pub fn[K : Hash + Eq + Compare, V] Table::insert_or_replace(
  self : Table[K, V],
  value : V,
) -> Result[(RowId, V?), Rejected[K, V]] {
  match self.replace_target(value) {
    Fresh => Ok((self.insert_impl(value), None))
    Replace(slot) =>
      // Exactly the check `update` runs, with the victim excluded (rule 1). While
      // `replace_target` is correct it can only return `None`; it is kept so that a
      // classifier regression degrades to `update`'s `Err` instead of corrupting I5.
      match self.find_conflict_excluding(value, slot) {
        Some(conflict) => Err({ conflict, record: value, })
        None => {
          let old_keys = self.projection_snapshot(slot)
          let displaced = self.modify_impl(slot, value, old_keys)
          let id : RowId = { slot, gen: self.gens[slot], }
          Ok((id, Some(displaced)))
        }
      }
    Ambiguous(text) => abort(text)
  }
}

///|
/// Replace the record named by `id` with `value`.
///
/// The **excluding-self** check runs first (docs/SPEC.md §5 rule 1): writing a
/// record back under a key it already holds is not a conflict. Every unique index
/// is probed before anything is written, so on `Err` the table is exactly what it
/// was and `value` travels back inside `UpdateRejected`. A stale `id` yields
/// `Err(StaleHandle(id, Some(value)))` — the caller's record is never swallowed.
pub fn[K : Hash + Eq + Compare, V] Table::update(
  self : Table[K, V],
  id : RowId,
  value : V,
) -> Result[Unit, UpdateRejected[K, V]] {
  match self.get(id) {
    None => Err(StaleHandle(id, Some(value)))
    Some(_) =>
      match self.find_conflict_excluding(value, id.slot) {
        Some(conflict) => Err(Conflict(conflict, value))
        None => {
          let old_keys = self.projection_snapshot(id.slot)
          self.modify_impl(id.slot, value, old_keys) |> ignore
          Ok(())
        }
      }
  }
}

///|
/// Replace the record named by `id` with `f(record)`.
///
/// `f` runs exactly once, before any mutation, and only the index buckets whose
/// projected key changes are touched (docs/SPEC.md §5 rule 4 — the ordering the
/// reference implementation gets wrong). A stale `id` yields
/// `Err(StaleHandle(id, None))`: with no record to transform there is no new
/// record to hand back.
pub fn[K : Hash + Eq + Compare, V] Table::modify(
  self : Table[K, V],
  id : RowId,
  f : (V) -> V,
) -> Result[Unit, UpdateRejected[K, V]] {
  match self.get(id) {
    None => Err(StaleHandle(id, None))
    Some(previous) => {
      // Snapshot the *old* projections before the closure runs: it may mutate a
      // container the record shares with the caller (docs/SPEC.md §3.2).
      let old_keys = self.projection_snapshot(id.slot)
      let value = f(previous)
      match self.find_conflict_excluding(value, id.slot) {
        Some(conflict) => Err(Conflict(conflict, value))
        None => {
          self.modify_impl(id.slot, value, old_keys) |> ignore
          Ok(())
        }
      }
    }
  }
}

///|
/// Remove and return the record named by `id`, or `None` if `id` is stale.
///
/// A rejected handle leaves the table — and the slot's current occupant —
/// completely untouched.
pub fn[K : Hash + Eq + Compare, V] Table::remove(
  self : Table[K, V],
  id : RowId,
) -> V? {
  match self.get(id) {
    None => None
    Some(_) => Some(self.remove_impl(id.slot, false))
  }
}

///|
/// Remove and return the record named by `id` in **O(1)**, or `None` if `id` is
/// stale.
///
/// This is `remove` with one difference: the last slot in `order` is moved into
/// the hole instead of compacting the tail. Records are not moved — every other
/// live `RowId` stays valid — but **iteration order no longer matches insertion
/// order** after a `swap_remove`. Use `remove` when you need insertion order,
/// `swap_remove` when you only need speed (see docs/SPEC.md §6).
///
/// A rejected handle leaves the table — and the slot's current occupant —
/// completely untouched, exactly like `remove`.
pub fn[K : Hash + Eq + Compare, V] Table::swap_remove(
  self : Table[K, V],
  id : RowId,
) -> V? {
  match self.get(id) {
    None => None
    Some(_) => Some(self.remove_impl(id.slot, true))
  }
}

///|
/// Drop every record and every index entry. Index registrations stay, so the next
/// insert is indexed again.
///
/// Storage is kept (the slot arrays are recycled, not freed) and every handle
/// issued before the call becomes stale, so a later insert can never resolve an
/// old handle. A clear of an already-empty table is not a change and leaves
/// `version` alone (I8).
pub fn[K, V] Table::clear(self : Table[K, V]) -> Unit {
  if self.len > 0 {
    self.clear_impl()
    self.version = self.version + 1
  }
}