spacetimedsl_derive 0.20.1

Macros to extend SpacetimeDSL. You shouldn't depend on this directly and instead use the spacetimedsl crate.
Documentation

โœจ SpacetimeDSL โ€” The SpacetimeDB Rust Server Module meta-framework

SpacetimeDSL allows you to interact in an ergonomic, more developer-friendly and type-safer way with the data in your SpacetimeDB server.

๐Ÿ“‘ Table of Contents

See DOCUMENTATION.md for a comprehensive reference with all features, examples, and rules.

Core Unique Features

Enhanced Developer Experience

Additional Information

๐Ÿ“‚ Examples

  • blackholio โ€” Real-world multiplayer game using SpacetimeDSL with foreign keys, hooks, scheduled tasks, and wrapper types.
  • test โ€” Integration test suite covering all SpacetimeDSL features.

๐Ÿš€ Example 2D Tile-based Game

๐Ÿ”ง Vanilla SpacetimeDB

Let's start with a ordinary SpacetimeDB schema:

// FIXME 1: Need to validate that entities are only created and deleted, not updated. Would be cool if the compiler would enforce this, but for now each developer must keep this in mind. I'll create docs on that and hope that everyone reads them...
#[spacetimedb::table(
    accessor = entity,
    public,
)]
pub struct Entity {
    // FIXME 2: Need to manually set it to `0` on creation and let the DB auto-generate the ID.
    // FIXME 3: Need to keep in mind to never set to a non-zero value otherwise that would cause ID conflicts in the DB.
    // FIXME 4: Need to ensure that the column value is never changed.
    #[primary_key]
    #[auto_inc]
    pub id: u128,

    // FIXME 5: Need to manually set it to `ctx.timestamp` on creation.
    // FIXME 4: Again.
    pub created_at: spacetimedb::Timestamp,
}

// FIXME 6: Need to validate where positions are created and changed that x and y is unique together, so that each tile can only contain one Entity. I hope I won't need more unique multi-column indices in the future... Develop a generic solution for this?!
// FIXME 7: Need to validate that positions are in specific bounds where positions are created and changed so that entities like players can't move outside the playable area.
#[spacetimedb::table(
    accessor = position,
    public,
    index(accessor = x_y, btree(columns = [x, y])),
)]
pub struct Position {
    // FIXME 2, 3 and 4: Again.
    #[primary_key]
    #[auto_inc]
    pub id: u128,

    // FIXME 4: Again.
    // FIXME 8: Need to validate where Positions are created that this only accepts Entity IDs.
    // FIXME 9: Need to validate that the referenced Entity actually exists.
    // FIXME 10: Need to ensure that the Position is deleted when the Entity with this ID is deleted.
    #[unique]
    pub entity_id: u128,

    pub x: i128,

    pub y: i128,

    // FIXME 11: Need to set this to `None` on creation
    // FIXME 12: Need to update it to `Some(ctx.timestamp)` on every update.
    pub modified_at: Option<spacetimedb::Timestamp>,
}

The Problem: SpacetimeDB is great technology, but has weaknesses that prevent developers from utilizing its full potential โ€” sometimes you work against the database.

โšก SpacetimeDB with SpacetimeDSL

Let's see what happens when adding SpacetimeDSL:

#[spacetimedsl::dsl( // Added
    plural_name = entities,
    method(update = false, delete = true),
)]
#[spacetimedb::table(
    accessor = entity,
    public,
)]
pub struct Entity {
    #[primary_key]
    #[auto_inc]
    #[create_wrapper] // Added
    #[referenced_by(path = self, table = position)] // Added
    id: u128, // no longer pub

    created_at: spacetimedb::Timestamp, // no longer pub
}

#[spacetimedsl::dsl( // Added
    plural_name = positions,
    method(update = true, delete = true),
    unique_index(name = x_y),
    hook(before(insert, update)),
)]
#[spacetimedb::table(
    accessor = position,
    public,
    index(accessor = x_y, btree(columns = [x, y])),
)]
pub struct Position {
    #[primary_key]
    #[auto_inc]
    #[create_wrapper] // Added
    id: u128, // no longer pub

    #[unique]
    #[use_wrapper(EntityId)] // Added
    #[foreign_key(path = self, table = entity, column = id, on_delete = Delete)] // Added
    entity_id: u128, // no longer pub

