diff --git a/crates/datastore/Cargo.toml b/crates/datastore/Cargo.toml index 86cb2aca0b6..37d005a7e64 100644 --- a/crates/datastore/Cargo.toml +++ b/crates/datastore/Cargo.toml @@ -47,6 +47,7 @@ test = ["spacetimedb-commitlog/test", "spacetimedb-schema/test"] spacetimedb-lib = { path = "../lib", features = ["proptest"] } spacetimedb-sats = { path = "../sats", features = ["proptest"] } spacetimedb-commitlog = { path = "../commitlog", features = ["test"] } +spacetimedb-schema = { workspace = true, features = ["test"] } # Also as dev-dependencies for use in _this_ crate's tests. proptest.workspace = true diff --git a/crates/datastore/src/locking_tx_datastore/committed_state.rs b/crates/datastore/src/locking_tx_datastore/committed_state.rs index 46e2e60131f..0ce629524c4 100644 --- a/crates/datastore/src/locking_tx_datastore/committed_state.rs +++ b/crates/datastore/src/locking_tx_datastore/committed_state.rs @@ -800,6 +800,32 @@ impl CommittedState { table.with_mut_schema(|s| s.remove_index(index_id)); self.index_id_map.remove(&index_id); } + // An index alias/source-name changed. Change it back. + IndexAlterSourceName(table_id, index_id, old_alias) => { + let table = self.tables.get_mut(&table_id)?; + let mut index_schema = table + .get_schema() + .indexes + .iter() + .find(|x| x.index_id == index_id)? + .clone(); + index_schema.alias = old_alias; + table.with_mut_schema(|s| s.update_index(index_schema)); + } + // A table accessor name alias changed. Change it back. + TableAlterAccessorName(table_id, old_alias) => { + let table = self.tables.get_mut(&table_id)?; + table.with_mut_schema(|s| s.alias = old_alias); + } + // A column accessor name alias changed. Change it back. + ColumnAlterAccessorName(table_id, col_id, old_alias) => { + let table = self.tables.get_mut(&table_id)?; + table.with_mut_schema(|s| { + if let Some(col) = s.columns.iter_mut().find(|x| x.col_pos == col_id) { + col.alias = old_alias; + } + }); + } // A table was removed. Add it back. TableRemoved(table_id, table) => { let is_view_table = table.schema.is_view(); diff --git a/crates/datastore/src/locking_tx_datastore/datastore.rs b/crates/datastore/src/locking_tx_datastore/datastore.rs index a219c14d548..7293a81fba0 100644 --- a/crates/datastore/src/locking_tx_datastore/datastore.rs +++ b/crates/datastore/src/locking_tx_datastore/datastore.rs @@ -309,6 +309,34 @@ impl Locking { tx.alter_table_primary_key(table_id, primary_key) } + pub fn alter_index_source_name_mut_tx( + &self, + tx: &mut MutTxId, + index_id: IndexId, + source_name: spacetimedb_sats::raw_identifier::RawNamespacedIdentifier, + ) -> Result<()> { + tx.alter_index_source_name(index_id, source_name) + } + + pub fn alter_table_accessor_name_mut_tx( + &self, + tx: &mut MutTxId, + table_id: TableId, + new_alias: spacetimedb_schema::identifier::NamespacedIdentifier, + ) -> Result<()> { + tx.alter_table_accessor_name(table_id, new_alias) + } + + pub fn alter_column_accessor_name_mut_tx( + &self, + tx: &mut MutTxId, + table_id: TableId, + col_id: ColId, + new_alias: spacetimedb_schema::identifier::Identifier, + ) -> Result<()> { + tx.alter_column_accessor_name(table_id, col_id, new_alias) + } + pub fn alter_table_row_type_mut_tx( &self, tx: &mut MutTxId, @@ -551,6 +579,34 @@ impl MutTxDatastore for Locking { tx.drop_index(index_id) } + fn alter_index_source_name_mut_tx( + &self, + tx: &mut Self::MutTx, + index_id: IndexId, + source_name: spacetimedb_sats::raw_identifier::RawNamespacedIdentifier, + ) -> Result<()> { + tx.alter_index_source_name(index_id, source_name) + } + + fn alter_table_accessor_name_mut_tx( + &self, + tx: &mut Self::MutTx, + table_id: TableId, + new_alias: spacetimedb_schema::identifier::NamespacedIdentifier, + ) -> Result<()> { + tx.alter_table_accessor_name(table_id, new_alias) + } + + fn alter_column_accessor_name_mut_tx( + &self, + tx: &mut Self::MutTx, + table_id: TableId, + col_id: ColId, + new_alias: spacetimedb_schema::identifier::Identifier, + ) -> Result<()> { + tx.alter_column_accessor_name(table_id, col_id, new_alias) + } + fn index_id_from_name_mut_tx(&self, tx: &Self::MutTx, index_name: &str) -> Result> { tx.index_id_from_name_or_alias(index_name) } diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index 3ba90142faf..fec1d4804dd 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -34,7 +34,7 @@ use crate::{ locking_tx_datastore::state_view::ScanOrIndex, traits::{InsertFlags, RowTypeForTable, TxData, UpdateFlags}, }; -use core::{cell::RefCell, iter, mem, ops::RangeBounds}; +use core::{cell::RefCell, iter, ops::RangeBounds}; use itertools::Either; use smallvec::SmallVec; use spacetimedb_data_structures::map::{HashMap, HashSet, IntMap}; @@ -1372,16 +1372,18 @@ impl MutTxId { impl MutTxId { /// Set the table access of `table_id` to `access`. pub(crate) fn alter_table_access(&mut self, table_id: TableId, access: StAccess) -> Result<()> { + let old_access = self.find_st_table_row(table_id)?.table_access; + // Write to the table in the tx state. let ((tx_table, ..), (commit_table, ..)) = self.get_or_create_insert_table_mut(table_id)?; tx_table.with_mut_schema_and_clone(commit_table, |s| s.table_access = access); - // Update system tables. - let old_access = self.update_st_table_row(table_id, |st| mem::replace(&mut st.table_access, access))?; - - // Remember the pending change so we can undo if necessary. + // Remember the pending change so we can undo it if a later system-table update fails. self.push_schema_change(PendingSchemaChange::TableAlterAccess(table_id, old_access)); + // Update system tables. + self.update_st_table_row(table_id, |st| st.table_access = access)?; + Ok(()) } @@ -1390,18 +1392,142 @@ impl MutTxId { /// Updates both the in-memory schema and the `st_table` system table. /// See: pub(crate) fn alter_table_primary_key(&mut self, table_id: TableId, new_primary_key: Option) -> Result<()> { + let old_pk = self.find_st_table_row(table_id)?.table_primary_key; + // Write to the table in the tx state. let ((tx_table, ..), (commit_table, ..)) = self.get_or_create_insert_table_mut(table_id)?; tx_table.with_mut_schema_and_clone(commit_table, |s| s.primary_key = new_primary_key); - // Update system tables. + // Remember the pending change so we can undo it if a later system-table update fails. let new_pk_col_list = new_primary_key.map(|col| col.into()); - let old_pk = - self.update_st_table_row(table_id, |st| mem::replace(&mut st.table_primary_key, new_pk_col_list))?; - - // Remember the pending change so we can undo if necessary. self.push_schema_change(PendingSchemaChange::TableAlterPrimaryKey(table_id, old_pk)); + // Update system tables. + self.update_st_table_row(table_id, |st| st.table_primary_key = new_pk_col_list)?; + + Ok(()) + } + + /// Change the source-name alias of the index identified by `index_id`. + pub(crate) fn alter_index_source_name( + &mut self, + index_id: IndexId, + source_name: RawNamespacedIdentifier, + ) -> Result<()> { + let st_index_ref = self + .iter_by_col_eq(ST_INDEX_ID, StIndexFields::IndexId, &index_id.into())? + .next() + .ok_or_else(|| TableError::IdNotFound(SystemTable::st_index, index_id.into()))?; + let st_index_row = StIndexRow::try_from(st_index_ref)?; + let table_id = st_index_row.table_id; + + let old_alias = self + .find_st_index_accessor_row_by_index_name(st_index_row.index_name.as_ref())? + .map(|row| row.accessor_name); + + if old_alias.as_ref() == Some(&source_name) { + return Ok(()); + } + + let ((tx_table, ..), (commit_table, ..)) = self.get_or_create_insert_table_mut(table_id)?; + let mut index_schema = tx_table + .get_schema() + .indexes + .iter() + .find(|index| index.index_id == index_id) + .cloned() + .ok_or_else(|| TableError::IdNotFound(SystemTable::st_index, index_id.into()))?; + index_schema.alias = Some(source_name.clone()); + tx_table.with_mut_schema_and_clone(commit_table, |s| s.update_index(index_schema)); + self.push_schema_change(PendingSchemaChange::IndexAlterSourceName(table_id, index_id, old_alias)); + + self.drop_st_index_accessor(&st_index_row.index_name)?; + self.insert_st_index_accessor(&st_index_row.index_name, Some(&source_name))?; + + Ok(()) + } + + /// Change the accessor name alias of the table identified by `table_id`. + pub(crate) fn alter_table_accessor_name( + &mut self, + table_id: TableId, + new_alias: NamespacedIdentifier, + ) -> Result<()> { + let table_name = self.find_st_table_row(table_id)?.table_name; + + let old_alias = self + .iter_by_col_eq( + ST_TABLE_ACCESSOR_ID, + StTableAccessorFields::TableName, + &table_name.as_ref().into(), + )? + .next() + .and_then(|row| StTableAccessorRow::try_from(row).ok()) + .map(|row| row.accessor_name); + + if old_alias.as_ref() == Some(&new_alias) { + return Ok(()); + } + + let ((tx_table, ..), (commit_table, ..)) = self.get_or_create_insert_table_mut(table_id)?; + tx_table.with_mut_schema_and_clone(commit_table, |s| s.alias = Some(new_alias.clone())); + self.push_schema_change(PendingSchemaChange::TableAlterAccessorName(table_id, old_alias)); + + self.drop_st_table_accessor(&table_name)?; + self.insert_st_table_accessor(&table_name, Some(&new_alias))?; + + Ok(()) + } + + /// Change the accessor name alias of the column identified by `table_id` and `col_id`. + pub(crate) fn alter_column_accessor_name( + &mut self, + table_id: TableId, + col_id: ColId, + new_alias: Identifier, + ) -> Result<()> { + let table_name = self.find_st_table_row(table_id)?.table_name; + + // Read old column info from the current schema + let col_info: Vec<(Identifier, Option, ColId)> = self + .schema_for_table(table_id)? + .columns + .iter() + .map(|c| (c.col_name.clone(), c.alias.clone(), c.col_pos)) + .collect(); + + let old_alias = col_info + .iter() + .find(|(_, _, pos)| *pos == col_id) + .and_then(|(_, alias, _)| alias.clone()); + + if old_alias.as_ref() == Some(&new_alias) { + return Ok(()); + } + + // Update system tables: drop and re-insert all column accessor rows for this table + self.drop_st_column_accessor(&table_name)?; + for (col_name, alias, pos) in &col_info { + let name = if *pos == col_id { + &new_alias + } else { + alias.as_ref().unwrap_or(col_name) + }; + self.insert_st_column_accessor(&table_name, col_name, Some(name))?; + } + + // Update in-memory schema + let ((tx_table, ..), (commit_table, ..)) = self.get_or_create_insert_table_mut(table_id)?; + tx_table.with_mut_schema_and_clone(commit_table, |s| { + if let Some(col) = s.columns.iter_mut().find(|c| c.col_pos == col_id) { + col.alias = Some(new_alias); + } + }); + + self.push_schema_change(PendingSchemaChange::ColumnAlterAccessorName( + table_id, col_id, old_alias, + )); + Ok(()) } @@ -1437,6 +1563,8 @@ impl MutTxId { // SAFETY: `commit_table` should have a schema identical to that of `tx_table` // prior to changing it just now. unsafe { commit_table.set_layout_and_schema_to(tx_table) }; + // Remember the pending change so we can undo it if a later system-table update fails. + self.push_schema_change(PendingSchemaChange::TableAlterRowType(table_id, old_column_schemas)); // Update system tables. // We'll simply remove all rows in `st_columns` and then add the new ones. @@ -1446,9 +1574,6 @@ impl MutTxId { self.drop_st_column_accessor(&table_name)?; self.insert_st_column(&table_name, &column_schemas)?; - // Remember the pending change so we can undo if necessary. - self.push_schema_change(PendingSchemaChange::TableAlterRowType(table_id, old_column_schemas)); - Ok(()) } @@ -1477,6 +1602,8 @@ impl MutTxId { commit_table .change_columns_of_empty_table_to(column_schemas.clone()) .map_err(|_| TableError::EventTableNotEmpty(table_id))?; + // Remember the pending change so we can undo it if a later system-table update fails. + self.push_schema_change(PendingSchemaChange::ReschemaEventTable(table_id, old_column_schemas)); // Update system tables. // We'll simply remove all rows in `st_columns` and then add the new ones. @@ -1486,9 +1613,6 @@ impl MutTxId { self.drop_st_column_accessor(&table_name)?; self.insert_st_column(&table_name, &column_schemas)?; - // Remember the pending change so we can undo if necessary. - self.push_schema_change(PendingSchemaChange::ReschemaEventTable(table_id, old_column_schemas)); - Ok(()) } diff --git a/crates/datastore/src/locking_tx_datastore/tx_state.rs b/crates/datastore/src/locking_tx_datastore/tx_state.rs index 6b47948d8a3..1d7680ae05a 100644 --- a/crates/datastore/src/locking_tx_datastore/tx_state.rs +++ b/crates/datastore/src/locking_tx_datastore/tx_state.rs @@ -1,8 +1,10 @@ use super::{delete_table::DeleteTable, sequence::Sequence}; use spacetimedb_data_structures::map::IntMap; use spacetimedb_lib::db::auth::StAccess; -use spacetimedb_primitives::{ColList, ConstraintId, IndexId, SequenceId, TableId}; +use spacetimedb_primitives::{ColId, ColList, ConstraintId, IndexId, SequenceId, TableId}; use spacetimedb_sats::memory_usage::MemoryUsage; +use spacetimedb_sats::raw_identifier::RawNamespacedIdentifier; +use spacetimedb_schema::identifier::{Identifier, NamespacedIdentifier}; use spacetimedb_schema::schema::{ColumnSchema, ConstraintSchema, IndexSchema, SequenceSchema}; use spacetimedb_table::{ blob_store::{BlobStore, HashMapBlobStore}, @@ -111,6 +113,15 @@ pub enum PendingSchemaChange { /// If adding this index caused the pointer map to be removed, /// it will be present here. IndexAdded(TableId, IndexId, Option), + /// The source-name alias of the index with [`IndexId`] changed. + /// The old alias is stored for rollback. + IndexAlterSourceName(TableId, IndexId, Option), + /// The accessor name alias of the table with [`TableId`] changed. + /// The old alias is stored for rollback. + TableAlterAccessorName(TableId, Option), + /// The accessor name alias of the column with [`ColId`] in the table with [`TableId`] changed. + /// The old alias is stored for rollback. + ColumnAlterAccessorName(TableId, ColId, Option), /// The [`Table`] with [`TableId`] was removed. TableRemoved(TableId, Table), /// The table with [`TableId`] was added. @@ -153,6 +164,9 @@ impl MemoryUsage for PendingSchemaChange { Self::IndexAdded(table_id, index_id, pointer_map) => { table_id.heap_usage() + index_id.heap_usage() + pointer_map.heap_usage() } + Self::IndexAlterSourceName(table_id, index_id, alias) => { + table_id.heap_usage() + index_id.heap_usage() + alias.as_ref().map(|a| a.as_ref().len()).unwrap_or(0) + } Self::TableRemoved(table_id, table) => table_id.heap_usage() + table.heap_usage(), Self::TableAdded(table_id) => table_id.heap_usage(), Self::TableAlterAccess(table_id, st_access) => table_id.heap_usage() + st_access.heap_usage(), @@ -168,6 +182,14 @@ impl MemoryUsage for PendingSchemaChange { table_id.heap_usage() + sequence.heap_usage() + sequence_schema.heap_usage() } Self::SequenceAdded(table_id, sequence_id) => table_id.heap_usage() + sequence_id.heap_usage(), + Self::TableAlterAccessorName(table_id, alias) => { + table_id.heap_usage() + alias.as_ref().map(|a| a.as_ref().len()).unwrap_or(0) + } + Self::ColumnAlterAccessorName(table_id, col_id, alias) => { + table_id.heap_usage() + + col_id.heap_usage() + + alias.as_ref().map(|a| a.as_raw().heap_usage()).unwrap_or(0) + } Self::ReschemaEventTable(table_id, column_schemas) => table_id.heap_usage() + column_schemas.heap_usage(), } } diff --git a/crates/datastore/src/traits.rs b/crates/datastore/src/traits.rs index 3296baef545..680f4a9032c 100644 --- a/crates/datastore/src/traits.rs +++ b/crates/datastore/src/traits.rs @@ -631,6 +631,25 @@ pub trait MutTxDatastore: TxDatastore + MutTx { fn create_index_mut_tx(&self, tx: &mut Self::MutTx, index_schema: IndexSchema, is_unique: bool) -> Result; fn drop_index_mut_tx(&self, tx: &mut Self::MutTx, index_id: IndexId) -> Result<()>; + fn alter_index_source_name_mut_tx( + &self, + tx: &mut Self::MutTx, + index_id: IndexId, + source_name: spacetimedb_sats::raw_identifier::RawNamespacedIdentifier, + ) -> Result<()>; + fn alter_table_accessor_name_mut_tx( + &self, + tx: &mut Self::MutTx, + table_id: TableId, + new_alias: spacetimedb_schema::identifier::NamespacedIdentifier, + ) -> Result<()>; + fn alter_column_accessor_name_mut_tx( + &self, + tx: &mut Self::MutTx, + table_id: TableId, + col_id: ColId, + new_alias: spacetimedb_schema::identifier::Identifier, + ) -> Result<()>; fn index_id_from_name_mut_tx(&self, tx: &Self::MutTx, index_name: &str) -> super::Result>; // TODO: Index data diff --git a/crates/engine/src/relational_db.rs b/crates/engine/src/relational_db.rs index 29c04fe0316..933949b666b 100644 --- a/crates/engine/src/relational_db.rs +++ b/crates/engine/src/relational_db.rs @@ -1072,6 +1072,36 @@ impl RelationalDB { Ok(self.inner.alter_table_primary_key_mut_tx(tx, name, primary_key)?) } + pub(crate) fn alter_index_source_name( + &self, + tx: &mut MutTx, + index_id: IndexId, + source_name: spacetimedb_sats::raw_identifier::RawNamespacedIdentifier, + ) -> Result<(), DBError> { + Ok(self.inner.alter_index_source_name_mut_tx(tx, index_id, source_name)?) + } + + pub(crate) fn alter_table_accessor_name( + &self, + tx: &mut MutTx, + table_id: TableId, + new_alias: spacetimedb_schema::identifier::NamespacedIdentifier, + ) -> Result<(), DBError> { + Ok(self.inner.alter_table_accessor_name_mut_tx(tx, table_id, new_alias)?) + } + + pub(crate) fn alter_column_accessor_name( + &self, + tx: &mut MutTx, + table_id: TableId, + col_id: ColId, + new_alias: spacetimedb_schema::identifier::Identifier, + ) -> Result<(), DBError> { + Ok(self + .inner + .alter_column_accessor_name_mut_tx(tx, table_id, col_id, new_alias)?) + } + pub(crate) fn alter_table_row_type( &self, tx: &mut MutTx, diff --git a/crates/engine/src/update.rs b/crates/engine/src/update.rs index e09ae1c076e..5b9e6e0fde2 100644 --- a/crates/engine/src/update.rs +++ b/crates/engine/src/update.rs @@ -360,11 +360,94 @@ fn auto_migrate_database( .indexes .iter() .find(|index| index.index_name == stored_name) - .unwrap(); + .ok_or_else(|| anyhow::anyhow!("Index `{index_name}` not found in table `{table_full_name}`"))?; log!(logger, "Dropping index `{index_name}` on table `{table_full_name}`"); stdb.drop_index(tx, index_schema.index_id)?; } + spacetimedb_schema::auto_migrate::AutoMigrateStep::ChangeTableAccessorName(table_name_key) => { + let (namespace, local) = table_name_key; + let table_name = joined(namespace, local); + let (_, new_table_def): (&ModuleDef, &spacetimedb_schema::def::TableDef) = + plan.new.find_table(table_name_key).ok_or_else(|| { + anyhow::anyhow!("ChangeTableAccessorName: `{table_name}` not found in new module def") + })?; + + let table_id = stdb.table_id_from_name_mut(tx, &table_name)?.unwrap(); + let new_alias = namespace.join(new_table_def.accessor_name.clone()); + + log!( + logger, + "Changing table accessor name for `{table_name}` to `{new_alias}`", + ); + stdb.alter_table_accessor_name(tx, table_id, new_alias)?; + } + spacetimedb_schema::auto_migrate::AutoMigrateStep::ChangeColumnAccessorName(table_name_key, col_name) => { + let (namespace, local) = table_name_key; + let table_name = joined(namespace, local); + let (_, new_table_def): (&ModuleDef, &spacetimedb_schema::def::TableDef) = + plan.new.find_table(table_name_key).ok_or_else(|| { + anyhow::anyhow!("ChangeColumnAccessorName: `{table_name}` not found in new module def") + })?; + let new_col_def = new_table_def + .columns + .iter() + .find(|col| &col.name == col_name) + .ok_or_else(|| anyhow::anyhow!("Column `{col_name}` not found in table `{table_name}`"))?; + + let table_id = stdb.table_id_from_name_mut(tx, &table_name)?.unwrap(); + let table_schema = stdb.schema_for_table_mut(tx, table_id)?; + let col_schema = table_schema + .columns + .iter() + .find(|col| &col.col_name == col_name) + .ok_or_else(|| anyhow::anyhow!("Column `{col_name}` not found in table `{table_name}`"))?; + + log!( + logger, + "Changing column accessor name for `{}`.`{}` to `{}`", + table_name, + col_name, + new_col_def.accessor_name, + ); + stdb.alter_column_accessor_name(tx, table_id, col_schema.col_pos, new_col_def.accessor_name.clone())?; + } + spacetimedb_schema::auto_migrate::AutoMigrateStep::ChangeIndexSourceName(key) => { + let (namespace, index_name) = key; + let (_old_owning_def, old_table_def) = + plan.old.find_storing_table(namespace, index_name).ok_or_else(|| { + anyhow::anyhow!("ChangeIndexSourceName: `{index_name}` not found in old module def") + })?; + let (_new_owning_def, new_table_def) = + plan.new.find_storing_table(namespace, index_name).ok_or_else(|| { + anyhow::anyhow!("ChangeIndexSourceName: `{index_name}` not found in new module def") + })?; + let table_full_name = joined(namespace, &old_table_def.name); + let stored_name = namespace.join_raw(&index_name.clone().into()); + let new_index_def = new_table_def + .indexes + .get(index_name) + .ok_or_else(|| anyhow::anyhow!("Index `{index_name}` not found in table `{table_full_name}`"))?; + + let table_id = stdb.table_id_from_name_mut(tx, &table_full_name)?.unwrap(); + let table_schema = stdb.schema_for_table_mut(tx, table_id)?; + let index_schema = table_schema + .indexes + .iter() + .find(|index| index.index_name == stored_name) + .ok_or_else(|| anyhow::anyhow!("Index `{index_name}` not found in table `{table_full_name}`"))?; + let new_source_name = namespace.join_raw(&new_index_def.source_name.clone().into()); + + log!( + logger, + "Changing index source name for `{}` on table `{}` from `{}` to `{}`", + index_name, + table_full_name, + index_schema.alias.as_deref().unwrap_or(""), + new_source_name, + ); + stdb.alter_index_source_name(tx, index_schema.index_id, new_source_name)?; + } spacetimedb_schema::auto_migrate::AutoMigrateStep::RemoveConstraint(key) => { let (namespace, constraint_name) = key; let (_owning_def, table_def) = @@ -651,13 +734,13 @@ mod test { use spacetimedb_datastore::system_tables::ST_EVENT_TABLE_ID; use spacetimedb_lib::{ db::raw_def::{ - v10::{RawModuleDefV10Builder, RawModuleDefV10Section, RawSubmoduleV10}, + v10::{ExplicitNames, RawModuleDefV10Builder, RawModuleDefV10Section, RawSubmoduleV10}, v9::{btree, RawIndexAlgorithm, RawModuleDefV9Builder, TableAccess}, }, Identity, }; - use spacetimedb_sats::{product, AlgebraicType, AlgebraicType::U64, ProductType}; - use spacetimedb_schema::{auto_migrate::ponder_migrate, def::ModuleDef}; + use spacetimedb_sats::{product, raw_identifier::RawIdentifier, AlgebraicType, AlgebraicType::U64, ProductType}; + use spacetimedb_schema::auto_migrate::ponder_migrate; struct TestLogger; impl UpdateLogger for TestLogger { @@ -739,6 +822,111 @@ mod test { Ok(()) } + #[test] + fn update_db_change_index_source_name_updates_lookup_and_persists() -> anyhow::Result<()> { + let auth_ctx = AuthCtx::for_testing(); + let stdb = TestDB::durable()?; + + fn module_def(table_source_name: &str, index_source_name: &str) -> ModuleDef { + let mut builder = RawModuleDefV10Builder::new(); + builder + .build_table_with_new_type( + table_source_name.to_owned(), + ProductType::from([("id", U64), ("emailAddress", AlgebraicType::String)]), + true, + ) + .with_access(TableAccess::Public) + .with_index(btree(1), index_source_name.to_owned(), "emailAddress") + .finish(); + + if table_source_name != "users" { + let mut explicit_names = ExplicitNames::default(); + explicit_names.insert_table(table_source_name.to_owned(), "users"); + builder.add_explicit_names(explicit_names); + } + + builder + .finish() + .try_into() + .expect("builder should create a valid database definition") + } + + let old_source_name = "users_emailAddress_idx_btree"; + let new_source_name = "appUsers_emailAddress_idx_btree"; + let old = module_def("users", old_source_name); + let new = module_def("appUsers", new_source_name); + + let mut tx = begin_mut_tx(&stdb); + for def in old.tables() { + create_table_from_def(&stdb, &mut tx, &old, def)?; + } + stdb.commit_tx(tx)?; + + let tx = begin_mut_tx(&stdb); + let table_id = stdb + .table_id_from_name_mut(&tx, "users")? + .expect("there should be a table named users"); + let table_schema = stdb.schema_for_table_mut(&tx, table_id)?; + let index_schema = table_schema + .indexes + .first() + .expect("there should be a single index") + .clone(); + let canonical_index_name = index_schema.index_name.to_string(); + let index_id = index_schema.index_id; + assert_eq!(stdb.index_id_from_name_mut(&tx, old_source_name)?, Some(index_id)); + assert_eq!(stdb.index_id_from_name_mut(&tx, new_source_name)?, None); + assert_eq!(stdb.index_id_from_name_mut(&tx, &canonical_index_name)?, Some(index_id)); + drop(tx); + + let MigratePlan::Auto(plan) = ponder_migrate(&old, &new)? else { + panic!("expected automatic migration"); + }; + let root = NamespacePath::root(); + let index_name = RawIdentifier::new(canonical_index_name.as_str()); + assert!( + plan.steps + .contains(&AutoMigrateStep::ChangeIndexSourceName((&root, &index_name))), + "plan steps: {:?}", + plan.steps + ); + assert!( + !plan.steps.contains(&AutoMigrateStep::RemoveIndex((&root, &index_name))), + "plan steps: {:?}", + plan.steps + ); + assert!( + !plan.steps.contains(&AutoMigrateStep::AddIndex((&root, &index_name))), + "plan steps: {:?}", + plan.steps + ); + let mut tx = begin_mut_tx(&stdb); + let res = update_database(&stdb, &mut tx, auth_ctx, MigratePlan::Auto(plan), &TestLogger)?; + assert!(matches!(res, UpdateResult::Success)); + + assert_eq!(stdb.index_id_from_name_mut(&tx, old_source_name)?, None); + assert_eq!(stdb.index_id_from_name_mut(&tx, new_source_name)?, Some(index_id)); + assert_eq!(stdb.index_id_from_name_mut(&tx, &canonical_index_name)?, Some(index_id)); + assert!( + tx.pending_schema_changes().iter().any(|change| matches!( + change, + PendingSchemaChange::IndexAlterSourceName(tid, iid, Some(old_alias)) + if *tid == table_id && *iid == index_id && old_alias.as_ref() == old_source_name + )), + "pending schema changes: {:?}", + tx.pending_schema_changes() + ); + stdb.commit_tx(tx)?; + + let stdb = stdb.reopen()?; + let tx = begin_mut_tx(&stdb); + assert_eq!(stdb.index_id_from_name_mut(&tx, old_source_name)?, None); + assert_eq!(stdb.index_id_from_name_mut(&tx, new_source_name)?, Some(index_id)); + assert_eq!(stdb.index_id_from_name_mut(&tx, &canonical_index_name)?, Some(index_id)); + + Ok(()) + } + /// Regression test for #3934: removing a primary key annotation and then /// re-publishing causes "Primary key mismatch" on the NEXT publish. #[test] diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index a516815fea1..97fff428830 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -302,6 +302,14 @@ pub enum AutoMigrateStep<'def> { /// When this step is present, no `ChangeColumns` steps will be, for the same table. AddColumns(::Key<'def>), + /// Change the source-name alias of an existing index. + ChangeIndexSourceName(::Key<'def>), + + /// Change the accessor name alias of an existing table. + ChangeTableAccessorName(::Key<'def>), + /// Change the accessor name alias of an existing column. + ChangeColumnAccessorName(::Key<'def>, &'def Identifier), + /// Add a table, including all indexes, constraints, and sequences. /// There will NOT be separate steps in the plan for adding indexes, constraints, and sequences. AddTable(::Key<'def>), @@ -765,6 +773,9 @@ fn auto_migrate_table<'def>( if old.primary_key != new.primary_key { plan.steps.push(AutoMigrateStep::ChangePrimaryKey(key)); } + if old.accessor_name != new.accessor_name { + plan.steps.push(AutoMigrateStep::ChangeTableAccessorName(key)); + } if old.schedule != new.schedule { if let Some(old_schedule) = &old.schedule { plan.steps.push(AutoMigrateStep::RemoveSchedule(old_schedule.key())); @@ -830,6 +841,10 @@ fn auto_migrate_table<'def>( } .into()) }; + if old_col.accessor_name != new_col.accessor_name { + plan.steps + .push(AutoMigrateStep::ChangeColumnAccessorName(key, &old_col.name)); + } (types_ok, positions_ok) .combine_errors() // `row_type_changed`, `columns_added`, `event_schema_changed` @@ -1128,6 +1143,8 @@ fn auto_migrate_indexes<'def>( if old_idx.algorithm != new_idx.algorithm { plan.steps.push(AutoMigrateStep::RemoveIndex(index_key)); plan.steps.push(AutoMigrateStep::AddIndex(index_key)); + } else if old_idx.source_name != new_idx.source_name { + plan.steps.push(AutoMigrateStep::ChangeIndexSourceName(index_key)); } Ok(()) } @@ -2262,6 +2279,108 @@ mod tests { ); } + #[test] + fn migrate_index_with_changed_source_name() { + fn module_def(source_name: &str) -> ModuleDef { + create_module_def_v10(|builder| { + builder + .build_table_with_new_type( + "FruitBasket", + ProductType::from([("basket_id", AlgebraicType::U64), ("fruit_name", AlgebraicType::String)]), + true, + ) + .with_index(btree([0, 1]), source_name.to_owned(), "fruitNameIndex") + .finish(); + }) + } + + let old_def = module_def("OldBasketLookup"); + let new_def = module_def("NewBasketLookup"); + let root = NamespacePath::root(); + let index_name = RawIdentifier::new("fruit_basket_basket_id_fruit_name_idx_btree"); + + let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed"); + let steps = &plan.steps[..]; + + assert!( + steps.contains(&AutoMigrateStep::ChangeIndexSourceName((&root, &index_name))), + "steps: {steps:?}" + ); + assert!( + !steps.contains(&AutoMigrateStep::RemoveIndex((&root, &index_name))), + "steps: {steps:?}" + ); + assert!( + !steps.contains(&AutoMigrateStep::AddIndex((&root, &index_name))), + "steps: {steps:?}" + ); + } + + #[test] + fn migrate_table_with_changed_accessor_name() { + let old_def = create_module_def_v10(|builder| { + builder + .build_table_with_new_type("my_table", ProductType::from([("id", AlgebraicType::U64)]), true) + .finish(); + }); + + let new_def = create_module_def_v10(|builder| { + builder + .build_table_with_new_type("renamed_table", ProductType::from([("id", AlgebraicType::U64)]), true) + .finish(); + let mut explicit = ExplicitNames::default(); + explicit.insert_table("renamed_table", "my_table"); + builder.add_explicit_names(explicit); + }); + + let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed"); + let steps = &plan.steps[..]; + let root = NamespacePath::root(); + let table_name = expect_identifier("my_table"); + + assert!( + steps.contains(&AutoMigrateStep::ChangeTableAccessorName((&root, &table_name))), + "steps: {steps:?}" + ); + assert!( + !steps.contains(&AutoMigrateStep::RemoveTable((&root, &table_name))), + "steps: {steps:?}" + ); + assert!( + !steps.contains(&AutoMigrateStep::AddTable((&root, &table_name))), + "steps: {steps:?}" + ); + } + + #[test] + fn migrate_column_with_changed_accessor_name() { + let old_def = create_module_def_v10(|builder| { + builder + .build_table_with_new_type("my_table", ProductType::from([("my_field", AlgebraicType::U64)]), true) + .finish(); + }); + + let new_def = create_module_def_v10(|builder| { + builder + .build_table_with_new_type("my_table", ProductType::from([("myField", AlgebraicType::U64)]), true) + .finish(); + }); + + let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed"); + let steps = &plan.steps[..]; + let root = NamespacePath::root(); + let table_name = expect_identifier("my_table"); + let col_name = expect_identifier("my_field"); + + assert!( + steps.contains(&AutoMigrateStep::ChangeColumnAccessorName( + (&root, &table_name), + &col_name + )), + "steps: {steps:?}" + ); + } + #[test] fn migrate_view_disconnect_clients() { struct TestCase { diff --git a/crates/schema/src/auto_migrate/formatter.rs b/crates/schema/src/auto_migrate/formatter.rs index ede9d9e44fa..7d079f04a62 100644 --- a/crates/schema/src/auto_migrate/formatter.rs +++ b/crates/schema/src/auto_migrate/formatter.rs @@ -66,6 +66,17 @@ fn format_step( let index_info = extract_index_info(*index, plan.old)?; f.format_index(&index_info, Action::Removed) } + AutoMigrateStep::ChangeIndexSourceName(index) => { + let index_info = extract_index_info(*index, plan.new)?; + f.format_index(&index_info, Action::Changed) + } + AutoMigrateStep::ChangeTableAccessorName(table) => { + let table_info = extract_table_info(*table, plan)?; + f.format_change_table_accessor_name(table_info.name.as_ref()) + } + AutoMigrateStep::ChangeColumnAccessorName(table, col) => { + f.format_change_column_accessor_name(&joined(*table), col) + } AutoMigrateStep::RemoveConstraint(constraint) => { let constraint_info = extract_constraint_info(*constraint, plan.old)?; f.format_constraint(&constraint_info, Action::Removed) @@ -186,6 +197,12 @@ pub trait MigrationFormatter { fn format_rls(&mut self, rls_info: &RlsInfo, action: Action) -> io::Result<()>; fn format_change_columns(&mut self, column_changes: &ColumnChanges) -> io::Result<()>; fn format_add_columns(&mut self, new_columns: &NewColumns) -> io::Result<()>; + fn format_change_table_accessor_name(&mut self, table_name: &str) -> io::Result<()>; + fn format_change_column_accessor_name( + &mut self, + table_name: &NamespacedIdentifier, + col_name: &str, + ) -> io::Result<()>; fn format_disconnect_warning(&mut self) -> io::Result<()>; // TODO(format-event-table-reschema): I (pgoldman 2026-06-10) didn't have time to meaningfully format event table reschemas, // so for now we're just printing the table name. diff --git a/crates/schema/src/auto_migrate/termcolor_formatter.rs b/crates/schema/src/auto_migrate/termcolor_formatter.rs index f19d19ec7ad..811c04b1860 100644 --- a/crates/schema/src/auto_migrate/termcolor_formatter.rs +++ b/crates/schema/src/auto_migrate/termcolor_formatter.rs @@ -417,6 +417,26 @@ impl MigrationFormatter for TermColorFormatter { Ok(()) } + fn format_change_table_accessor_name(&mut self, table_name: &str) -> io::Result<()> { + self.write_action_prefix(&Action::Changed)?; + self.buffer.write_all(b" table accessor name for ")?; + self.write_colored(table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b"\n") + } + + fn format_change_column_accessor_name( + &mut self, + table_name: &NamespacedIdentifier, + col_name: &str, + ) -> io::Result<()> { + self.write_action_prefix(&Action::Changed)?; + self.buffer.write_all(b" column accessor name for ")?; + self.write_colored(table_name, Some(self.colors.table_name), true)?; + self.buffer.write_all(b".")?; + self.write_colored(col_name, Some(self.colors.column_type), true)?; + self.buffer.write_all(b"\n") + } + fn format_disconnect_warning(&mut self) -> io::Result<()> { self.write_indent()?; self.write_with_background( diff --git a/crates/schema/tests/ensure_same_schema.rs b/crates/schema/tests/ensure_same_schema.rs index fb6d572b2a1..b2427942e88 100644 --- a/crates/schema/tests/ensure_same_schema.rs +++ b/crates/schema/tests/ensure_same_schema.rs @@ -32,6 +32,9 @@ fn step_namespace<'a, 'def>(step: &'a AutoMigrateStep<'def>) -> Option<&'a Names | AutoMigrateStep::RemoveIndex((ns, _)) | AutoMigrateStep::RemoveConstraint((ns, _)) | AutoMigrateStep::RemoveSequence((ns, _)) + | AutoMigrateStep::ChangeIndexSourceName((ns, _)) + | AutoMigrateStep::ChangeTableAccessorName((ns, _)) + | AutoMigrateStep::ChangeColumnAccessorName((ns, _), _) | AutoMigrateStep::AddIndex((ns, _)) | AutoMigrateStep::AddConstraint((ns, _)) | AutoMigrateStep::AddSequence((ns, _)) => Some(ns), @@ -67,6 +70,9 @@ fn assert_identical_modules(module_name_prefix: &str, lang_name: &str, suffix: & | AutoMigrateStep::AddView(_) | AutoMigrateStep::RemoveView(_) | AutoMigrateStep::UpdateView(_) + | AutoMigrateStep::ChangeIndexSourceName(_) + | AutoMigrateStep::ChangeTableAccessorName(_) + | AutoMigrateStep::ChangeColumnAccessorName(_, _) ) }); diff --git a/crates/smoketests/tests/smoketests/typescript_index_source_name.rs b/crates/smoketests/tests/smoketests/typescript_index_source_name.rs index 416ef704faa..f3608ccd339 100644 --- a/crates/smoketests/tests/smoketests/typescript_index_source_name.rs +++ b/crates/smoketests/tests/smoketests/typescript_index_source_name.rs @@ -1,6 +1,6 @@ use spacetimedb_smoketests::{random_string, require_local_server, require_pnpm, ModuleLanguage, Smoketest}; -const TYPESCRIPT_MODULE_WITHOUT_NEW_COLUMNS: &str = r#"import { schema, table, t } from "spacetimedb/server"; +const TYPESCRIPT_MODULE_V1: &str = r#"import { schema, table, t } from "spacetimedb/server"; const AppUsers = table( { name: "users", public: false }, @@ -72,6 +72,34 @@ export const find_users_by_active_status = spacetimedb.reducer( ); "#; +const TYPESCRIPT_MODULE_V2_RENAMED_ACCESSOR: &str = r#"import { schema, table, t } from "spacetimedb/server"; + +const renamedUsers = table( + { name: "users", public: false }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string(), + emailAddress: t.string().index("btree"), + }, +); + +const spacetimedb = schema({ + renamedUsers, +}); +export default spacetimedb; + +export const find_user_by_email = spacetimedb.reducer( + { emailAddress: t.string() }, + (ctx, { emailAddress }) => { + let count = 0; + for (const _row of ctx.db.renamedUsers.emailAddress.filter(emailAddress)) { + count += 1; + } + console.info(`matched ${count}`); + }, +); +"#; + #[test] fn test_typescript_add_optional_columns() { require_pnpm!(); @@ -86,7 +114,7 @@ fn test_typescript_add_optional_columns() { .source( ModuleLanguage::TypeScript, "typescript-add-optional-columns-v1", - TYPESCRIPT_MODULE_WITHOUT_NEW_COLUMNS, + TYPESCRIPT_MODULE_V1, ) .run() .unwrap(); @@ -108,3 +136,37 @@ fn test_typescript_add_optional_columns() { test.call("find_user_by_email", &["alice@example.com"]).unwrap(); test.call("find_users_by_active_status", &["false"]).unwrap(); } + +#[test] +fn test_typescript_change_index_source_name() { + require_pnpm!(); + require_local_server!(); + + let mut test = Smoketest::builder().autopublish(false).build(); + let module_name = format!("typescript-change-source-name-{}", random_string()); + + let database_identity = test + .publish() + .name(&module_name) + .source( + ModuleLanguage::TypeScript, + "typescript-change-source-name-v1", + TYPESCRIPT_MODULE_V1, + ) + .run() + .unwrap(); + + test.call("insert_user", &["Alice", "alice@example.com"]).unwrap(); + + test.publish() + .name(&database_identity) + .source( + ModuleLanguage::TypeScript, + "typescript-change-source-name-v2", + TYPESCRIPT_MODULE_V2_RENAMED_ACCESSOR, + ) + .run() + .unwrap(); + + test.call("find_user_by_email", &["alice@example.com"]).unwrap(); +}