SpacetimeDB icon indicating copy to clipboard operation
SpacetimeDB copied to clipboard

Typesafe primary keys

Open drdozer opened this issue 2 months ago • 4 comments

As it stands, we choose a type for primary keys from a closed set of possibilities, e.g. u32. This makes foreign keys non-typesafe.

#[table(name = foo, public)]
struct Foo {
  #[primary_key],
  #[auto_inc],
  pub id: u32,
}

#[table(name = bar, public)]
struct Bar {
  pub foo_id: u32;
}

This prevents the compiler from preventing mistakes where a u32 that did not come from a Foo.id is used as a value for Bar.foo_id. I did try to manually implement something, but it seems some of the traits needed to make this work aren't accessible to mortals. My proposal is something like this:

#[table(name = foo, public)]
struct Foo {
  #[primary_key(key_type=u32],
  #[auto_inc],
  pub id: Foo::ID,
}

#[table(name = bar, public)]
struct Bar {
  #[derive(Default)]
  pub foo_id: Foo::ID;
}

This would generate:

mod Foo {
struct ID(u32);

... glue to box/unbox u32 through the SpacetimeType, Filterable, and other required machinery
}

The semantics would be that if a key_type is provided to primary_key, it must be a single-member tuple struct where the contained value is itself valid as a primary key type. The Default value would always be the "not set" flag value, to be replaced in an auto_inc field. The wrapping ID type would not appear in the wire protocol and probably not in the schema, but it may be glued back in for the client facades.

I hope I didn't fumble the rust snippets - I haven't run this through an IDE first.

drdozer avatar Oct 23 '25 19:10 drdozer

Hey! Great idea.

If you could hand me some code of your attempt where you faced the trait accessibility issue (perhaps, your primary_key macro patch), I can try implementing it into a proper PR!

egormanga avatar Oct 23 '25 20:10 egormanga

Not totally minimal, but I had another go, and this code compiles. I haven't checked if it actually runs as expected, as I've not yet written a client. Obviously, in a real API I wouldn't want the id type to be called UserId, but this was just something to get something to work.

use spacetimedb::{table, FilterableValue, Identity, ReducerContext, SpacetimeType, Table};

type UID = u32;

#[derive(SpacetimeType, Default)]
pub struct UserId {
    id: u32,
}

impl table::SequenceTrigger for UserId {
    fn is_sequence_trigger(&self) -> bool {
        self.id.is_sequence_trigger()
    }

    fn decode(reader: &mut &[u8]) -> Result<Self, spacetimedb::sats::bsatn::DecodeError> {
        let id = u32::decode(reader)?;
        Ok(UserId { id })
    }
}

impl spacetimedb::spacetimedb_lib::Private for &UserId {}

impl FilterableValue for &UserId {
    type Column = u32;
}

#[table(name = user, public)]
#[derive(Default)]
pub struct User {
    #[primary_key]
    #[auto_inc]
    pub id: UserId,
    #[unique]
    pub identity: Identity,
    pub display_name: Option<String>,
}

#[table(name = adventure, public)]
#[derive(Default)]
pub struct Adventure {
    #[primary_key]
    #[auto_inc]
    pub id: UID,
    // User.id
    #[index(btree)]
    pub author: UserId,
    pub title: String,
    pub description: String,
}

#[spacetimedb::reducer]
pub fn user_update_display_name(
    ctx: &ReducerContext,
    user_id: UserId,
    new_name: String,
) -> Result<(), String> {
    let mut user = ctx.db.user().id().find(user_id).ok_or("User not found")?;
    user.display_name = Some(new_name);
    ctx.db.user().id().update(user);
    Ok(())
}

#[spacetimedb::reducer]
pub fn adventure_create(ctx: &ReducerContext, author: UserId) -> Result<(), String> {
    let adventure = Adventure {
        author,
        ..Default::default()
    };
    ctx.db.adventure().try_insert(adventure)?;
    Ok(())
}

drdozer avatar Oct 26 '25 14:10 drdozer

Some notes:

  • I was unable to make this work with a single-member struct, but it may have been user error -- so UserId(u32) would not work for me
  • I had to implement the Private flag trait -- a bit yucky - this is an argument for this being macro-generated
  • The impls are going on reference types, which seems a bit weird, but OK
  • there must be some arkane arts going on here, because nowhere do I explain how to unbox UserId into a u32 for the btree filtering logic -- and the field is not marked public, so not sure how it is being unboxed elsewhere

Also, I tried to move the ID type into a sub-module, but I could not get macros to behave. If they could be moved into a sub-module, then we can do something like this to hide this from the user:

// define Id for User in a sub-module user_table

mod user_table {
  ...

    #[derive(SpacetimeType, Default, Clone, Copy)]
    pub struct Id {
        id: u32,
    }

  ...
}

// then in the top table, we expose it as:

#![feature(inherent_associated_types)]
impl User {
    type Id = user_table::Id;
}

// then use as:

#[table(name = user, public)]
#[derive(Default)]
pub struct User {
    #[primary_key]
    #[auto_inc]
    pub id: User::Id,
    #[unique]
    pub identity: Identity,
    pub display_name: Option<String>,
}

I don't know enough about the internals of the macros to be able to debug this.

drdozer avatar Oct 26 '25 15:10 drdozer

Thank you for filing this and iterating on the design+internals a bit! I've triaged this. We'll discuss more as a team and come up with a path forward.

bfops avatar Nov 12 '25 18:11 bfops