Skip to main content

src/glesia.gleam

//// Safe-ish Gleam bindings for Erlang Mnesia.
////
//// Glesia wraps the common Mnesia lifecycle and data operations while keeping
//// the dynamic BEAM boundary explicit. Table names and attributes are atoms;
//// records cross the FFI boundary as `Dynamic` values and can be decoded by
//// callers with normal Gleam decoders.

import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode.{type Decoder}
import gleam/erlang/atom.{type Atom}
import gleam/list
import gleam/result
import gleam/string

pub type MnesiaError {
  AlreadyExists
  NotFound
  Timeout
  BadType(String)
  Abort(String)
  Unknown(String)
}

pub type StorageType {
  RamCopies
  DiscCopies
  DiscOnlyCopies
}

pub type TableType {
  Set
  OrderedSet
  Bag
}

pub type SchemaResult {
  SchemaOk
  SchemaError(reason: Dynamic)
}

pub type SimpleResult {
  SimpleOk
  SimpleError(reason: Dynamic)
}

pub type TableCreateResult {
  TableCreateOk
  TableCreateAlreadyExists
  TableCreateError(reason: Dynamic)
}

pub type ReadResult {
  ReadOk(records: List(Dynamic))
  ReadError(reason: Dynamic)
}

pub type InsertNewResult {
  InsertNewInserted
  InsertNewExists
  InsertNewError(reason: Dynamic)
}

pub type TransactionResult {
  TransactionOk(value: Dynamic)
  TransactionAbort(reason: Dynamic)
}

@external(erlang, "glesia_ffi", "create_schema")
fn create_schema_ffi(nodes: List(Atom)) -> SchemaResult

@external(erlang, "glesia_ffi", "delete_schema")
fn delete_schema_ffi(nodes: List(Atom)) -> SchemaResult

@external(erlang, "glesia_ffi", "set_dir")
fn set_dir_ffi(path: String) -> SimpleResult

@external(erlang, "glesia_ffi", "start")
fn start_ffi() -> SimpleResult

@external(erlang, "glesia_ffi", "stop")
fn stop_ffi() -> SimpleResult

@external(erlang, "glesia_ffi", "create_table")
fn create_table_ffi(
  table: Atom,
  attributes: List(Atom),
  storage_type: String,
  table_type: String,
) -> TableCreateResult

@external(erlang, "glesia_ffi", "dirty_write")
fn dirty_write_ffi(record: Dynamic) -> SimpleResult

@external(erlang, "glesia_ffi", "dirty_read")
fn dirty_read_ffi(table: Atom, key: Dynamic) -> ReadResult

@external(erlang, "glesia_ffi", "dirty_delete")
fn dirty_delete_ffi(table: Atom, key: Dynamic) -> SimpleResult

@external(erlang, "glesia_ffi", "insert_new")
fn insert_new_ffi(table: Atom, key: Dynamic, record: Dynamic) -> InsertNewResult

@external(erlang, "glesia_ffi", "transaction")
fn transaction_ffi(fun: fn() -> value) -> TransactionResult

pub fn create_schema(nodes: List(Atom)) -> Result(Nil, MnesiaError) {
  case create_schema_ffi(nodes) {
    SchemaOk -> Ok(Nil)
    SchemaError(reason) -> Error(classify(reason))
  }
}

pub fn create_local_schema() -> Result(Nil, MnesiaError) {
  create_schema([node()])
}

pub fn delete_schema(nodes: List(Atom)) -> Result(Nil, MnesiaError) {
  case delete_schema_ffi(nodes) {
    SchemaOk -> Ok(Nil)
    SchemaError(reason) -> Error(classify(reason))
  }
}

pub fn delete_local_schema() -> Result(Nil, MnesiaError) {
  delete_schema([node()])
}

pub fn set_dir(path: String) -> Result(Nil, MnesiaError) {
  case set_dir_ffi(path) {
    SimpleOk -> Ok(Nil)
    SimpleError(reason) -> Error(classify(reason))
  }
}

