Skip to content

feat: add table accessor without lifetime#5055

Open
onx2 wants to merge 1 commit into
clockworklabs:masterfrom
onx2:chore/table-accessor-for-downstream
Open

feat: add table accessor without lifetime#5055
onx2 wants to merge 1 commit into
clockworklabs:masterfrom
onx2:chore/table-accessor-for-downstream

Conversation

@onx2

@onx2 onx2 commented May 16, 2026

Copy link
Copy Markdown
Contributor

Description of Changes

Adds a lifetime-aware TableAccessor trait to the Rust SDK and updates Rust codegen to emit a generated accessor marker type for each table.

Generated bindings now include a marker type like:

pub struct PlayerPositionTableAccessor;

impl __sdk::TableAccessor<super::RemoteTables> for PlayerPositionTableAccessor {
    type Row = PlayerPositionRow;
    type Handle<'db> = PlayerPositionTableHandle<'db>;

    fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> {
        db.player_position()
    }
}

The existing generated table access methods remain unchanged:

fn player_position(&self) -> PlayerPositionTableHandle<'_>;

Motivation

Generated table handles are lifetime-scoped to RemoteTables, which makes it difficult for downstream crates to expose ergonomic generic APIs over generated table accessors. In particular, a method like RemoteTables::player_position returns a handle whose type depends on the borrow lifetime of RemoteTables, which is hard to represent in higher-order APIs.

The existing lifetime is not mechanically required by the current handle storage model: generated table handles own an SDK TableHandle<Row>, which holds shared connection/cache state rather than borrowing from RemoteTables. However, the lifetime does express the conceptual scope of a handle relative to the database view that produced it.

This PR preserves that scoped API contract instead of removing the lifetime, and adds a generated marker type so generic downstream APIs can name table accessors without erasing or weakening the lifetime relationship.

This enables APIs such as the below instead of requiring closure-based registration everywhere which greatly simplifies downstream crate code and consumer code.

- .add_table::<PlayerPosition>(|reg, db| reg.bind(db.player_position()))
+ .add_table::<PlayerPositionTableAccessor>()

API and ABI breaking changes

None.

This is additive and should not break existing generated bindings usage. Existing code such as the below continues to work unchanged:

ctx.db.player_position().on_insert(...)

The new TableAccessor trait is exported from both the SDK root and __codegen, and generated marker types are added alongside existing generated table handles and access traits.

Expected complexity level and risk

Complexity: 2/5

The change is small and additive, but it touches Rust SDK codegen and generated binding shape. The main risk is introducing generated code that depends on the new SDK trait, so generated bindings and the SDK version must match.

This does not alter table handle ownership, callback behavior, or existing access methods.

Testing

  • Ran cargo check -p spacetimedb-sdk.
  • Regenerated Rust bindings for my game.
  • Verified generated bindings include *TableAccessor marker types.
  • Used generated accessors from downstream local bevy_stdb.
  • Ran cargo check -p bevy_stdb against the local SDK.

Comment thread crates/codegen/src/rust.rs
@gefjon

gefjon commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Can you give an example of an API that is enhanced by this change? I'm having trouble seeing how this would be useful.

@onx2

onx2 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Can you give an example of an API that is enhanced by this change? I'm having trouble seeing how this would be useful.

@gefjon It lets bevy_stdb, and other Rust downstream crates, name a table accessor as a concrete type instead of a per-table closure while keeping the handle's lifetime relationship which is what makes generic table registration possible.

The real world outcome is a simpler API for consumers of the Bevy integration:

Before

.add_table::<Player>(|reg, db| reg.bind(db.player()))

After

.add_table::<PlayerTableAccessor>()

It mainly enables a simpler API and fewer lifetime hoops for integration/tooling authors. So, anyone needing to solve the "register N tables generically" problem rather than calling db.player() directly. It also makes generic utilities like fn count<A: TableAccessor<Db>>(db: &Db) -> u64 { A::get(db).count() } expressible. But for me it was a cleaner Bevy integration API and internals for bevy_stdb, but the primitive is general.

@kistz

kistz commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

how would this work with where clauses tho 🤔 or is that used at a different point where only the row type or smth needs to be known? ^^

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants