glindex

Type-safe IndexedDB bindings for Gleam.

Package VersionHex Docs

gleam add glindex@1
import gleam/dynamic/decode
import gleam/javascript/promise
import gleam/option
import glindex.{type Database, type Index, type Store}
import glindex/database
import glindex/index
import glindex/store
import glindex/transaction
import glindex/upgrade

pub type TrackStore

pub type Track {
  Track(id: Int, title: String, artist: String)
}

pub fn track_store() -> Store(TrackStore, _, _, _) {
  glindex.store(
    name: "tracks",
    to_value: fn(track: Track, action: glindex.Action) {
      case action {
        glindex.Add ->
          glindex.object([
            #("title", glindex.string(track.title)),
            #("artist", glindex.string(track.artist)),
          ])
        glindex.Put ->
          glindex.object([
            #("id", glindex.int(track.id)),
            #("title", glindex.string(track.title)),
            #("artist", glindex.string(track.artist)),
          ])
      }
    },
    decoder: {
      use id <- decode.field("id", decode.int)
      use title <- decode.field("title", decode.string)
      use artist <- decode.field("artist", decode.string)
      decode.success(Track(id:, title:, artist:))
    },
    to_key: fn(id: Int) { glindex.int(id) },
    key_decoder: decode.int,
  )
}

pub fn track_artist_index() -> Index(TrackStore, _, _, _) {
  glindex.index(
    name: "tracks_artist",
    to_index_key: fn(artist: String) { glindex.string(artist) },
    index_key_decoder: decode.string,
  )
}

pub fn main() -> promise.Promise(Nil) {
  use db_result <- promise.await(
    database.new("MyApp", 1)
    |> database.add_version(1, fn(tx) {
      let assert Ok(s) =
        upgrade.create_store(
          tx,
          "tracks",
          upgrade.StoreOptions(
            key_path: upgrade.KeyPath("id"),
            auto_increment: True,
          ),
        )
      let assert Ok(_) =
        upgrade.create_index(
          tx,
          upgrade.index(s, "tracks_artist"),
          upgrade.KeyPath("artist"),
          upgrade.index_options(),
        )
      Nil
    })
    |> database.open(),
  )

  case db_result {
    Error(_) -> promise.resolve(Nil)
    Ok(db) -> {
      use _ <- promise.await(add_track(
        db,
        Track(id: 0, title: "Bohemian Rhapsody", artist: "Queen"),
      ))
      use tracks <- promise.await(get_tracks_by_artist(db, "Queen"))
      let _ = echo tracks
      database.close(db)
      promise.resolve(Nil)
    }
  }
}

pub fn add_track(
  db: Database,
  track: Track,
) -> promise.Promise(Result(Track, transaction.TransactionError)) {
  let tx = transaction.prepare(db, transaction.read_write)
  let #(tx, s) = transaction.store(tx, track_store())
  use tx <- promise.await(transaction.begin(tx))
  case tx {
    Ok(tx) -> {
      use maybe_id <- promise.await(store.add(tx, s, track))
      case maybe_id {
        Ok(id) -> store.get(tx, s, glindex.Only(id))
        Error(e) -> promise.resolve(Error(e))
      }
    }
    Error(e) -> promise.resolve(Error(e))
  }
}

pub fn get_tracks_by_artist(
  db: Database,
  artist: String,
) -> promise.Promise(Result(List(Track), transaction.TransactionError)) {
  let tx = transaction.prepare(db, transaction.read_only)
  let #(tx, s) = transaction.store(tx, track_store())
  let idx = transaction.index(s, track_artist_index())
  use tx <- promise.await(transaction.begin(tx))
  case tx {
    Ok(tx) -> index.get_all(tx, idx, glindex.Only(artist), option.None)
    Error(e) -> promise.resolve(Error(e))
  }
}

Defining stores and indexes

Each store is declared as a function returning a Store value that bundles the store name together with its serializer, decoder, key serializer, and key decoder. This keeps everything in one place and makes the compiler verify that the right codecs are used throughout.

pub type TrackStore

pub fn track_store() -> Store(TrackStore, _, _, _) {
  glindex.store(
    name: "tracks",
    to_value: fn(track: Track, action: glindex.Action) {
      case action {
        glindex.Add ->
          glindex.object([
            #("title", glindex.string(track.title)),
            #("artist", glindex.string(track.artist)),
          ])
        glindex.Put ->
          glindex.object([
            #("id", glindex.int(track.id)),
            #("title", glindex.string(track.title)),
            #("artist", glindex.string(track.artist)),
          ])
      }
    },
    decoder: {
      use id <- decode.field("id", decode.int)
      use title <- decode.field("title", decode.string)
      use artist <- decode.field("artist", decode.string)
      decode.success(Track(id:, title:, artist:))
    },
    to_key: fn(id: Int) { glindex.int(id) },
    key_decoder: decode.int,
  )
}

The action parameter in to_value distinguishes Add (insert) from Put (upsert). Use it when the serialized form must differ between the two - for example, when the key is auto-generated by IndexedDB on Add and must therefore be omitted from the object, but included on Put so the existing record is correctly identified.

Indexes are declared the same way:

pub fn track_artist_index() -> Index(TrackStore, _, _, _) {
  glindex.index(
    name: "tracks_artist",
    to_index_key: fn(artist: String) { glindex.string(artist) },
    index_key_decoder: decode.string,
  )
}

