Files
Tyler Cloutier 3f58b5951b Implement event tables (server, Rust/TS/C# codegen + client SDKs) (#4217)
## Summary

Implements event tables end-to-end: server datastore, module bindings
(Rust/TypeScript/C#), client codegen (Rust/TypeScript/C#), client SDKs
(Rust/TypeScript/C#), and integration tests.

Event tables are tables whose rows are ephemeral — they persist to the
commitlog and are delivered to V2 subscribers, but are NOT merged into
committed state. Rows are only visible within the transaction that
inserted them. This is the mechanism that replaces reducer event
callbacks in 2.0.

## What's included

### Server
- `is_event` flag on `RawTableDefV10`, `TableDef`, `TableSchema`
- Event table rows recorded in TxData but skipped during committed state
merge
- Commitlog replay treats event table inserts as no-ops
- Migration validation rejects changing `is_event` between module
versions
- `SELECT * FROM *` excludes event tables
- V1 WebSocket subscriptions to event tables rejected with upgrade
message
- V2 subscription path delivers event table rows correctly
- `CanBeLookupTable` trait — event tables cannot be lookup tables in
semijoins
- Runtime view validation rejects event tables

### Module bindings
- **Rust**: `#[spacetimedb::table(name = my_events, public, event)]`
- **TypeScript**: `table({ event: true }, ...)`
- **C#**: `[Table(Event = true)]`

### Client codegen (`crates/codegen/`)
- **Rust**: Generates `EventTable` impl (insert-only) for event tables,
`Table` impl for normal tables. `CanBeLookupTable` emitted for non-event
tables.
- **TypeScript**: Emits `event: true` in generated table schemas.
`ClientTableCore` type excludes `onDelete`/`onUpdate` for event tables
via conditional types.
- **C#**: Generates classes inheriting from `RemoteEventTableHandle`
(which hides `OnDelete`/`OnBeforeDelete`/`OnUpdate`) for event tables.

### Client SDKs
- **Rust**: `EventTable` trait with insert-only callbacks, client cache
bypass, `count()` returns 0, `iter()` returns empty
- **TypeScript**: Event table cache bypass in `table_cache.ts` — fires
`onInsert` callbacks but doesn't store rows. Type-level narrowing
excludes delete/update methods.
- **C#**: `RemoteEventTableHandle` base class hides delete/update
events. Parse/Apply/PostApply handle `EventTableRows` wire format, skip
cache storage, fire only `OnInsert`.

### Tests
- 9 datastore unit tests (insert/delete/update semantics, replay,
constraints, indexes, auto-inc, cross-tx reset)
- 3 Rust SDK integration tests (basic events, multiple events per
reducer, no persistence across transactions)
- Codegen snapshot tests (Rust, TypeScript, C#)
- Trybuild compile tests (event tables rejected as semijoin lookup
tables)

## Deferred
- `on_delete` codegen for event tables (server only sends inserts;
client synthesis deferred)
- Event tables in subscription joins / views (well-defined but
restricted for now)
- C++ SDK support
- RLS integration test

## API and ABI breaking changes

- `is_event: bool` added to `RawTableDefV10` (appended, defaults to
`false` — existing modules unaffected)
- `CanBeLookupTable` trait bound on semijoin methods in query builder
(all non-event tables implement it, so existing code compiles unchanged)
- `RemoteEventTableHandle` added to C# SDK (new base class for generated
event table handles)

## Expected complexity level and risk

3 — Changes touch the schema pipeline end-to-end and all three client
SDKs, but each individual change is straightforward. The core risk area
is the committed state merge skip in `committed_state.rs`. Client SDK
changes are additive (new code paths for event tables, existing paths
unchanged).

## Testing

- [x] `cargo clippy --workspace --tests --benches` passes
- [x] `cargo test -p spacetimedb-codegen` (snapshot tests)
- [x] `cargo test -p spacetimedb-datastore --features
spacetimedb-schema/test -- event_table` (9 unit tests)
- [x] `pnpm format` passes
- [x] Rust SDK integration tests pass (`event_table_tests` module)

---------

Signed-off-by: Tyler Cloutier <cloutiertyler@users.noreply.github.com>
Co-authored-by: Phoebe Goldman <phoebe@goldman-tribe.org>
Co-authored-by: Jason Larabie <jason@clockworklabs.io>
Co-authored-by: joshua-spacetime <josh@clockworklabs.io>
2026-02-15 22:56:20 +00:00

112 lines
5.2 KiB
Rust

//! The [`Table`] and [`TableWithPrimaryKey`] traits,
//! which allow certain simple queries of the client cache,
//! and expose row callbacks which run when subscribed rows are inserted, deleted or updated.
//!
//! These traits are implemented by "table handles,"
//! types generated by the SpacetimeDB CLI's module-specific codegen
//! which mediate access to tables in the client cache.
//! Obtain a table handle by calling a method on `ctx.db`, where `ctx` is a `DbConnection` or `EventContext`.
/// Trait implemented by table handles, which mediate access to tables in the client cache.
///
/// Obtain a table handle by calling a method on `ctx.db`, where `ctx` is a `DbConnection` or `EventContext`.
///
/// For persistent (non-event) tables only. See [`EventTable`] for transient event tables.
pub trait Table {
/// The type of rows stored in this table.
type Row: 'static;
/// The `EventContext` type generated for the module which defines this table.
type EventContext;
/// The number of subscribed rows in the client cache.
fn count(&self) -> u64;
/// An iterator over all the subscribed rows in the client cache.
fn iter(&self) -> impl Iterator<Item = Self::Row> + '_;
type InsertCallbackId;
/// Register a callback to run whenever a subscribed row is inserted into the client cache.
///
/// The returned [`Self::InsertCallbackId`] can be passed to [`Self::remove_on_insert`]
/// to cancel the callback.
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> Self::InsertCallbackId;
/// Cancel a callback previously registered by [`Self::on_insert`], causing it not to run in the future.
fn remove_on_insert(&self, callback: Self::InsertCallbackId);
type DeleteCallbackId;
/// Register a callback to run whenever a subscribed row is deleted from the client cache.
///
/// The returned [`Self::DeleteCallbackId`] can be passed to [`Self::remove_on_delete`]
/// to cancel the callback.
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> Self::DeleteCallbackId;
/// Cancel a callback previously registered by [`Self::on_delete`], causing it not to run in the future.
fn remove_on_delete(&self, callback: Self::DeleteCallbackId);
}
/// Subtrait of [`Table`] implemented only by tables with a column designated as a primary key,
/// which allows the SDK to identify updates.
///
/// SpacetimeDB does not have a special notion of updates as a primitive operation.
/// Instead, an update is a delete followed by an insert
/// of rows with the same primary key within the same transaction.
/// This means that the module's calls to `ctx.db.some_table().unique_column().update(...)`
/// may not result in an update event on the client if `unique_column` is not the primary key,
/// and that clients may observe update events resulting from deletes and inserts by the module
/// without going through such an `update` method.
pub trait TableWithPrimaryKey: Table {
type UpdateCallbackId;
/// Register a callback to run whenever a subscribed row is updated within a transaction,
/// with an old version deleted and a new version inserted with the same primary key.
///
/// The callback's arguments are `(ctx, old, new)`,
/// where `old` is the deleted row, and `new` is the inserted row.
///
/// The returned [`Self::UpdateCallbackId`] can be passed to [`Self::remove_on_update`]
/// to cancel the callback.
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> Self::UpdateCallbackId;
/// Cancel a callback previously registered by [`Self::on_update`], causing it not to run in the future.
fn remove_on_update(&self, callback: Self::UpdateCallbackId);
}
/// Trait for event tables, whose rows are transient and never persisted in the client cache.
///
/// Event table rows are delivered as inserts but are not stored;
/// only `on_insert` callbacks fire, and `count`/`iter` always reflect an empty table.
///
/// Obtain a table handle by calling a method on `ctx.db`, where `ctx` is a `DbConnection` or `EventContext`.
pub trait EventTable {
/// The type of rows in this table.
type Row: 'static;
/// The `EventContext` type generated for the module which defines this table.
type EventContext;
/// The number of subscribed rows in the client cache (always 0 for event tables).
fn count(&self) -> u64;
/// An iterator over all the subscribed rows in the client cache (always empty for event tables).
fn iter(&self) -> impl Iterator<Item = Self::Row> + '_;
type InsertCallbackId;
/// Register a callback to run whenever a row is inserted.
///
/// The returned [`Self::InsertCallbackId`] can be passed to [`Self::remove_on_insert`]
/// to cancel the callback.
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> Self::InsertCallbackId;
/// Cancel a callback previously registered by [`Self::on_insert`], causing it not to run in the future.
fn remove_on_insert(&self, callback: Self::InsertCallbackId);
}