    pub x: i128, // Still pub because it should be updatable

    pub y: i128, // Still pub because it should be updatable

    modified_at: Option<spacetimedb::Timestamp>, // No longer pub
}

// Added
#[spacetimedsl::hook]
fn before_position_insert(
    _dsl: &spacetimedsl::DSL<'_, T>,
    position: CreatePosition,
) -> Result<CreatePosition, spacetimedsl::SpacetimeDSLError> {
    before_position_hook_helper(&position.x, &position.y)?;

    Ok(position)
}

// Added
#[spacetimedsl::hook]
fn before_position_update(
    _dsl: &spacetimedsl::DSL<'_, T>,
    old: &Position,
    new: Position,
) -> Result<Position, spacetimedsl::SpacetimeDSLError> {
    if *old.get_x() == *new.get_x() && *old.get_y() == *new.get_y() {
        // No change in position, so we can skip validation
        return Ok(new);
    }

    before_position_hook_helper(new.get_x(), new.get_y())?;

    Ok(new)
}

const WORLD_BOUNDARY: i128 = 10_000_000_000_000_000_000;

// Added
fn before_position_hook_helper(
    x: &i128,
    y: &i128,
) -> Result<(), spacetimedsl::SpacetimeDSLError> {
    if *x < -WORLD_BOUNDARY || *x > WORLD_BOUNDARY || *y < -WORLD_BOUNDARY || *y > WORLD_BOUNDARY
    {
        return Err(spacetimedsl::SpacetimeDSLError::Error(
            "Position out of bounds".to_string(),
        ));
    }

    Ok(())
}

โœจ What's different?

Cleaner Modeling:

  • ๐ŸŽฏ Entity is constrained to create/delete only (update = false), so lifecycle intent is explicit in the type-level API.
  • ๐Ÿ”’ Sensitive columns (id, created_at, entity_id, modified_at) are private in the struct, preventing accidental mutation.
  • ๐Ÿง  Generated getters (without setters / mut-getters for immutable fields) enforce safer usage patterns by default.

Smart Defaults & Automation:

  • ๐Ÿค– Auto-increment IDs are handled automatically (no manual ID assignment, no non-zero ID mistakes).
  • โฐ created_at is set automatically on create.
  • ๐Ÿ”„ modified_at is set to None on create and updated to Some(ctx.timestamp) on update.
  • ๐Ÿงท No-op update guard: if position x/y didn't change, validation is skipped.

Data Integrity by Construction:

  • ๐Ÿท๏ธ Wrapper types (#[create_wrapper] + #[use_wrapper(EntityId)]) make cross-table ID misuse much harder.
  • ๐Ÿ”— Foreign-key validation ensures referenced Entity exists on create (and update, if the entity_id column would be mutable).
  • ๐Ÿงน Referential cleanup on delete keeps dependent Position rows in sync automatically when deleting their corresponding Entity.
  • ๐ŸŽฒ unique_index(name = x_y) enforces unique multi-column (x, y) positions.

Hooks for Domain Rules:

  • ๐Ÿช Before-insert and before-update hooks validate world bounds.

๐Ÿš€ Try SpacetimeDSL for yourself, by adding it to your server module's Cargo.toml:


# https://crates.io/crates/spacetimedsl The SpacetimeDB Rust Server Module meta-framework
spacetimedsl = { version = "0.20.1" }

๐Ÿ“– Get started by adding #[spacetimedsl::dsl] plus helper attributes

  • #[create_wrapper],
  • #[use_wrapper],
  • #[foreign_key] and
  • #[referenced_by]

to your structs with #[spacetimedb::table]!

๐Ÿ’ฌ Need help?


โš ๏ธ Current Limitations


โ“ FAQ

โ” Why must #[primary_key] columns be private?

Currently, they are allowed to be public, until SpacetimeDB#3754 is resolved and released.

  • ๐Ÿ”’ They should never change after insertion
  • DSL generates setters and mut-getters for non-private columns
  • Making them public would:
    • โŒ Allow changes after creation via setters
    • โŒ Allow direct struct member access
    • โŒ Bypass wrapped types

๐Ÿ“œ Licensing

SpacetimeDSL is dual-licensed under:

Open Source โค๏ธ

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.