Id

Status

This part of relm4-store is rather set in stone. I don't expect any changes here.

What it is?

Id is a value which uniquely identifies the record. In our case it's about record in the store. If your store holds records from database, you can reuse the database id.

Application vs database

Let's talk a bit about records lifetime. If we would talk about the record from database point of view, you create a record by inserting all the data required and in most cases db somehow generates id for you. Every database I've worked with had some way to give unique id to the new record. So database doesn't need to deal with records which can't be identified by the key.

On the other hand your application first creates a record. Now you need to do two things at the same time

  1. Show the record to the user
  2. Persist record in the storage

If you show it before persisting then you send id which was generated by the database to all places where your record is shown so database and your application don't diverge. If you first persist and later show the record to the user it might lead to unresponsive application since user can be waiting for db instead of working.

Id structure

relm4-store-record separates id into two concepts

  • Memory representation
  • Id state

Memory representation

relm4-store gives you freedom how your id is structured in the memory. You can use usize, or Uuid, or i32, or your custom structure. Memory representation must implement

  • Copy
  • PartialEq
  • Eq
  • std::hash::Hash
  • std::fmt::Debug

PartialEq and Eq

PartialEq and Eq are required so we can tell wherever given one instance of the id is equal to other so we can distinguish if we talk about the different instance of the same record or not.

Example:

You would like to mark the task in the todo application as complete. You pull the record from the store. Update the complete field. Now you send it back to the store with new value. But store returned a copy of a task and not a mutable reference. Now inside of the store implementation we need to find the original task and replace it with a new value. To do so we use id.

Hash

Hash is required because there is no order which can be defined on id and we somehow need to be able to create a mapping id -> record and this gives us access to two excellent data structures HashMap and HashSet.

Copy

In general case record is many times larger then id. If we would keep ordered vector of records any shuffling operation would cause a massive amount of memory operations related to cloning records. On the other hand id is small and it's size should be perfectly known which makes memory copy super efficient operation.

Debug

If something goes wrong relm4-store will report an issue using record id. Since id is unique it should give you enough information about which record caused an issue.

Implementing memory representation

To let relm4-store what is memory representation of your id you must implement relm4-store-record::TemporaryIdAllocator trait. It's defined as

pub trait TemporaryIdAllocator: Clone + Debug {
    /// Type of values on which `Id` is based of
    /// 
    /// This type defines memory representation of the id for the record
    type Type: Copy + PartialEq + Hash + Eq + std::fmt::Debug;
    /// Returns value of new **temporary** id
    /// 
    /// Every call must return new different value otherwise it's possible to have a conflict which could end up
    /// with data loss
    fn new_id() -> Self::Type;
}

Type defines the memory layout of your id and all of the requirements mentioned before. Now lets go to the hard part, new_id method. Why it's hard? In single execution of your application every time you call it it must return unique value. If you call it twice to different values. You call it from another thread it returns different value. Wherever you call it in your application it must return different value each time and to make it worse only state it has at it's disposal is global one. In C there is rand() function. There are a lot of similarities in expectations about how new_id and rand works.

Until now we've focused on why it's hard to write new_id. Now let's talk about thinks that makes it easier for you. The most important part is, that it must return value which is unique only for given execution of the application. If you run your application twice it's not expected that values don't duplicate in such a case. It's expected that it will return values proper for Id::New. They might be illegal for Id::Permanent.

Provided id allocators

This is a list of id allocators provided by the relm4-store out of the box

Allocator nameMemory representationNotes
UuidAllocatoruuid::Uuid
DefaultIdAllocatoruuid::UuidType alias to UuidAllocator

Uuid was chosen as default since it's easy to implement and provides much better guarantees on uniqueness of the id then required. UuidAllocator::new_id returns random v4 uuid based on operating system RNG as the source of random bytes.

Why there is no allocator for i32, u32, i64, u64

I'll discuss it based on i32 example. All other allocators follows the same logic so if you replace i32 in this section with any other value arguments will still hold.

To return different values for each new_id call we need to hold a counter somewhere in global scope. Let's use AtomicI32 value to do that. Now if we only use this allocator once in our application that's fine. Since id's will be given proper values. If you use it for two kinds of records the new_id requirements are still kept since they are unique in scope of two stores and not just one but is it valid in your application logic? Do you expect generated id's to keep an order? Or maybe you need them unique but random so they are not predictable for security reasons?

There is a lot of decisions to make when you choose your temporary id allocator, so we've given up on implementing allocators requiring global state. In examples there is one which will show how to implement i32 id allocator.

Examples

In relm4-store-examples crate you can find following examples showing custom memory representations for id's

Example nameDescription
24_char_id_allocatorShows how to implement an id which consist of 24 characters, both for new and permanent ids
id_allocator_with_different_types_for_new_and_permanentShows how to implement an id which has different memory representation for new and permanent id's
i32_allocatorShows how to implement allocator returning consecutive i32 values

Id state

This part is provided for you by the relm4-store as relm4-store-record::Id enum. It has two variants New and Permanent. New is for records which has not been persisted. Permanent is for records which were.

Definition of the relm4-store-record::Id

pub enum Id<T> 
where
    T: ?Sized + Record,
{
    /// Id for records which were not committed yet to store
    New{
        /// Value of the id
        value: <T::Allocator as TemporaryIdAllocator>::Type,
    },
    /// Id for records which are persisted already
    /// 
    /// What persisted means depends on the store.
    Permanent {
        /// Value of the id
        value: <T::Allocator as TemporaryIdAllocator>::Type,
    }
}

Persistance of the record is something which depends on the store/backend implementation. Some backends might consider persistance to be at the moment of inserting a data into the store. Some when remote server responded with acknowledgment of storing the data. Some when they serialize the content of the store to the hard disk. This transition might require some special attention from you when dealing with records.

Why so complex?

This whole machinery allows to reliably describe a state where records are not stored yet and they still retain uniqueness required by the rest of the application. This allows user to work with your software while application still waits for persisting a record. This also allows you to perform the switch to the new id generated by your storage without user ever being interrupted.