pub fn start() -> Result(Nil, MnesiaError) {
  case start_ffi() {
    SimpleOk -> Ok(Nil)
    SimpleError(reason) -> Error(classify(reason))
  }
}

pub fn stop() -> Result(Nil, MnesiaError) {
  case stop_ffi() {
    SimpleOk -> Ok(Nil)
    SimpleError(reason) -> Error(classify(reason))
  }
}

pub fn create_table(
  table: Atom,
  attributes: List(Atom),
  storage_type: StorageType,
  table_type: TableType,
) -> Result(Nil, MnesiaError) {
  case
    create_table_ffi(
      table,
      attributes,
      storage_type_to_string(storage_type),
      table_type_to_string(table_type),
    )
  {
    TableCreateOk -> Ok(Nil)
    TableCreateAlreadyExists -> Error(AlreadyExists)
    TableCreateError(reason) -> Error(classify(reason))
  }
}

pub fn create_ram_table(
  table: Atom,
  attributes: List(Atom),
) -> Result(Nil, MnesiaError) {
  create_table(table, attributes, RamCopies, Set)
}

pub fn dirty_write(record: Dynamic) -> Result(Nil, MnesiaError) {
  case dirty_write_ffi(record) {
    SimpleOk -> Ok(Nil)
    SimpleError(reason) -> Error(classify(reason))
  }
}

pub fn dirty_read(
  table: Atom,
  key: Dynamic,
) -> Result(List(Dynamic), MnesiaError) {
  case dirty_read_ffi(table, key) {
    ReadOk(records) -> Ok(records)
    ReadError(reason) -> Error(classify(reason))
  }
}

pub fn dirty_read_decoded(
  table: Atom,
  key: Dynamic,
  decoder: Decoder(value),
) -> Result(List(value), MnesiaError) {
  dirty_read(table, key)
  |> result.try(fn(records) {
    records
    |> list.try_map(fn(record) {
      decode.run(record, decoder)
      |> result.map_error(fn(error) { BadType(string.inspect(error)) })
    })
  })
}

pub fn dirty_delete(table: Atom, key: Dynamic) -> Result(Nil, MnesiaError) {
  case dirty_delete_ffi(table, key) {
    SimpleOk -> Ok(Nil)
    SimpleError(reason) -> Error(classify(reason))
  }
}

/// Atomically write a record only when no record exists for the given key.
///
/// This uses a transactional Mnesia read with a write lock, then writes the
/// record inside the same transaction. It returns `Ok(True)` when inserted and
/// `Ok(False)` when the key already exists.
pub fn insert_new(
  table: Atom,
  key: Dynamic,
  record: Dynamic,
) -> Result(Bool, MnesiaError) {
  case insert_new_ffi(table, key, record) {
    InsertNewInserted -> Ok(True)
    InsertNewExists -> Ok(False)
    InsertNewError(reason) -> Error(classify(reason))
  }
}

pub fn transaction(fun: fn() -> value) -> Result(Dynamic, MnesiaError) {
  case transaction_ffi(fun) {
    TransactionOk(value) -> Ok(value)
    TransactionAbort(reason) -> Error(Abort(string.inspect(reason)))
  }
}

fn storage_type_to_string(storage_type: StorageType) -> String {
  case storage_type {
    RamCopies -> "ram_copies"
    DiscCopies -> "disc_copies"
    DiscOnlyCopies -> "disc_only_copies"
  }
}

fn table_type_to_string(table_type: TableType) -> String {
  case table_type {
    Set -> "set"
    OrderedSet -> "ordered_set"
    Bag -> "bag"
  }
}

fn classify(reason: Dynamic) -> MnesiaError {
  let inspected = string.inspect(reason)

  case inspected {
    "already_exists" -> AlreadyExists
    "not_found" -> NotFound
    "timeout" -> Timeout
    _ -> Unknown(inspected)
  }
}

@external(erlang, "erlang", "node")
fn node() -> Atom