pub fn track_artist_album_index() -> Index(TrackStore, _, _, _) {
  glindex.index(
    name: "tracks_artist_and_album",
    to_index_key: fn(key: #(String, String)) {
      glindex.array([glindex.string(key.0), glindex.string(key.1)])
    },
    index_key_decoder: {
      use first <- decode.field(0, decode.string)
      use second <- decode.field(1, decode.string)
      decode.success(#(first, second))
    },
  )
}

The phantom type TrackStore links each index to its store at the type level. The compiler will reject any attempt to use track_artist_index() with a store other than track_store(), catching mismatches before they reach the browser.

Opening a database

Use database.new to declare the database name and target version, then chain database.add_version calls to register incremental migrations. Each migration only runs when the database is being upgraded past that version. database.open returns a Promise.

database.new("MyApp", 2)
|> database.add_version(1, fn(tx) {
  let assert Ok(s) =
    upgrade.create_store(
      tx,
      "tracks",
      upgrade.StoreOptions(key_path: upgrade.KeyPath("id"), auto_increment: True),
    )
  let assert Ok(_) =
    upgrade.create_index(
      tx,
      upgrade.index(s, "tracks_artist"),
      upgrade.KeyPath("artist"),
      upgrade.index_options(),
    )
  Nil
})
|> database.add_version(2, fn(tx) {
  let s = upgrade.store(tx, "tracks")
  let assert Ok(_) =
    upgrade.delete_index(tx, upgrade.index(s, "tracks_artist"))
  let assert Ok(_) =
    upgrade.create_index(
      tx,
      upgrade.index(s, "tracks_artist_and_album"),
      upgrade.CompositeKeyPath(["artist", "album"]),
      upgrade.index_options(),
    )
  Nil
})
|> database.open()

Transactions

All database operations run inside a transaction. Build one with transaction.prepare, register the stores you need with transaction.store, then await the Promise returned by transaction.begin.

let tx = transaction.prepare(db, transaction.read_write)
let #(tx, s) = transaction.store(tx, track_store())
use tx <- promise.await(transaction.begin(tx))
case tx {
  Ok(tx) -> {
    use result <- promise.await(store.add(tx, s, track))
    ...
  }
  Error(e) -> promise.resolve(Error(e))
}

Use transaction.read_only when you only need reads - IndexedDB can run multiple read-only transactions concurrently.

Transaction lifetime

IndexedDB auto-closes a transaction as soon as it has no pending requests and all microtasks have been processed. Do not await anything unrelated to the database inside a transaction - for example, an HTTP request or a timer. If the event loop goes idle between two database operations, the transaction will have already committed and the next operation will fail.

// OK - every await is a database operation on the same transaction
use id <- promise.await(store.add(tx, s, track))
use _  <- promise.await(store.get(tx, s, glindex.Only(id)))

// WRONG - the transaction closes during the HTTP request
use id       <- promise.await(store.add(tx, s, track))
use response <- promise.await(http.get("/api/confirm"))  // transaction is now closed
use _        <- promise.await(store.get(tx, s, glindex.Only(id)))  // fails

Store operations live in glindex/store; index operations live in glindex/index. Both modules expose functions that accept a transaction handle and return a Promise.

store.get(tx, s, glindex.Only(42))
store.get_all(tx, s, glindex.All, option.None)
store.add(tx, s, track)
store.put(tx, s, track)
store.delete(tx, s, glindex.Only(42))

index.get(tx, idx, glindex.Only("Queen"))
index.get_all(tx, idx, glindex.Only("Queen"), option.None)
index.count(tx, idx, glindex.All)

Queries

glindex.Query controls which records an operation targets. The query type is parameterised by the key type of the store or index, so values are passed directly without manual conversion.

Constructor Meaning
All Every record
Only(value) Exact key match
LowerBound(value, exclusive) Keys >= (or >) value
UpperBound(value, exclusive) Keys <= (or <) value
Bound(lower, upper, excl_lower, excl_upper) Key range
glindex.Only(42)
glindex.LowerBound(100, False)
glindex.Bound("a", "z", False, True)

Cursors

Cursors let you walk through a range of records one at a time, optionally mutating or deleting each one as you go. Open one with store.open_cursor or index.open_cursor.

The handler receives the current accumulator and the cursor, and must return a Promise of the new accumulator paired with a navigation instruction.

use result <- promise.await(
  store.open_cursor(tx, s, glindex.All, cursor.Next, [], fn(acc, cur) {
    case cursor.cursor_value(cur) {
      Ok(track) -> promise.resolve(#([track, ..acc], cursor.continue()))
      Error(_) -> promise.resolve(#(acc, cursor.stop()))
    }
  }),
)

Navigation instructions:

Function Effect
cursor.continue() Advance to the next record
cursor.advance(n) Skip n records forward
cursor.continue_key(key) Jump to the first record with key >= key
cursor.continue_primary_key(key, primary_key) Jump to a specific index key + primary key pair (index cursors only)
cursor.stop() Stop iteration and return the accumulator

Inside a read_write cursor you can also mutate or delete the current record:

store.open_cursor(tx, s, glindex.Only(artist), cursor.Next, Nil, fn(_, cur) {
  case cursor.cursor_value(cur) {
    Ok(track) -> {
      use _ <- promise.map(cursor.cursor_update(cur, Track(..track, artist: new_name)))
      #(Nil, cursor.continue())
    }
    Error(_) -> promise.resolve(#(Nil, cursor.stop()))
  }
})

Use store.open_key_cursor or index.open_key_cursor when you only need the key - these skip fetching the full record value and are faster for counting or bulk deletes.

Further documentation can be found at https://hexdocs.pm/glindex.