瀏覽代碼

stage DDL in explicit transactions and publish it with the commit

BEGIN/COMMIT/ROLLBACK reverted DML but DDL published to the schema catalog
immediately, so CREATE/DROP/ALTER TABLE and CREATE/DROP INDEX survived
ROLLBACK and other sessions saw an uncommitted migration.

Add a transaction-scoped catalog overlay (storage.SchemaTx) owned by the
Session. Inside an explicit transaction the DDL handlers stage their catalog
changes there instead of writing the shared SchemaManager: the session reads
through the overlay (SchemaView is now the session itself: GetSchema,
ListTables, GetIndex, ListIndexes, ListTableIndexes, Version, and the DDL
entry points), while every other session keeps reading the committed catalog.

The overlay records the durable LSN of every catalog key it stages a change
to. COMMIT merges the staged schema and index records, the table and index
catalog lists, the pending row rewrites of ALTER TABLE (ADD/DROP/RENAME
COLUMN), and the row moves of RENAME/DROP TABLE into the transaction's single
CompareBatchWrite, so a multi-statement migration and the rows it touched land
atomically. A conflicting concurrent DDL fails that compare with 40001 instead
of overwriting the catalog. The commit re-reads the committed catalog under
the table gates before validating staged rows, so a concurrent ADD COLUMN is
still observed. ROLLBACK and a failed DDL statement (statement savepoint)
discard the overlay; the prior schema and rows are untouched.

Validations use the staged catalog: INSERT/UPDATE/DELETE resolve defaults,
NOT NULL, CHECK, composite PK, FOREIGN KEY, unique-index, and row-key handling
through the session's view, DDL validation (CHECK/UNIQUE/NOT NULL/backfill)
runs against the staged definitions, and the commit's FK, UNIQUE, and cache
planning includes staged indexes and the rows a staged rewrite produces.
Savepoints capture the overlay, so ROLLBACK TO SAVEPOINT also reverts DDL.

Autocommit DDL keeps the existing immediate path and cost. The on-disk format
is unchanged: the commit writes the same schema, index, catalog, and row
records the autocommit paths already write.

Tests cover CREATE/DROP/ALTER/CREATE INDEX/DROP INDEX rollback, two-session
visibility before and after COMMIT, durable reload after commit, DDL+DML in
one transaction, a failed statement preserving earlier staged DDL, a
multi-statement migration rolled back as a unit, staged CHECK/NOT NULL/
composite-PK/FK validation, staged UNIQUE enforcement at commit, cascades
across a commit, savepoint rollback, and serialization errors for concurrent
DDL and concurrent writes.
Danilo Fragoso 1 天之前
父節點
當前提交
e5018c37a7

+ 9 - 0
README.md

@@ -527,6 +527,15 @@ no partial statement effects; in an explicit transaction, earlier successful
 statements remain staged. Mutations exceeding a single storage transaction's
 limits fail rather than being split into independently committed chunks.
 
+DDL is transactional as well: inside an explicit transaction, `CREATE TABLE`,
+`DROP TABLE`, `ALTER TABLE` (add/drop/rename column, rename table), and
+`CREATE`/`DROP INDEX` are staged. The issuing session sees them immediately,
+other sessions keep reading the committed catalog, `ROLLBACK` restores the prior
+schema and rows, and `COMMIT` publishes the whole migration in the same atomic
+batch as the transaction's rows. A statement that fails leaves the transaction's
+earlier DDL intact, and DDL that conflicts with a concurrently committed DDL
+fails with a serialization error. Autocommit DDL is unchanged.
+
 Simple autocommit scans with a literal non-negative `LIMIT`/`OFFSET` can stop
 after enough matching rows. Queries requiring sorting, grouping, DISTINCT,
 windows, subquery projections, or explicit-transaction overlay validation keep

二進制
bin/pizzasql


+ 2 - 0
pkg/capabilities/capabilities.go

@@ -81,6 +81,7 @@ type Constraints struct {
 }
 
 type DDL struct {
+	Transactional          Feature `json:"transactional"`
 	AddColumn              Feature `json:"add_column"`
 	AddColumnBackfill      Feature `json:"add_column_backfill"`
 	DropColumn             Feature `json:"drop_column"`
@@ -175,6 +176,7 @@ func Current() *Document {
 		},
 
 		DDL: DDL{
+			Transactional:          Feature{Supported: true, Status: "staged_until_commit", Notes: "inside an explicit transaction CREATE TABLE, DROP TABLE, ALTER TABLE (add/drop/rename column, rename table), and CREATE/DROP INDEX are staged in a session-scoped catalog overlay: the issuing session reads its own DDL, other sessions keep reading the committed catalog, ROLLBACK discards the overlay and the transaction's rows together, and COMMIT publishes both in one atomic compare-batch write; validations use the staged catalog, and a staged catalog write that conflicts with a concurrently committed DDL fails with 40001; a failed DDL statement leaves the transaction's earlier schema intact; autocommit DDL keeps the same immediate behavior"},
 			AddColumn:              Feature{Supported: true, Status: "materializes_defaults", Notes: "adds one nullable column; a constant DEFAULT is written into every existing row, and a whitelisted clock default is evaluated once per ALTER statement, so the rewrite cost is O(rows) and holds the table's exclusive gate"},
 			AddColumnBackfill:      Feature{Supported: true, Status: "rewrites_rows", Notes: "a DEFAULT is materialized into existing rows during the rewrite: constants as their stored value, whitelisted clock defaults evaluated at one instant per ALTER statement; ADD COLUMN ... NOT NULL without a DEFAULT fails with 23502 on a non-empty table, ADD COLUMN ... UNIQUE conflicts with 23505 when backfilling a non-NULL default into more than one row (a constant default is predicted before the rewrite, a clock default is validated against the values the rewrite writes), ADD COLUMN ... CHECK fails with 23514 when an existing row or the backfilled default violates the predicate, and the column is published together with its CHECK constraints and UNIQUE index in one atomic schema write"},
 			DropColumn:             Feature{Supported: true, Status: "rewrites_rows", Notes: "rejected with 0A000 when a persisted CHECK constraint references the column or a FOREIGN KEY depends on it (child column or referenced parent column); the stored constraint text cannot be rewritten"},

+ 10 - 0
pkg/capabilities/capabilities_test.go

@@ -59,6 +59,16 @@ func TestJSONArrowOperatorsAreAdvertised(t *testing.T) {
 	}
 }
 
+func TestTransactionalDDLIsAdvertised(t *testing.T) {
+	ddl := Current().DDL
+	if !ddl.Transactional.Supported {
+		t.Fatalf("transactional DDL = %+v, want supported", ddl.Transactional)
+	}
+	if ddl.Transactional.Status != "staged_until_commit" {
+		t.Fatalf("transactional DDL status = %q, want staged_until_commit", ddl.Transactional.Status)
+	}
+}
+
 func TestAddColumnBackfillIsAdvertised(t *testing.T) {
 	doc := Current()
 	if !doc.DDL.AddColumnBackfill.Supported {

+ 1 - 1
pkg/executor/checks.go

@@ -112,7 +112,7 @@ func schemaWithColumn(schema *storage.Schema, column storage.Column) *storage.Sc
 // cannot rewrite them; leaving the old identifier behind would make the
 // constraint evaluate to NULL and silently stop enforcing it.
 func (e *Executor) rejectCheckColumnChange(table, column, action string) error {
-	schema, err := e.schema.GetSchema(table)
+	schema, err := e.session.GetSchema(table)
 	if err != nil {
 		return err
 	}

+ 93 - 84
pkg/executor/executor.go

@@ -45,9 +45,9 @@ type Executor struct {
 	currentDatabase   string                         // current database alias (default is "main")
 
 	// Transaction state
-	inTransaction      bool
-	savepoints         []string // stack of savepoint names
-	savepointPositions []int    // session mutation-log positions for each savepoint
+	inTransaction   bool
+	savepoints      []string                    // stack of savepoint names
+	savepointStates []*storage.SessionSavepoint // full session state (rows and staged DDL) per savepoint
 
 	// LISTEN/NOTIFY state. The hub and subscriber are per database instance;
 	// listenOps and notifyQueue are staged until COMMIT so LISTEN and NOTIFY
@@ -165,7 +165,7 @@ func (e *Executor) NewSessionExecutor() *Executor {
 
 // SyncCatalog synchronizes the analyzer catalog with the storage schema.
 func (e *Executor) SyncCatalog() error {
-	tables, err := e.schema.ListTables()
+	tables, err := e.session.ListTables()
 	if err != nil {
 		return err
 	}
@@ -173,7 +173,7 @@ func (e *Executor) SyncCatalog() error {
 	storageTables := make(map[string]struct{}, len(tables))
 	for _, tableName := range tables {
 		storageTables[strings.ToUpper(tableName)] = struct{}{}
-		schema, err := e.schema.GetSchema(tableName)
+		schema, err := e.session.GetSchema(tableName)
 		if err != nil {
 			continue
 		}
@@ -190,7 +190,7 @@ func (e *Executor) SyncCatalog() error {
 		}
 	}
 
-	e.catalogVersion = e.schema.Version()
+	e.catalogVersion = e.session.Version()
 	return nil
 }
 
@@ -291,22 +291,39 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 		}
 		return e.executeAtomicMutation(func() (*Result, error) { return e.executeDelete(s) })
 	case *parser.CreateTableStmt:
-		return e.executeCreateTable(s)
+		return e.executeDDL(func() (*Result, error) { return e.executeCreateTable(s) })
 	case *parser.DropTableStmt:
-		return e.executeDropTable(s)
+		return e.executeDDL(func() (*Result, error) { return e.executeDropTable(s) })
 	case *parser.CreateIndexStmt:
-		return e.executeCreateIndex(s)
+		return e.executeDDL(func() (*Result, error) { return e.executeCreateIndex(s) })
 	case *parser.DropIndexStmt:
-		return e.executeDropIndex(s)
+		return e.executeDDL(func() (*Result, error) { return e.executeDropIndex(s) })
 	case *parser.AlterTableStmt:
-		return e.executeAlterTable(s)
+		return e.executeDDL(func() (*Result, error) { return e.executeAlterTable(s) })
 	default:
 		return nil, fmt.Errorf("unsupported statement type: %T", stmt)
 	}
 }
 
+// executeDDL runs a schema-changing statement with a statement savepoint. A
+// failing DDL statement discards every catalog and row effect it staged and
+// leaves the transaction's prior schema intact, whether or not a transaction is
+// open.
+func (e *Executor) executeDDL(apply func() (*Result, error)) (*Result, error) {
+	if !e.session.InTx() {
+		return apply()
+	}
+	savepoint := e.session.Savepoint()
+	result, err := apply()
+	if err != nil {
+		e.session.RollbackToSavepoint(savepoint)
+		_ = e.SyncCatalog()
+	}
+	return result, err
+}
+
 func (e *Executor) analyzeWithCatalogRetry(stmt parser.Statement) error {
-	if e.catalogVersion != e.schema.Version() {
+	if e.catalogVersion != e.session.Version() {
 		if err := e.SyncCatalog(); err != nil {
 			return err
 		}
@@ -447,7 +464,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		return e.executeSelectFromSubquery(&modifiedStmt)
 	}
 
-	schema, err := e.schema.GetSchema(tableName)
+	schema, err := e.session.GetSchema(tableName)
 	if err != nil {
 		return nil, err
 	}
@@ -569,7 +586,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 					}
 				} else {
 					// Look for an index on this column
-					indexes, _ := e.schema.ListTableIndexes(tableName)
+					indexes, _ := e.session.ListTableIndexes(tableName)
 					for _, idx := range indexes {
 						if len(idx.Columns) == 1 && strings.EqualFold(idx.Columns[0].Name, colName) {
 							rows, err = e.session.SelectByIndex(tableName, idx.Name, colValue)
@@ -727,7 +744,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 			if alias == "" {
 				alias = tref.Name
 			}
-			sch, _ := e.schema.GetSchema(tref.Name)
+			sch, _ := e.session.GetSchema(tref.Name)
 			cols := map[string]bool{}
 			if sch != nil {
 				for _, c := range sch.Columns {
@@ -965,7 +982,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 			if rerr != nil {
 				return nil, rerr
 			}
-			seedSchema, _ := e.schema.GetSchema(stmt.From[seed].Name)
+			seedSchema, _ := e.session.GetSchema(stmt.From[seed].Name)
 			for j := range seedRows {
 				normalizeRowBySchema(seedRows[j], seedSchema)
 				seedRows[j] = e.addTableAlias(seedRows[j], allTableInfos[seed].alias)
@@ -1007,7 +1024,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				if rerr != nil {
 					return nil, rerr
 				}
-				nextSchema, _ := e.schema.GetSchema(stmt.From[nextC].Name)
+				nextSchema, _ := e.session.GetSchema(stmt.From[nextC].Name)
 				for j := range nextRows {
 					normalizeRowBySchema(nextRows[j], nextSchema)
 					nextRows[j] = e.addTableAlias(nextRows[j], allTableInfos[nextC].alias)
@@ -1092,7 +1109,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				if err != nil {
 					return nil, err
 				}
-				firstSchema, _ := e.schema.GetSchema(stmt.From[first].Name)
+				firstSchema, _ := e.session.GetSchema(stmt.From[first].Name)
 				for i := range rows {
 					normalizeRowBySchema(rows[i], firstSchema)
 					rows[i] = e.addTableAlias(rows[i], allTableInfos[first].alias)
@@ -1118,7 +1135,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				if rerr != nil {
 					return nil, rerr
 				}
-				rightSchema, _ := e.schema.GetSchema(stmt.From[idx].Name)
+				rightSchema, _ := e.session.GetSchema(stmt.From[idx].Name)
 				for j := range rightRows {
 					normalizeRowBySchema(rightRows[j], rightSchema)
 					rightRows[j] = e.addTableAlias(rightRows[j], ti.alias)
@@ -1190,7 +1207,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				if rightAlias == "" {
 					rightAlias = rightRef.Name
 				}
-				rightSchema, _ := e.schema.GetSchema(rightRef.Name)
+				rightSchema, _ := e.session.GetSchema(rightRef.Name)
 				for j := range rightRows {
 					normalizeRowBySchema(rightRows[j], rightSchema)
 					rightRows[j] = e.addTableAlias(rightRows[j], rightAlias)
@@ -1281,7 +1298,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				if isMultiTable || hasJoin {
 					// For multi-table SELECT *, extract columns using qualified names
 					for _, tref := range allTableRefs {
-						sch, _ := e.schema.GetSchema(tref.Name)
+						sch, _ := e.session.GetSchema(tref.Name)
 						if sch != nil {
 							for _, c := range sch.Columns {
 								qualKey := tref.Alias + "." + c.Name
@@ -2100,7 +2117,7 @@ func (e *Executor) executeJoinsWithMode(tableRef parser.TableRef, leftRows []sto
 		rightRows = cloneRows(cte.rows)
 		rightSchema = schemaFromColumns(cte.columns)
 	default:
-		rightSchema, err = e.schema.GetSchema(rightTable)
+		rightSchema, err = e.session.GetSchema(rightTable)
 		if err != nil {
 			return nil, err
 		}
@@ -2416,7 +2433,7 @@ func makeNullRow(rightRows []storage.Row, rightTable string, e *Executor) storag
 			nullRight[k] = nil
 		}
 	} else {
-		rightSchema, err := e.schema.GetSchema(rightTable)
+		rightSchema, err := e.session.GetSchema(rightTable)
 		if err == nil {
 			for _, col := range rightSchema.Columns {
 				nullRight[col.Name] = nil
@@ -2519,7 +2536,7 @@ func (e *Executor) selectColumnNames(stmt *parser.SelectStmt, schema *storage.Sc
 		case col.Star:
 			if multiTable || hasJoin {
 				for _, ref := range refs {
-					if refSchema, err := e.schema.GetSchema(ref.Name); err == nil {
+					if refSchema, err := e.session.GetSchema(ref.Name); err == nil {
 						for _, c := range refSchema.Columns {
 							names = append(names, c.Name)
 						}
@@ -2556,7 +2573,7 @@ func (e *Executor) resolveTableStar(from []parser.TableRef, qualifier string) ([
 	refs := collectAllTableRefs(from)
 	for _, ref := range refs {
 		if strings.EqualFold(ref.Alias, qualifier) {
-			sch, err := e.schema.GetSchema(ref.Name)
+			sch, err := e.session.GetSchema(ref.Name)
 			if err != nil {
 				return nil, "", err
 			}
@@ -2565,7 +2582,7 @@ func (e *Executor) resolveTableStar(from []parser.TableRef, qualifier string) ([
 	}
 	for _, ref := range refs {
 		if strings.EqualFold(ref.Name, qualifier) {
-			sch, err := e.schema.GetSchema(ref.Name)
+			sch, err := e.session.GetSchema(ref.Name)
 			if err != nil {
 				return nil, "", err
 			}
@@ -2638,7 +2655,7 @@ func (e *Executor) findConflictRow(table string, schema *storage.Schema, row sto
 		return found, true, nil
 	}
 
-	indexes, err := e.schema.ListTableIndexes(table)
+	indexes, err := e.session.ListTableIndexes(table)
 	if err != nil {
 		return nil, false, err
 	}
@@ -2742,7 +2759,7 @@ func (e *Executor) applyUpsert(table string, schema *storage.Schema, existing, r
 // executeInsert executes an INSERT statement.
 func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 	tableName := stmt.Table.Name
-	schema, err := e.schema.GetSchema(tableName)
+	schema, err := e.session.GetSchema(tableName)
 	if err != nil {
 		return nil, err
 	}
@@ -3139,7 +3156,7 @@ func (e *Executor) isPointMutation(table string, where parser.Expr, assignments
 	if !equality || !safePrimaryKeyLiteral(value) {
 		return false
 	}
-	schema, err := e.schema.GetSchema(table)
+	schema, err := e.session.GetSchema(table)
 	if err != nil || !schema.IsSingleColumnPrimaryKey() || !strings.EqualFold(column, schema.PrimaryKey) {
 		return false
 	}
@@ -3240,7 +3257,7 @@ func (e *Executor) primeSubqueries(expr parser.Expr) {
 // executeUpdate executes an UPDATE statement.
 func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 	tableName := stmt.Table.Name
-	schema, err := e.schema.GetSchema(tableName)
+	schema, err := e.session.GetSchema(tableName)
 	if err != nil {
 		return nil, err
 	}
@@ -3638,7 +3655,7 @@ func (e *Executor) materializeFrom(from []parser.TableRef) ([]storage.Row, error
 // executeDelete executes a DELETE statement.
 func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 	tableName := stmt.Table.Name
-	schema, err := e.schema.GetSchema(tableName)
+	schema, err := e.session.GetSchema(tableName)
 	if err != nil {
 		return nil, err
 	}
@@ -3704,7 +3721,7 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 // executeCreateTable executes a CREATE TABLE statement.
 func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, error) {
 	// Check if exists
-	if e.schema.TableExists(stmt.Table.Name) {
+	if e.session.TableExists(stmt.Table.Name) {
 		if stmt.IfNotExists {
 			result := NewResult("CREATE TABLE")
 			return result, nil
@@ -3890,7 +3907,7 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 	}
 	schema.ForeignKeys = foreignKeys
 
-	if err := e.schema.CreateTable(schema); err != nil {
+	if err := e.session.CreateTable(schema); err != nil {
 		if stmt.IfNotExists && strings.Contains(err.Error(), "table already exists") {
 			return NewResult("CREATE TABLE"), nil
 		}
@@ -3898,14 +3915,16 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 	}
 
 	// Register each materialized unique index. On failure, best-effort cleanup of
-	// the table and any indexes already created for it (DDL is not transactional).
+	// the table and any indexes already created for it; outside a transaction the
+	// statement's DDL is already durable, while inside one the statement savepoint
+	// discards it.
 	var created []*storage.Index
 	for _, idx := range uniqIndexes {
-		if err := e.schema.CreateIndex(idx); err != nil {
+		if err := e.session.CreateIndex(idx); err != nil {
 			for _, c := range created {
-				e.schema.DropIndex(c.Name)
+				e.session.DropIndex(c.Name)
 			}
-			e.schema.DropTable(stmt.Table.Name)
+			e.session.DropTable(stmt.Table.Name)
 			return nil, err
 		}
 		created = append(created, idx)
@@ -3993,7 +4012,7 @@ func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error)
 	}
 
 	for _, tableRef := range stmt.Tables {
-		if !e.schema.TableExists(tableRef.Name) {
+		if !e.session.TableExists(tableRef.Name) {
 			if stmt.IfExists {
 				continue
 			}
@@ -4004,7 +4023,7 @@ func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error)
 		}
 
 		// First, drop all indexes associated with this table
-		indexes, _ := e.schema.ListTableIndexes(tableRef.Name)
+		indexes, _ := e.session.ListTableIndexes(tableRef.Name)
 		for _, idx := range indexes {
 			// Clear index entries
 			columns := make([]string, len(idx.Columns))
@@ -4013,11 +4032,11 @@ func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error)
 			}
 			e.session.ClearIndex(idx.Name, tableRef.Name, columns)
 			// Drop the index schema
-			e.schema.DropIndex(idx.Name)
+			e.session.DropIndex(idx.Name)
 		}
 
 		// DropTable removes durable rows and schema state together.
-		if err := e.schema.DropTable(tableRef.Name); err != nil {
+		if err := e.session.DropTable(tableRef.Name); err != nil {
 			return nil, err
 		}
 		e.session.InvalidateCache(tableRef.Name)
@@ -4034,7 +4053,7 @@ func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error)
 // executeCreateIndex creates a new index.
 func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, error) {
 	// Check if index already exists
-	if e.schema.IndexExists(stmt.Name) {
+	if e.session.IndexExists(stmt.Name) {
 		if stmt.IfNotExists {
 			result := NewResult("CREATE INDEX")
 			return result, nil
@@ -4043,12 +4062,12 @@ func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, er
 	}
 
 	// Verify table exists
-	if !e.schema.TableExists(stmt.Table) {
+	if !e.session.TableExists(stmt.Table) {
 		return nil, fmt.Errorf("table not found: %s", stmt.Table)
 	}
 
 	// Verify columns exist
-	schema, err := e.schema.GetSchema(stmt.Table)
+	schema, err := e.session.GetSchema(stmt.Table)
 	if err != nil {
 		return nil, err
 	}
@@ -4090,23 +4109,14 @@ func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, er
 		index.Columns = append(index.Columns, ic)
 	}
 
-	if stmt.Unique {
-		// Validate existing rows and register the index under the table's
-		// exclusive gate so no concurrent writer can insert a conflicting value
-		// between validation and registration.
-		if err := e.table.CreateUniqueIndex(index); err != nil {
-			if stmt.IfNotExists && strings.Contains(err.Error(), "index already exists") {
-				return NewResult("CREATE INDEX"), nil
-			}
-			return nil, err
-		}
-	} else {
-		if err := e.schema.CreateIndex(index); err != nil {
-			if stmt.IfNotExists && strings.Contains(err.Error(), "index already exists") {
-				return NewResult("CREATE INDEX"), nil
-			}
-			return nil, err
+	// A UNIQUE index is validated against the rows this session observes (the
+	// autocommit path does so under the table's exclusive gate), and inside a
+	// transaction the definition is staged for the commit instead.
+	if err := e.session.CreateIndex(index); err != nil {
+		if stmt.IfNotExists && strings.Contains(err.Error(), "index already exists") {
+			return NewResult("CREATE INDEX"), nil
 		}
+		return nil, err
 	}
 
 	// Build index entries for existing rows
@@ -4116,7 +4126,7 @@ func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, er
 	}
 	if err := e.session.BuildIndex(stmt.Name, stmt.Table, columns); err != nil {
 		// Rollback index creation on failure
-		e.schema.DropIndex(stmt.Name)
+		e.session.DropIndex(stmt.Name)
 		return nil, fmt.Errorf("failed to build index: %w", err)
 	}
 
@@ -4126,7 +4136,7 @@ func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, er
 
 // executeDropIndex drops an index.
 func (e *Executor) executeDropIndex(stmt *parser.DropIndexStmt) (*Result, error) {
-	if !e.schema.IndexExists(stmt.Name) {
+	if !e.session.IndexExists(stmt.Name) {
 		if stmt.IfExists {
 			result := NewResult("DROP INDEX")
 			return result, nil
@@ -4135,7 +4145,7 @@ func (e *Executor) executeDropIndex(stmt *parser.DropIndexStmt) (*Result, error)
 	}
 
 	// Get index info to clear entries
-	index, err := e.schema.GetIndex(stmt.Name)
+	index, err := e.session.GetIndex(stmt.Name)
 	if err == nil && index != nil {
 		columns := make([]string, len(index.Columns))
 		for i, col := range index.Columns {
@@ -4144,7 +4154,7 @@ func (e *Executor) executeDropIndex(stmt *parser.DropIndexStmt) (*Result, error)
 		e.session.ClearIndex(stmt.Name, index.Table, columns)
 	}
 
-	if err := e.schema.DropIndex(stmt.Name); err != nil {
+	if err := e.session.DropIndex(stmt.Name); err != nil {
 		return nil, err
 	}
 
@@ -4190,7 +4200,7 @@ func (e *Executor) executeCreateView(stmt *parser.CreateViewStmt) (*Result, erro
 	// For SELECT *, pull columns from the underlying table(s).
 	if hasStar && len(stmt.Select.From) > 0 {
 		baseName := stmt.Select.From[0].Name
-		if schema, err := e.schema.GetSchema(baseName); err == nil {
+		if schema, err := e.session.GetSchema(baseName); err == nil {
 			for _, c := range schema.Columns {
 				viewCols = append(viewCols, analyzer.ColumnInfo{
 					Name:      c.Name,
@@ -4250,7 +4260,7 @@ func (e *Executor) executeAlterTable(stmt *parser.AlterTableStmt) (*Result, erro
 // one is evaluated at a single instant sampled for the ALTER statement, so
 // validation and the backfill write the same CURRENT_TIMESTAMP value.
 func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddColumnAction) (*Result, error) {
-	schema, err := e.schema.GetSchema(table)
+	schema, err := e.session.GetSchema(table)
 	if err != nil {
 		return nil, err
 	}
@@ -4330,7 +4340,7 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 		return nil, err
 	}
 
-	if err := e.schema.AddColumn(table, col, checks, uniqueIndex); err != nil {
+	if err := e.session.AddColumn(table, col, checks, uniqueIndex); err != nil {
 		if action.IfNotExists && strings.Contains(err.Error(), "column already exists") {
 			return NewResult("ALTER TABLE"), nil
 		}
@@ -4354,7 +4364,7 @@ func (e *Executor) addColumnUniqueIndex(table, column string, constraint *parser
 		base = constraint.Name
 	}
 	name := base
-	for n := 2; e.schema.IndexExists(name); n++ {
+	for n := 2; e.session.IndexExists(name); n++ {
 		name = fmt.Sprintf("%s_%d", base, n)
 	}
 	index := &storage.Index{
@@ -4377,7 +4387,7 @@ func (e *Executor) executeAlterTableDropColumn(table string, action *parser.Drop
 	if err := e.rejectForeignKeyColumnChange(table, action.Column, "drop"); err != nil {
 		return nil, err
 	}
-	if err := e.schema.DropColumn(table, action.Column); err != nil {
+	if err := e.session.DropColumn(table, action.Column); err != nil {
 		return nil, err
 	}
 
@@ -4393,7 +4403,7 @@ func (e *Executor) executeAlterTableRename(table string, action *parser.RenameTa
 	if err := e.rejectForeignKeyTableRename(table); err != nil {
 		return nil, err
 	}
-	if err := e.schema.RenameTable(table, action.NewName); err != nil {
+	if err := e.session.RenameTable(table, action.NewName); err != nil {
 		return nil, err
 	}
 	e.session.InvalidateCache(table)
@@ -4417,7 +4427,7 @@ func (e *Executor) executeAlterTableRenameColumn(table string, action *parser.Re
 	if err := e.rejectForeignKeyColumnChange(table, action.OldName, "rename"); err != nil {
 		return nil, err
 	}
-	if err := e.schema.RenameColumn(table, action.OldName, action.NewName); err != nil {
+	if err := e.session.RenameColumn(table, action.OldName, action.NewName); err != nil {
 		return nil, err
 	}
 
@@ -4434,7 +4444,7 @@ func (e *Executor) executeAlterTableRenameColumn(table string, action *parser.Re
 // UNIQUE expression index, because its rows no longer resolve the referenced
 // column.
 func (e *Executor) rejectExpressionIndexRename(table, column string) error {
-	indexes, err := e.schema.ListTableIndexes(table)
+	indexes, err := e.session.ListTableIndexes(table)
 	if err != nil {
 		return err
 	}
@@ -4471,7 +4481,7 @@ func (e *Executor) executeBegin(stmt *parser.BeginStmt) (*Result, error) {
 	}
 	e.inTransaction = true
 	e.savepoints = nil
-	e.savepointPositions = nil
+	e.savepointStates = nil
 	e.clearNotificationState()
 
 	result := NewResult("BEGIN")
@@ -4488,14 +4498,14 @@ func (e *Executor) executeCommit(stmt *parser.CommitStmt) (*Result, error) {
 	if err != nil {
 		e.inTransaction = false
 		e.savepoints = nil
-		e.savepointPositions = nil
+		e.savepointStates = nil
 		e.finishRollback()
 		return nil, err
 	}
 
 	e.inTransaction = false
 	e.savepoints = nil
-	e.savepointPositions = nil
+	e.savepointStates = nil
 	e.finishCommit(lsn)
 
 	result := NewResult("COMMIT")
@@ -4519,7 +4529,7 @@ func (e *Executor) executeRollback(stmt *parser.RollbackStmt) (*Result, error) {
 	}
 	e.inTransaction = false
 	e.savepoints = nil
-	e.savepointPositions = nil
+	e.savepointStates = nil
 	e.finishRollback()
 	// total_changes() is monotonic (SQLite semantics): it is NOT decremented on
 	// rollback. last_insert_rowid() and changes() also intentionally hold.
@@ -4537,13 +4547,13 @@ func (e *Executor) executeSavepoint(stmt *parser.SavepointStmt) (*Result, error)
 		}
 		e.inTransaction = true
 		e.savepoints = nil
-		e.savepointPositions = nil
+		e.savepointStates = nil
 		e.clearNotificationState()
 	}
 
 	// Add savepoint marker
 	e.savepoints = append(e.savepoints, stmt.Name)
-	e.savepointPositions = append(e.savepointPositions, e.session.Snapshot())
+	e.savepointStates = append(e.savepointStates, e.session.Savepoint())
 	e.notificationSaves = append(e.notificationSaves, e.notificationSnapshot())
 
 	result := NewResult("SAVEPOINT")
@@ -4561,7 +4571,7 @@ func (e *Executor) executeRelease(stmt *parser.ReleaseStmt) (*Result, error) {
 	for i := len(e.savepoints) - 1; i >= 0; i-- {
 		if e.savepoints[i] == stmt.Name {
 			e.savepoints = e.savepoints[:i]
-			e.savepointPositions = e.savepointPositions[:i]
+			e.savepointStates = e.savepointStates[:i]
 			if i <= len(e.notificationSaves) {
 				e.notificationSaves = e.notificationSaves[:i]
 			}
@@ -4660,8 +4670,7 @@ func (e *Executor) rollbackToSavepoint(name string) (*Result, error) {
 		return nil, fmt.Errorf("no such savepoint: %s", name)
 	}
 
-	logPosition := e.savepointPositions[savepointIdx]
-	e.session.RollbackTo(logPosition)
+	e.session.RollbackToSavepoint(e.savepointStates[savepointIdx])
 	if savepointIdx < len(e.notificationSaves) {
 		e.rollbackNotificationsTo(e.notificationSaves[savepointIdx])
 		e.notificationSaves = e.notificationSaves[:savepointIdx+1]
@@ -4669,7 +4678,7 @@ func (e *Executor) rollbackToSavepoint(name string) (*Result, error) {
 
 	// Remove savepoints after the target
 	e.savepoints = e.savepoints[:savepointIdx+1]
-	e.savepointPositions = e.savepointPositions[:savepointIdx+1]
+	e.savepointStates = e.savepointStates[:savepointIdx+1]
 
 	result := NewResult("ROLLBACK")
 	return result, nil
@@ -4851,7 +4860,7 @@ func (e *Executor) pragmaTableInfo(tableName string) (*Result, error) {
 		return nil, fmt.Errorf("table_info requires a table name")
 	}
 
-	schema, err := e.schema.GetSchema(tableName)
+	schema, err := e.session.GetSchema(tableName)
 	if err != nil {
 		return nil, err
 	}
@@ -4877,7 +4886,7 @@ func (e *Executor) pragmaTableInfo(tableName string) (*Result, error) {
 
 // pragmaTableList returns a list of all tables.
 func (e *Executor) pragmaTableList() (*Result, error) {
-	tables, err := e.schema.ListTables()
+	tables, err := e.session.ListTables()
 	if err != nil {
 		return nil, err
 	}

+ 5 - 5
pkg/executor/foreign_keys.go

@@ -111,7 +111,7 @@ func (e *Executor) resolveForeignKey(table string, schema *storage.Schema, uniqu
 		parent = schema
 		parentIndexes = uniqueIndexes
 	} else {
-		resolved, err := e.schema.GetSchema(declaration.refTable)
+		resolved, err := e.session.GetSchema(declaration.refTable)
 		if err != nil {
 			return storage.ForeignKeyConstraint{}, pgerror.Unsupported(
 				"foreign key %s on table %s references table %q, which does not exist; create the parent table before the child table",
@@ -119,7 +119,7 @@ func (e *Executor) resolveForeignKey(table string, schema *storage.Schema, uniqu
 			)
 		}
 		parent = resolved
-		indexes, err := e.schema.ListTableIndexes(declaration.refTable)
+		indexes, err := e.session.ListTableIndexes(declaration.refTable)
 		if err != nil {
 			return storage.ForeignKeyConstraint{}, err
 		}
@@ -235,7 +235,7 @@ func sameColumnSet(a, b []string) bool {
 // rewritten, so accepting the change would either silently stop enforcing the
 // constraint or leave the catalog naming a missing column.
 func (e *Executor) rejectForeignKeyColumnChange(table, column, action string) error {
-	labels, err := e.schema.ForeignKeyColumnDependency(table, column)
+	labels, err := e.session.ForeignKeyColumnDependency(table, column)
 	if err != nil {
 		return err
 	}
@@ -253,7 +253,7 @@ func (e *Executor) rejectForeignKeyColumnChange(table, column, action string) er
 // persisted verbatim, so a rename would leave parent-side enforcement pointing
 // at a table that no longer exists.
 func (e *Executor) rejectForeignKeyTableRename(table string) error {
-	labels, err := e.schema.ForeignKeyTableDependency(table)
+	labels, err := e.session.ForeignKeyTableDependency(table)
 	if err != nil {
 		return err
 	}
@@ -270,7 +270,7 @@ func (e *Executor) rejectForeignKeyTableRename(table string) error {
 // foreign key names as its parent, because the constraint would be left dangling
 // on the child table.
 func (e *Executor) rejectForeignKeyDependentTableDrop(table string) error {
-	labels, err := e.schema.ForeignKeyTableDependency(table)
+	labels, err := e.session.ForeignKeyTableDependency(table)
 	if err != nil {
 		return err
 	}

+ 3 - 3
pkg/executor/generated.go

@@ -82,7 +82,7 @@ func (e *Executor) ensureGeneratedRowID(tableName string, schema *storage.Schema
 	if !ok || !isIntegerColumnType(pkCol.Type) {
 		return nil
 	}
-	id, err := e.schema.GetNextRowID(tableName)
+	id, err := e.session.GetNextRowID(tableName)
 	if err != nil {
 		return err
 	}
@@ -124,7 +124,7 @@ func (e *Executor) insertConflictMatchers(tableName string, schema *storage.Sche
 	if stmtReplace {
 		matchers = append(matchers, conflictMatcher{primaryKey: true})
 	}
-	indexes, err := e.schema.ListTableIndexes(tableName)
+	indexes, err := e.session.ListTableIndexes(tableName)
 	if err != nil {
 		return nil, err
 	}
@@ -180,7 +180,7 @@ func (e *Executor) matcherConflicts(tableName string, schema *storage.Schema, m
 // not just the primary key.
 func (e *Executor) hasAnyInsertConflict(tableName string, schema *storage.Schema, candidate storage.Row) (bool, error) {
 	matchers := []conflictMatcher{{primaryKey: true}}
-	indexes, err := e.schema.ListTableIndexes(tableName)
+	indexes, err := e.session.ListTableIndexes(tableName)
 	if err != nil {
 		return false, err
 	}

+ 2 - 2
pkg/executor/result_types.go

@@ -75,7 +75,7 @@ func (e *Executor) joinedSelectColumnTypes(stmt *parser.SelectStmt, refs []parse
 	for _, projection := range stmt.Columns {
 		if projection.Star {
 			for _, ref := range refs {
-				if schema, err := e.schema.GetSchema(ref.Name); err == nil {
+				if schema, err := e.session.GetSchema(ref.Name); err == nil {
 					for _, column := range schema.Columns {
 						types = append(types, column.Type)
 					}
@@ -106,7 +106,7 @@ func (e *Executor) joinedTypeResolver(refs []parser.TableRef) columnTypeResolver
 			if table != "" && !strings.EqualFold(table, ref.Alias) && !strings.EqualFold(table, ref.Name) {
 				continue
 			}
-			schema, err := e.schema.GetSchema(ref.Name)
+			schema, err := e.session.GetSchema(ref.Name)
 			if err != nil {
 				continue
 			}

+ 8 - 8
pkg/executor/sqlite_catalog.go

@@ -72,14 +72,14 @@ func (e *Executor) sqliteCatalogSelect(stmt *parser.SelectStmt) (*Result, bool,
 func (e *Executor) sqliteCatalogRows() ([]storage.Row, error) {
 	var rows []storage.Row
 
-	tables, err := e.schema.ListTables()
+	tables, err := e.session.ListTables()
 	if err != nil {
 		return nil, err
 	}
 	tableNames := append([]string(nil), tables...)
 	sort.Strings(tableNames)
 	for _, name := range tableNames {
-		schema, err := e.schema.GetSchema(name)
+		schema, err := e.session.GetSchema(name)
 		if err != nil {
 			return nil, fmt.Errorf("sqlite_catalog: resolve table %q: %w", name, err)
 		}
@@ -92,14 +92,14 @@ func (e *Executor) sqliteCatalogRows() ([]storage.Row, error) {
 		})
 	}
 
-	indexes, err := e.schema.ListIndexes()
+	indexes, err := e.session.ListIndexes()
 	if err != nil {
 		return nil, err
 	}
 	indexNames := append([]string(nil), indexes...)
 	sort.Strings(indexNames)
 	for _, name := range indexNames {
-		idx, err := e.schema.GetIndex(name)
+		idx, err := e.session.GetIndex(name)
 		if err != nil {
 			return nil, fmt.Errorf("sqlite_catalog: resolve index %q: %w", name, err)
 		}
@@ -219,7 +219,7 @@ func (e *Executor) pragmaIndexList(table string) (*Result, error) {
 	if table == "" {
 		return nil, fmt.Errorf("index_list requires a table name")
 	}
-	indexes, err := e.schema.ListTableIndexes(table)
+	indexes, err := e.session.ListTableIndexes(table)
 	if err != nil {
 		return nil, err
 	}
@@ -244,11 +244,11 @@ func (e *Executor) pragmaIndexInfo(name string) (*Result, error) {
 	if name == "" {
 		return nil, fmt.Errorf("index_info requires an index name")
 	}
-	idx, err := e.schema.GetIndex(name)
+	idx, err := e.session.GetIndex(name)
 	if err != nil {
 		return nil, err
 	}
-	schema, err := e.schema.GetSchema(idx.Table)
+	schema, err := e.session.GetSchema(idx.Table)
 	if err != nil {
 		return nil, err
 	}
@@ -276,7 +276,7 @@ func (e *Executor) pragmaTableXInfo(table string) (*Result, error) {
 	if table == "" {
 		return nil, fmt.Errorf("table_xinfo requires a table name")
 	}
-	schema, err := e.schema.GetSchema(table)
+	schema, err := e.session.GetSchema(table)
 	if err != nil {
 		return nil, err
 	}

+ 607 - 0
pkg/executor/transactional_ddl_test.go

@@ -0,0 +1,607 @@
+package executor
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// transactional DDL: CREATE TABLE and its rows are visible to the transaction
+// that issued them, invisible to other sessions, and discarded by ROLLBACK.
+func TestTransactionalDDLCreateTableRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	writer := newExec(schema, table)
+	observer := newExec(schema, table)
+
+	execMust(t, writer, "BEGIN")
+	execMust(t, writer, "CREATE TABLE staged (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, writer, "INSERT INTO staged VALUES (1, 'one')")
+
+	// Read-your-writes inside the transaction.
+	if res := execMust(t, writer, "SELECT v FROM staged WHERE id = 1"); res.RowCount != 1 || res.Rows[0][0] != "one" {
+		t.Fatalf("staged table read = %v", res.Rows)
+	}
+	// Another session must not see the staged table.
+	if _, err := execSQL(observer, "SELECT * FROM staged"); err == nil {
+		t.Fatal("observer saw a table created in another session's uncommitted transaction")
+	}
+
+	execMust(t, writer, "ROLLBACK")
+
+	if _, err := execSQL(writer, "SELECT * FROM staged"); err == nil {
+		t.Fatal("staged table survived ROLLBACK")
+	}
+	if schema.TableExists("staged") {
+		t.Fatal("durable catalog contains a rolled-back table")
+	}
+	if _, err := schema.GetSchema("staged"); err == nil {
+		t.Fatal("durable catalog still resolves the rolled-back table")
+	}
+	// DDL and its rows are one unit: the staged INSERT left no durable rows.
+	if rows, err := table.Select("staged", nil); err == nil && len(rows) > 0 {
+		t.Fatalf("rolled-back table left %d durable rows", len(rows))
+	}
+}
+
+// transactional DDL: DROP TABLE is undone by ROLLBACK, rows included.
+func TestTransactionalDDLDropTableRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE ledger (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO ledger VALUES (1, 'a'), (2, 'b')")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "DROP TABLE ledger")
+	if _, err := execSQL(e, "SELECT * FROM ledger"); err == nil {
+		t.Fatal("dropped table is still visible to the transaction")
+	}
+	execMust(t, e, "ROLLBACK")
+
+	res := execMust(t, e, "SELECT id, v FROM ledger ORDER BY id")
+	if res.RowCount != 2 {
+		t.Fatalf("rows after rollback = %v", res.Rows)
+	}
+	// The catalog and the rows must both be intact after the rollback.
+	if !schema.TableExists("ledger") {
+		t.Fatal("durable catalog lost the table")
+	}
+	row, err := table.GetByPK("ledger", "1")
+	if err != nil || row["v"] != "a" {
+		t.Fatalf("durable row after rollback: %v %v", row, err)
+	}
+}
+
+// transactional DDL: ALTER TABLE variants are undone by ROLLBACK.
+func TestTransactionalDDLAlterRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)")
+	execMust(t, e, "INSERT INTO people VALUES (1, 'ada')")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "ALTER TABLE people ADD COLUMN age INTEGER DEFAULT 30")
+	// Read-your-writes: the new column exists for this session and carries the
+	// backfilled default.
+	res := execMust(t, e, "SELECT age FROM people WHERE id = 1")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(30) {
+		t.Fatalf("staged ALTER ADD COLUMN read = %v", res.Rows)
+	}
+	execMust(t, e, "ALTER TABLE people RENAME COLUMN name TO full_name")
+	if res := execMust(t, e, "SELECT full_name FROM people"); res.Rows[0][0] != "ada" {
+		t.Fatalf("staged RENAME COLUMN read = %v", res.Rows)
+	}
+	execMust(t, e, "ALTER TABLE people RENAME TO humans")
+	if res := execMust(t, e, "SELECT full_name FROM humans"); res.Rows[0][0] != "ada" {
+		t.Fatalf("staged RENAME TABLE read = %v", res.Rows)
+	}
+	execMust(t, e, "ROLLBACK")
+
+	res = execMust(t, e, "SELECT id, name FROM people")
+	if res.RowCount != 1 || res.Rows[0][1] != "ada" {
+		t.Fatalf("rows after rollback = %v", res.Rows)
+	}
+	if _, err := execSQL(e, "SELECT age FROM people"); err == nil {
+		t.Fatal("rolled-back ADD COLUMN is still visible")
+	}
+	if _, err := execSQL(e, "SELECT * FROM humans"); err == nil {
+		t.Fatal("rolled-back RENAME TABLE is still visible")
+	}
+	if _, err := execSQL(e, "SELECT * FROM people"); err != nil {
+		t.Fatalf("original table name lost by ROLLBACK: %v", err)
+	}
+}
+
+// transactional DDL: CREATE INDEX is transaction-scoped.
+func TestTransactionalDDLCreateIndexRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE items (id INTEGER PRIMARY KEY, sku TEXT)")
+	execMust(t, e, "INSERT INTO items VALUES (1, 'a'), (2, 'b')")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE UNIQUE INDEX idx_items_sku ON items (sku)")
+	if !e.session.IndexExists("idx_items_sku") {
+		t.Fatal("staged index missing from the transaction's catalog")
+	}
+	if schema.IndexExists("idx_items_sku") {
+		t.Fatal("staged index leaked into the committed catalog")
+	}
+	execMust(t, e, "ROLLBACK")
+
+	if schema.IndexExists("idx_items_sku") {
+		t.Fatal("rolled-back index survived in the durable catalog")
+	}
+	// The index name is reusable, which proves the catalog write was discarded.
+	execMust(t, e, "CREATE INDEX idx_items_sku ON items (sku)")
+}
+
+// transactional DDL: another session keeps reading the committed catalog until
+// the DDL commit publishes it.
+func TestTransactionalDDLTwoSessionVisibility(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	writer := newExec(schema, table)
+	observer := newExec(schema, table)
+
+	execMust(t, writer, "CREATE TABLE base (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, observer, "SELECT * FROM base")
+	execMust(t, writer, "INSERT INTO base VALUES (1, 'committed')")
+
+	execMust(t, writer, "BEGIN")
+	execMust(t, writer, "CREATE TABLE extra (id INTEGER PRIMARY KEY)")
+	execMust(t, writer, "ALTER TABLE base ADD COLUMN note TEXT DEFAULT 'new'")
+	execMust(t, writer, "CREATE INDEX idx_base_note ON base (note)")
+
+	// The observer sees neither the new table nor the new column, and it still
+	// reads the committed rows.
+	if _, err := execSQL(observer, "SELECT * FROM extra"); err == nil {
+		t.Fatal("observer saw staged CREATE TABLE before COMMIT")
+	}
+	if _, err := execSQL(observer, "SELECT note FROM base"); err == nil {
+		t.Fatal("observer saw a staged column before COMMIT")
+	}
+	if res := execMust(t, observer, "SELECT v FROM base"); res.RowCount != 1 || res.Rows[0][0] != "committed" {
+		t.Fatalf("observer read = %v", res.Rows)
+	}
+
+	execMust(t, writer, "COMMIT")
+
+	if _, err := execSQL(observer, "SELECT * FROM extra"); err != nil {
+		t.Fatalf("observer did not see committed CREATE TABLE: %v", err)
+	}
+	// The observer's executor resynchronizes its analyzer catalog from the
+	// bumped catalog version.
+	res := execMust(t, observer, "SELECT note FROM base")
+	if res.RowCount != 1 || res.Rows[0][0] != "new" {
+		t.Fatalf("observer row after COMMIT = %v", res.Rows)
+	}
+	if !schema.IndexExists("idx_base_note") {
+		t.Fatal("committed index missing from the durable catalog")
+	}
+}
+
+// transactional DDL: COMMIT publishes every staged statement atomically and the
+// result reloads from durable storage.
+func TestTransactionalDDLCommitPublishesAndReloads(t *testing.T) {
+	pool, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE orders (id INTEGER PRIMARY KEY, total INTEGER)")
+	execMust(t, e, "INSERT INTO orders VALUES (1, 10)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "ALTER TABLE orders ADD COLUMN region TEXT DEFAULT 'eu'")
+	execMust(t, e, "CREATE TABLE shipments (id INTEGER PRIMARY KEY, order_id INTEGER)")
+	execMust(t, e, "INSERT INTO shipments VALUES (100, 1)")
+	execMust(t, e, "CREATE INDEX idx_orders_region ON orders (region)")
+	execMust(t, e, "COMMIT")
+
+	// Reload the catalog from durable state with fresh managers over the same KV
+	// server: the commit must have written schema, index, and rows together.
+	reloadedSchemas := storage.NewSchemaManager(pool, "testdb")
+	reloadedTables := storage.NewTableManager(pool, reloadedSchemas, "testdb")
+
+	orders, err := reloadedSchemas.GetSchema("orders")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := orders.GetColumn("region"); !ok {
+		t.Fatal("committed ADD COLUMN missing after reload")
+	}
+	if !reloadedSchemas.IndexExists("idx_orders_region") {
+		t.Fatal("committed index missing after reload")
+	}
+	if !reloadedSchemas.TableExists("shipments") {
+		t.Fatal("committed CREATE TABLE missing after reload")
+	}
+	orderRow, err := reloadedTables.GetByPK("orders", "1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if orderRow["region"] != "eu" {
+		t.Fatalf("backfilled default after reload = %v", orderRow)
+	}
+	shipment, err := reloadedTables.GetByPK("shipments", "100")
+	if err != nil || shipment["order_id"] != int64(1) {
+		t.Fatalf("committed insert after reload: %v %v", shipment, err)
+	}
+}
+
+// transactional DDL: DML and DDL commit or roll back as one unit.
+func TestTransactionalDDLWithDML(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE events (id INTEGER PRIMARY KEY, kind TEXT)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE TABLE archive (id INTEGER PRIMARY KEY, kind TEXT)")
+	execMust(t, e, "INSERT INTO archive VALUES (1, 'old')")
+	execMust(t, e, "INSERT INTO events VALUES (1, 'new')")
+	execMust(t, e, "ALTER TABLE events ADD COLUMN archived INTEGER DEFAULT 0")
+	execMust(t, e, "ROLLBACK")
+
+	if _, err := execSQL(e, "SELECT * FROM archive"); err == nil {
+		t.Fatal("rollback kept a table created with DML in the same transaction")
+	}
+	if res := execMust(t, e, "SELECT COUNT(*) FROM events"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("rollback kept a DML row: %v", res.Rows)
+	}
+	if _, err := execSQL(e, "SELECT archived FROM events"); err == nil {
+		t.Fatal("rollback kept a staged column")
+	}
+
+	// The same sequence commits as one unit.
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE TABLE archive (id INTEGER PRIMARY KEY, kind TEXT)")
+	execMust(t, e, "INSERT INTO archive VALUES (1, 'old')")
+	execMust(t, e, "INSERT INTO events VALUES (1, 'new')")
+	execMust(t, e, "ALTER TABLE events ADD COLUMN archived INTEGER DEFAULT 0")
+	execMust(t, e, "COMMIT")
+
+	if res := execMust(t, e, "SELECT COUNT(*) FROM archive"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("committed CREATE TABLE rows = %v", res.Rows)
+	}
+	if res := execMust(t, e, "SELECT archived FROM events WHERE id = 1"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("committed ADD COLUMN default = %v", res.Rows)
+	}
+}
+
+// Transactional DDL: a rejected statement leaves the transaction's prior schema
+// intact, including DDL staged before it.
+func TestTransactionalDDLFailureLeavesPriorSchema(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
+	execMust(t, e, "INSERT INTO parent VALUES (1)")
+	execMust(t, e, "INSERT INTO child VALUES (10, 1)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE TABLE scratch (id INTEGER PRIMARY KEY)")
+	// Duplicate primary key inside the staged table fails the statement.
+	if _, err := execSQL(e, "INSERT INTO scratch VALUES (1), (1)"); err == nil {
+		t.Fatal("duplicate primary key accepted")
+	}
+	// Dropping a referenced parent is rejected.
+	if _, err := execSQL(e, "DROP TABLE parent"); err == nil {
+		t.Fatal("dropped a table another table references")
+	}
+	// Adding a NOT NULL column without a default to a populated table fails.
+	if _, err := execSQL(e, "ALTER TABLE child ADD COLUMN required TEXT NOT NULL"); err == nil {
+		t.Fatal("NOT NULL column without default accepted on a populated table")
+	}
+
+	// Earlier staged DDL is still visible and staged DML is intact.
+	if res := execMust(t, e, "SELECT COUNT(*) FROM scratch"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("scratch rows = %v", res.Rows)
+	}
+	if res := execMust(t, e, "SELECT COUNT(*) FROM child"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("child rows after failed statements = %v", res.Rows)
+	}
+	execMust(t, e, "COMMIT")
+
+	if !schema.TableExists("scratch") {
+		t.Fatal("committed staged table missing after a failed statement")
+	}
+	if schema.TableExists("scratch") && schema.TableExists("nonexistent") {
+		t.Fatal("unreachable")
+	}
+}
+
+// Transactional DDL: a multi-statement migration rolls back as one unit.
+func TestTransactionalDDLMultiStatementMigrationRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE TABLE audit (id INTEGER PRIMARY KEY, account_id INTEGER)")
+	execMust(t, e, "ALTER TABLE accounts ADD COLUMN currency TEXT DEFAULT 'EUR'")
+	execMust(t, e, "CREATE UNIQUE INDEX idx_accounts_currency ON accounts (currency)")
+	execMust(t, e, "INSERT INTO accounts VALUES (1, 100, 'EUR')")
+	execMust(t, e, "INSERT INTO audit VALUES (1, 1)")
+	execMust(t, e, "ROLLBACK")
+
+	if schema.TableExists("audit") || schema.IndexExists("idx_accounts_currency") {
+		t.Fatal("migration objects survived ROLLBACK")
+	}
+	accounts, err := schema.GetSchema("accounts")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := accounts.GetColumn("currency"); ok {
+		t.Fatal("migration column survived ROLLBACK")
+	}
+}
+
+// Transactional DDL: a UNIQUE index staged by the transaction is enforced by
+// the commit's validation, so duplicate values never become durable.
+func TestTransactionalDDLStagedUniqueIndexValidation(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)")
+	execMust(t, e, "INSERT INTO accounts VALUES (1, 100)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "ALTER TABLE accounts ADD COLUMN currency TEXT DEFAULT 'EUR'")
+	execMust(t, e, "CREATE UNIQUE INDEX idx_accounts_currency ON accounts (currency)")
+	execMust(t, e, "INSERT INTO accounts (id, balance) VALUES (2, 200)")
+	// Both rows now carry the backfilled default 'EUR', so the staged UNIQUE
+	// index must reject the transaction at commit.
+	if _, err := execSQL(e, "COMMIT"); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("commit with duplicate staged UNIQUE values = %v", err)
+	}
+	if schema.IndexExists("idx_accounts_currency") {
+		t.Fatal("failed commit published the staged index")
+	}
+	if res := execMust(t, e, "SELECT COUNT(*) FROM accounts"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("failed commit published staged rows: %v", res.Rows)
+	}
+}
+
+// Transactional DDL: validations run against the staged catalog.
+func TestTransactionalDDLValidationsUseStagedSchema(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE TABLE parents (a INTEGER, b INTEGER, PRIMARY KEY (a, b))")
+	execMust(t, e, "CREATE TABLE kids (id INTEGER PRIMARY KEY, pa INTEGER, pb INTEGER, FOREIGN KEY (pa, pb) REFERENCES parents(a, b))")
+	execMust(t, e, "INSERT INTO parents VALUES (1, 2)")
+	execMust(t, e, "INSERT INTO kids VALUES (10, 1, 2)")
+	// Composite primary key: the same pair collides, a different pair does not.
+	if _, err := execSQL(e, "INSERT INTO parents VALUES (1, 2)"); err == nil {
+		t.Fatal("duplicate composite primary key accepted")
+	}
+	execMust(t, e, "INSERT INTO parents VALUES (3, 4)")
+	// CHECK added by a staged ALTER TABLE is enforced by staged writes.
+	execMust(t, e, "ALTER TABLE kids ADD COLUMN age INTEGER CHECK (age >= 0)")
+	execMust(t, e, "INSERT INTO kids VALUES (12, 3, 4, 5)")
+	if _, err := execSQL(e, "INSERT INTO kids VALUES (13, 3, 4, -1)"); err == nil {
+		t.Fatal("staged CHECK constraint not enforced")
+	}
+	// NOT NULL added by a staged ALTER TABLE is enforced by staged writes.
+	execMust(t, e, "ALTER TABLE kids ADD COLUMN tag TEXT NOT NULL DEFAULT 't'")
+	if _, err := execSQL(e, "INSERT INTO kids (id, pa, pb, age, tag) VALUES (14, 3, 4, 1, NULL)"); err == nil {
+		t.Fatal("staged NOT NULL constraint not enforced")
+	}
+	execMust(t, e, "COMMIT")
+
+	// The staged constraints are durable and still enforced.
+	if _, err := execSQL(e, "INSERT INTO kids VALUES (15, 3, 4, -1, 'x')"); err == nil {
+		t.Fatal("committed CHECK constraint not enforced")
+	}
+
+	// A composite foreign key declared by staged DDL is validated at commit
+	// against the transaction's final state.
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO kids (id, pa, pb) VALUES (11, 1, 9)")
+	if _, err := execSQL(e, "COMMIT"); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
+		t.Fatalf("commit with a dangling staged composite foreign key = %v", err)
+	}
+	if res := execMust(t, e, "SELECT COUNT(*) FROM kids WHERE id = 11"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("failed commit published the dangling row: %v", res.Rows)
+	}
+	if _, err := execSQL(e, "INSERT INTO kids (id, pa, pb) VALUES (16, 8, 9)"); err == nil {
+		t.Fatal("committed FOREIGN KEY not enforced")
+	}
+}
+
+// Transactional DDL: cascades and composite keys keep working across a commit.
+func TestTransactionalDDLCascadesAcrossCommit(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE TABLE parents (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE kids (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parents(id) ON DELETE CASCADE)")
+	execMust(t, e, "INSERT INTO parents VALUES (1)")
+	execMust(t, e, "INSERT INTO kids VALUES (10, 1)")
+	execMust(t, e, "COMMIT")
+
+	if res := execMust(t, e, "SELECT COUNT(*) FROM kids"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("kids = %v", res.Rows)
+	}
+	execMust(t, e, "DELETE FROM parents WHERE id = 1")
+	if res := execMust(t, e, "SELECT COUNT(*) FROM kids"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("cascade did not delete the child row: %v", res.Rows)
+	}
+}
+
+// Transactional DDL: a concurrent committed DDL that conflicts with the staged
+// change fails the commit with a serialization error instead of corrupting the
+// catalog.
+func TestTransactionalDDLConcurrentConflict(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	first := newExec(schema, table)
+	second := newExec(schema, table)
+
+	execMust(t, first, "BEGIN")
+	execMust(t, first, "CREATE TABLE races (id INTEGER PRIMARY KEY)")
+
+	// A second session creates the same table and commits first.
+	execMust(t, second, "CREATE TABLE races (id INTEGER PRIMARY KEY, v TEXT)")
+
+	if _, err := execSQL(first, "COMMIT"); err == nil {
+		t.Fatal("conflicting DDL commit succeeded")
+	} else if !strings.Contains(err.Error(), "serialization") {
+		t.Fatalf("conflicting DDL commit error = %v, want a serialization error", err)
+	}
+
+	// The committed definition wins and stays intact.
+	stored, err := schema.GetSchema("races")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := stored.GetColumn("v"); !ok {
+		t.Fatalf("concurrent committed schema was overwritten: %+v", stored.Columns)
+	}
+}
+
+// Transactional DDL: a savepoint rollback also reverts staged DDL.
+func TestTransactionalDDLSavepointRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "CREATE TABLE before_sp (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "SAVEPOINT sp1")
+	execMust(t, e, "CREATE TABLE after_sp (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "ALTER TABLE t ADD COLUMN v TEXT DEFAULT 'x'")
+	execMust(t, e, "ROLLBACK TO SAVEPOINT sp1")
+
+	if _, err := execSQL(e, "SELECT * FROM after_sp"); err == nil {
+		t.Fatal("DDL after the savepoint survived ROLLBACK TO SAVEPOINT")
+	}
+	if _, err := execSQL(e, "SELECT v FROM t"); err == nil {
+		t.Fatal("ALTER after the savepoint survived ROLLBACK TO SAVEPOINT")
+	}
+	if _, err := execSQL(e, "SELECT * FROM before_sp"); err != nil {
+		t.Fatalf("DDL before the savepoint was lost: %v", err)
+	}
+	execMust(t, e, "COMMIT")
+
+	if !schema.TableExists("before_sp") || schema.TableExists("after_sp") {
+		t.Fatal("committed savepoint result is wrong")
+	}
+}
+
+// transactional DDL: DROP INDEX is undone by ROLLBACK and stays invisible to
+// other sessions until COMMIT.
+func TestTransactionalDDLDropIndexRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	writer := newExec(schema, table)
+	observer := newExec(schema, table)
+	execMust(t, writer, "CREATE TABLE docs (id INTEGER PRIMARY KEY, slug TEXT)")
+	execMust(t, writer, "CREATE UNIQUE INDEX idx_docs_slug ON docs (slug)")
+	execMust(t, writer, "INSERT INTO docs VALUES (1, 'a')")
+
+	execMust(t, writer, "BEGIN")
+	execMust(t, writer, "DROP INDEX idx_docs_slug")
+	if writer.session.IndexExists("idx_docs_slug") {
+		t.Fatal("staged DROP INDEX is still visible to the transaction")
+	}
+	if !observer.session.IndexExists("idx_docs_slug") {
+		t.Fatal("staged DROP INDEX became visible to another session before COMMIT")
+	}
+	execMust(t, writer, "ROLLBACK")
+	if !schema.IndexExists("idx_docs_slug") {
+		t.Fatal("rollback did not restore the dropped index")
+	}
+
+	// The duplicate still fails, proving the unique index survived intact.
+	if _, err := execSQL(writer, "INSERT INTO docs VALUES (2, 'a')"); err == nil {
+		t.Fatal("unique index lost its enforcement after a rolled-back DROP INDEX")
+	}
+
+	execMust(t, writer, "BEGIN")
+	execMust(t, writer, "DROP INDEX idx_docs_slug")
+	execMust(t, writer, "COMMIT")
+	if schema.IndexExists("idx_docs_slug") {
+		t.Fatal("committed DROP INDEX did not reach the catalog")
+	}
+	execMust(t, observer, "INSERT INTO docs VALUES (3, 'a')")
+}
+
+// transactional DDL: a renamed table stays writable inside the transaction and
+// the staged rows move with it at commit.
+func TestTransactionalDDLRenameTableReadYourWrites(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE old_name (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO old_name VALUES (1, 'kept')")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "ALTER TABLE old_name RENAME TO new_name")
+	execMust(t, e, "INSERT INTO new_name VALUES (2, 'staged')")
+	if res := execMust(t, e, "SELECT v FROM new_name ORDER BY id"); res.RowCount != 2 {
+		t.Fatalf("rows after staged rename = %v", res.Rows)
+	}
+	execMust(t, e, "COMMIT")
+
+	if schema.TableExists("old_name") {
+		t.Fatal("renamed table is still addressable under its old name")
+	}
+	res := execMust(t, e, "SELECT id, v FROM new_name ORDER BY id")
+	if res.RowCount != 2 || res.Rows[0][1] != "kept" || res.Rows[1][1] != "staged" {
+		t.Fatalf("rows after committed rename = %v", res.Rows)
+	}
+	// The durable rows moved rather than being copied.
+	if rows, err := table.Select("old_name", nil); err == nil && len(rows) > 0 {
+		t.Fatalf("old data prefix still holds %d rows", len(rows))
+	}
+}
+
+// transactional DDL: another session keeps reading a dropped table's rows until
+// the drop commits.
+func TestTransactionalDDLDropTableTwoSessions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	writer := newExec(schema, table)
+	observer := newExec(schema, table)
+	execMust(t, writer, "CREATE TABLE doomed (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, writer, "INSERT INTO doomed VALUES (1, 'still here')")
+
+	execMust(t, writer, "BEGIN")
+	execMust(t, writer, "DROP TABLE doomed")
+	if res := execMust(t, observer, "SELECT v FROM doomed"); res.RowCount != 1 || res.Rows[0][0] != "still here" {
+		t.Fatalf("observer rows while the drop is uncommitted = %v", res.Rows)
+	}
+	execMust(t, writer, "COMMIT")
+
+	if _, err := execSQL(observer, "SELECT * FROM doomed"); err == nil {
+		t.Fatal("observer still reads a dropped table after COMMIT")
+	}
+	if rows, err := table.Select("doomed", nil); err == nil && len(rows) > 0 {
+		t.Fatalf("dropped table left %d durable rows", len(rows))
+	}
+}
+
+// transactional DDL: a write that commits after the DDL statement but before the
+// DDL commit makes the commit fail with a serialization error rather than
+// silently discarding the concurrent write.
+func TestTransactionalDDLConcurrentWriteConflicts(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	writer := newExec(schema, table)
+	other := newExec(schema, table)
+	execMust(t, writer, "CREATE TABLE shared (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, writer, "INSERT INTO shared VALUES (1, 'a')")
+
+	execMust(t, writer, "BEGIN")
+	execMust(t, writer, "ALTER TABLE shared ADD COLUMN note TEXT DEFAULT 'x'")
+
+	// Another session writes the table between the DDL statement and the commit.
+	execMust(t, other, "INSERT INTO shared VALUES (2, 'b')")
+
+	if _, err := execSQL(writer, "COMMIT"); err == nil {
+		t.Fatal("commit over a concurrent write succeeded")
+	} else if !strings.Contains(err.Error(), "serialization") {
+		t.Fatalf("commit error = %v, want a serialization error", err)
+	}
+	// The concurrent row is intact and the column was never published.
+	if res := execMust(t, other, "SELECT COUNT(*) FROM shared"); res.Rows[0][0] != int64(2) {
+		t.Fatalf("concurrent rows after failed commit = %v", res.Rows)
+	}
+	if _, err := execSQL(other, "SELECT note FROM shared"); err == nil {
+		t.Fatal("failed commit published the staged column")
+	}
+}

+ 106 - 39
pkg/storage/foreign_key.go

@@ -215,6 +215,24 @@ func (m *SchemaManager) foreignKeyCatalogSnapshot() (*foreignKeyCatalog, error)
 	if err != nil {
 		return nil, err
 	}
+	catalog, err := buildForeignKeyCatalog(version, tables, m.GetSchema)
+	if err != nil {
+		return nil, err
+	}
+
+	m.fkCatalogMu.Lock()
+	if m.Version() == version {
+		m.fkCatalog = catalog
+	}
+	m.fkCatalogMu.Unlock()
+	return catalog, nil
+}
+
+// buildForeignKeyCatalog derives the foreign-key metadata of a catalog view from
+// its table list and definitions. Both the shared SchemaManager cache and a
+// transaction's staged catalog build it, so a session validates against the
+// constraints it can see.
+func buildForeignKeyCatalog(version uint64, tables []string, getSchema func(string) (*Schema, error)) (*foreignKeyCatalog, error) {
 	catalog := &foreignKeyCatalog{
 		version:      version,
 		participants: make(map[string]bool),
@@ -228,7 +246,7 @@ func (m *SchemaManager) foreignKeyCatalogSnapshot() (*foreignKeyCatalog, error)
 		catalog.adjacency[a][b] = true
 	}
 	for _, name := range tables {
-		schema, err := m.GetSchema(name)
+		schema, err := getSchema(name)
 		if err != nil {
 			return nil, err
 		}
@@ -242,13 +260,37 @@ func (m *SchemaManager) foreignKeyCatalogSnapshot() (*foreignKeyCatalog, error)
 			link(parent, child)
 		}
 	}
+	return catalog, nil
+}
 
-	m.fkCatalogMu.Lock()
-	if m.Version() == version {
-		m.fkCatalog = catalog
+// closure returns the foreign-key-connected component of the given tables.
+func (c *foreignKeyCatalog) closure(tables []string) map[string]bool {
+	closure := make(map[string]bool)
+	queue := make([]string, 0, len(tables))
+	for _, table := range tables {
+		if c.participants[strings.ToLower(table)] {
+			queue = append(queue, strings.ToLower(table))
+		}
 	}
-	m.fkCatalogMu.Unlock()
-	return catalog, nil
+	for len(queue) > 0 {
+		table := queue[0]
+		queue = queue[1:]
+		if closure[table] {
+			continue
+		}
+		closure[table] = true
+		neighbors := make([]string, 0, len(c.adjacency[table]))
+		for neighbor := range c.adjacency[table] {
+			neighbors = append(neighbors, neighbor)
+		}
+		sort.Strings(neighbors)
+		for _, neighbor := range neighbors {
+			if !closure[neighbor] {
+				queue = append(queue, neighbor)
+			}
+		}
+	}
+	return closure
 }
 
 // referencingForeignKeys returns every FOREIGN KEY declaration whose parent is
@@ -354,32 +396,7 @@ func (m *SchemaManager) ForeignKeyClosure(tables []string) (map[string]bool, err
 	if err != nil {
 		return nil, err
 	}
-	closure := make(map[string]bool)
-	queue := make([]string, 0, len(tables))
-	for _, table := range tables {
-		if catalog.participants[strings.ToLower(table)] {
-			queue = append(queue, strings.ToLower(table))
-		}
-	}
-	for len(queue) > 0 {
-		table := queue[0]
-		queue = queue[1:]
-		if closure[table] {
-			continue
-		}
-		closure[table] = true
-		neighbors := make([]string, 0, len(catalog.adjacency[table]))
-		for neighbor := range catalog.adjacency[table] {
-			neighbors = append(neighbors, neighbor)
-		}
-		sort.Strings(neighbors)
-		for _, neighbor := range neighbors {
-			if !closure[neighbor] {
-				queue = append(queue, neighbor)
-			}
-		}
-	}
-	return closure, nil
+	return catalog.closure(tables), nil
 }
 
 // lockForeignKeyWrites serializes writes that can affect referential integrity,
@@ -477,14 +494,56 @@ func joinForeignKeyValues(values []string) string {
 // foreignKeyClosure returns the tables a commit must re-validate, or nil when no
 // staged table participates in a foreign key.
 func (s *Session) foreignKeyClosure() (map[string]bool, error) {
-	if len(s.overlay) == 0 {
-		return nil, nil
-	}
 	tables := make([]string, 0, len(s.overlay))
+	seen := make(map[string]bool, len(s.overlay))
 	for table := range s.overlay {
+		if seen[table] {
+			continue
+		}
+		seen[table] = true
 		tables = append(tables, table)
 	}
-	return s.schema.ForeignKeyClosure(tables)
+	// DDL staged by this transaction can add or remove a constraint without
+	// staging a row, so its tables join the validation component too.
+	if s.schemaTx != nil {
+		for table, schema := range s.schemaTx.staged {
+			if schema == nil || seen[table] {
+				continue
+			}
+			seen[table] = true
+			tables = append(tables, table)
+		}
+	}
+	if len(tables) == 0 {
+		return nil, nil
+	}
+	return s.foreignKeyClosureLocked(tables)
+}
+
+// foreignKeyClosureLocked computes the constrained component of tables against
+// the catalog this session observes (caller holds the session lock).
+func (s *Session) foreignKeyClosureLocked(tables []string) (map[string]bool, error) {
+	if s.schemaTx == nil {
+		return s.schema.ForeignKeyClosure(tables)
+	}
+	catalog, err := s.foreignKeyCatalogLocked()
+	if err != nil {
+		return nil, err
+	}
+	return catalog.closure(tables), nil
+}
+
+// referencingForeignKeysLocked returns the FOREIGN KEY declarations whose parent
+// is table, as this session observes the catalog.
+func (s *Session) referencingForeignKeysLocked(table string) ([]referencingForeignKey, error) {
+	if s.schemaTx == nil {
+		return s.schema.referencingForeignKeys(table)
+	}
+	catalog, err := s.foreignKeyCatalogLocked()
+	if err != nil {
+		return nil, err
+	}
+	return catalog.referencing[strings.ToLower(table)], nil
 }
 
 // validateForeignKeyFinalState checks the committed row image of every table in
@@ -497,7 +556,7 @@ func (s *Session) validateForeignKeyFinalState(closure map[string]bool) error {
 	schemas := make(map[string]*Schema, len(closure))
 	tables := make(map[string][]Row, len(closure))
 	for table := range closure {
-		schema, err := s.schema.GetSchema(table)
+		schema, err := s.getSchemaLocked(table)
 		if err != nil {
 			return err
 		}
@@ -518,8 +577,9 @@ func (s *Session) validateForeignKeyFinalState(closure map[string]bool) error {
 // table gate because the caller holds the table's exclusive gate.
 func (s *Session) finalRows(table string, schema *Schema) ([]Row, error) {
 	overlay := s.overlay[strings.ToLower(table)]
+	source := s.durableSourceLocked(table)
 	rows := make([]Row, 0)
-	err := s.table.scanRows(table, func(row Row) (bool, error) {
+	err := s.table.scanRowsAtPrefix(s.table.dataPrefix(source), func(_ string, row Row, _ uint64) (bool, error) {
 		pk, err := schema.PrimaryKeyForRow(row)
 		if err != nil {
 			return true, err
@@ -527,6 +587,9 @@ func (s *Session) finalRows(table string, schema *Schema) ([]Row, error) {
 		if _, staged := overlay[s.table.dataKey(table, pk)]; staged {
 			return false, nil
 		}
+		if err := s.applyStagedRewritesLocked(table, row); err != nil {
+			return true, err
+		}
 		rows = append(rows, row)
 		return false, nil
 	})
@@ -537,7 +600,11 @@ func (s *Session) finalRows(table string, schema *Schema) ([]Row, error) {
 		if entry.absent {
 			continue
 		}
-		rows = append(rows, cloneRow(entry.row))
+		row := cloneRow(entry.row)
+		if err := s.applyStagedRewritesLocked(table, row); err != nil {
+			return nil, err
+		}
+		rows = append(rows, row)
 	}
 	return rows, nil
 }

+ 5 - 5
pkg/storage/foreign_key_cascade.go

@@ -226,7 +226,7 @@ func (s *Session) stageCascadeDelete(table, key string, row Row, exp *cascadeExp
 // cascadeAfterDelete applies the ON DELETE actions triggered by removing row
 // from table.
 func (s *Session) cascadeAfterDelete(table string, row Row, exp *cascadeExpansion) error {
-	refs, err := s.schema.referencingForeignKeys(table)
+	refs, err := s.referencingForeignKeysLocked(table)
 	if err != nil {
 		return err
 	}
@@ -257,7 +257,7 @@ func (s *Session) cascadeAfterDelete(table string, row Row, exp *cascadeExpansio
 // the child's foreign-key columns and then propagate as an update, because those
 // columns can themselves be a key another table references.
 func (s *Session) applyDeleteAction(ref referencingForeignKey, child Row, action ReferentialAction, exp *cascadeExpansion) error {
-	schema, err := s.schema.GetSchema(ref.Table)
+	schema, err := s.getSchemaLocked(ref.Table)
 	if err != nil {
 		return err
 	}
@@ -305,7 +305,7 @@ func (s *Session) applySetAction(schema *Schema, fk ForeignKeyConstraint, child
 // cascadeAfterUpdate applies the ON UPDATE actions triggered by changing a
 // parent row whose referenced key moved from oldRow to newRow.
 func (s *Session) cascadeAfterUpdate(table string, oldRow, newRow Row, exp *cascadeExpansion) error {
-	refs, err := s.schema.referencingForeignKeys(table)
+	refs, err := s.referencingForeignKeysLocked(table)
 	if err != nil {
 		return err
 	}
@@ -330,7 +330,7 @@ func (s *Session) cascadeAfterUpdate(table string, oldRow, newRow Row, exp *casc
 		if err != nil {
 			return err
 		}
-		childSchema, err := s.schema.GetSchema(ref.Table)
+		childSchema, err := s.getSchemaLocked(ref.Table)
 		if err != nil {
 			return err
 		}
@@ -372,7 +372,7 @@ func (s *Session) cascadeAfterUpdate(table string, oldRow, newRow Row, exp *casc
 // commit; FK and UNIQUE validity are validated against the transaction's final
 // state at commit.
 func (s *Session) stageCascadeUpdate(table string, oldRow, newRow Row, exp *cascadeExpansion) error {
-	schema, err := s.schema.GetSchema(table)
+	schema, err := s.getSchemaLocked(table)
 	if err != nil {
 		return err
 	}

+ 15 - 0
pkg/storage/kv.go

@@ -739,6 +739,21 @@ func (c *KVClient) Read(key string) (string, error) {
 	return string(res.Value), nil
 }
 
+// ReadWithLSN reads a key together with the LSN the store observed it at. A
+// missing key reports found=false and LSN 0, which is exactly the compare value
+// a later CreateIfAbsent-style check needs. Transactional DDL uses it to pin
+// the catalog generation each staged record was derived from.
+func (c *KVClient) ReadWithLSN(key string) (value string, lsn uint64, found bool, err error) {
+	res, err := c.Get([]byte(key))
+	if err == ErrKeyNotFound {
+		return "", 0, false, nil
+	}
+	if err != nil {
+		return "", 0, false, err
+	}
+	return string(res.Value), res.LSN, true, nil
+}
+
 func (c *KVClient) Delete(key string) error {
 	_, err := c.Del([]byte(key))
 	return err

+ 69 - 2
pkg/storage/schema.go

@@ -344,6 +344,72 @@ func (m *SchemaManager) rowIDKey(table string) string {
 	return fmt.Sprintf("%s:_sys:rowid:%s", m.database, strings.ToLower(table))
 }
 
+// readDurable reads a catalog key straight from durable storage, bypassing the
+// in-memory caches, and reports the LSN the store observed it at. Transactional
+// DDL uses it so a staged catalog change is pinned to the exact durable version
+// it was derived from, and a concurrent committed DDL fails the commit instead
+// of being silently overwritten.
+func (m *SchemaManager) readDurable(key string) (value string, lsn uint64, found bool, err error) {
+	err = m.pool.WithClient(func(c *KVClient) error {
+		var rerr error
+		value, lsn, found, rerr = c.ReadWithLSN(key)
+		return rerr
+	})
+	return value, lsn, found, err
+}
+
+// catalogTablesDurable returns the committed table catalog and its LSN.
+func (m *SchemaManager) catalogTablesDurable() ([]string, uint64, error) {
+	data, lsn, found, err := m.readDurable(m.catalogKey())
+	if err != nil {
+		return nil, 0, err
+	}
+	if !found {
+		return nil, lsn, nil
+	}
+	var tables []string
+	if err := json.Unmarshal([]byte(data), &tables); err != nil {
+		return nil, 0, fmt.Errorf("failed to parse catalog: %w", err)
+	}
+	return tables, lsn, nil
+}
+
+// indexListDurable returns the committed index catalog and its LSN.
+func (m *SchemaManager) indexListDurable() ([]string, uint64, error) {
+	data, lsn, found, err := m.readDurable(m.indexListKey())
+	if err != nil {
+		return nil, 0, err
+	}
+	if !found {
+		return nil, lsn, nil
+	}
+	var indexes []string
+	if err := json.Unmarshal([]byte(data), &indexes); err != nil {
+		return nil, 0, fmt.Errorf("failed to parse index list: %w", err)
+	}
+	return indexes, lsn, nil
+}
+
+// invalidateCommittedDDL publishes a committed transactional DDL change to
+// readers: every cache derived from the catalog is dropped and the version is
+// bumped so the foreign-key metadata and session analyzers reload. The caches
+// are rebuilt lazily from the durable catalog, which the commit already wrote.
+func (m *SchemaManager) invalidateCommittedDDL() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.cache = make(map[string]*Schema)
+	m.indexCache = make(map[string]*Index)
+	m.indexListCache = nil
+	m.indexListCached = false
+	m.rowIDMu.Lock()
+	m.rowIDAlloc = make(map[string]*rowIDAllocator)
+	m.rowIDMu.Unlock()
+	m.fkCatalogMu.Lock()
+	m.fkCatalog = nil
+	m.fkCatalogMu.Unlock()
+	m.bumpVersionLocked()
+}
+
 // CreateTable creates a new table.
 func (m *SchemaManager) CreateTable(schema *Schema) error {
 	m.mu.Lock()
@@ -1809,8 +1875,9 @@ func (m *SchemaManager) RenameColumn(table, oldName, newName string) error {
 
 	// Publish the renamed schema before the index definitions: reads of the new
 	// column name resolve against rewritten rows as soon as the schema lands.
-	// Index DDL is not transactional (as elsewhere in this engine), so a failure
-	// here leaves stale index metadata for the caller to drop and recreate.
+	// Autocommit index DDL is not covered by this write, so a failure here leaves
+	// stale index metadata for the caller to drop and recreate; inside a
+	// transaction the whole statement is staged and published atomically.
 	if err := m.updateSchemaUnsafe(schema); err != nil {
 		return err
 	}

+ 1589 - 0
pkg/storage/schema_tx.go

@@ -0,0 +1,1589 @@
+package storage
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/goccy/go-json"
+)
+
+// rowRewrite is one pending ALTER TABLE row transformation. It reports whether
+// it changed the row and must be idempotent: it runs again on every read of the
+// table while the transaction is open and once more on each durable row when
+// the transaction commits.
+type rowRewrite func(row Row) (bool, error)
+
+// SchemaTx is the transaction-scoped catalog overlay of one Session. DDL inside
+// an explicit transaction records its catalog and row effects here instead of
+// publishing them: the owning session reads through the overlay, every other
+// session keeps reading the committed catalog, ROLLBACK discards it, and COMMIT
+// merges the overlay into the session's single compare-and-swap batch write, so
+// a multi-statement migration becomes durable atomically or not at all.
+//
+// The overlay starts out empty and reads through to the durable catalog while
+// recording the LSN each touched key was observed at. Those LSNs become the
+// commit's compare checks, so a concurrent DDL that committed after the
+// transaction derived its catalog makes the commit fail with ErrSerialization
+// instead of overwriting the concurrent change.
+//
+// Every method assumes the owning session lock is held: a Session executes one
+// statement at a time, so the overlay needs no lock of its own and can share the
+// session's row overlay directly.
+type SchemaTx struct {
+	base *SchemaManager
+	sess *Session
+
+	// staged holds the transaction's definition of every touched table, keyed
+	// by lowercased name. A nil entry means the name is not visible to this
+	// transaction: it was dropped, or moved by RENAME TABLE.
+	staged map[string]*Schema
+	// stagedIndexes holds the transaction's definition of every touched index,
+	// keyed by lowercased name; nil means dropped.
+	stagedIndexes map[string]*Index
+	// committedSchemas and committedIndexes cache the definitions read from
+	// durable storage so the executor's repeated catalog lookups inside one
+	// statement do not re-read them. An entry is dropped when the transaction
+	// stages a replacement for the same name.
+	committedSchemas map[string]*Schema
+	committedIndexes map[string]*Index
+	// tables and indexList are the staged catalog lists. They are loaded from
+	// the committed catalog on first use and rewritten in place afterwards.
+	tables    []string
+	indexList []string
+	// catalogDirty and indexListDirty report that the corresponding list is
+	// part of the commit.
+	catalogDirty   bool
+	indexListDirty bool
+
+	// reads records the durable LSN every staged catalog key was derived from
+	// (0 for a key that was absent). commitChecks turns them into compare
+	// checks.
+	reads map[string]uint64
+
+	// rewrites holds pending row transformations keyed by the lowercased table
+	// name whose rows they apply to, in statement order.
+	rewrites map[string][]rowRewrite
+	// dataSources maps a staged table name to the durable table name whose data
+	// prefix still holds its rows: RENAME TABLE moves the definition without
+	// touching rows, so a later ALTER or DROP must look under the old prefix.
+	// An entry with an empty source is a table created by this transaction.
+	dataSources map[string]string
+	// drops holds durable table names whose data prefix is deleted at commit.
+	drops map[string]bool
+	// renames maps a durable table name to the staged name its rows move to at
+	// commit.
+	renames map[string]string
+
+	// rekeys records the overlay re-keyings this transaction performed, in
+	// order, so a savepoint can undo them.
+	rekeys []rekeyPair
+
+	version uint64
+}
+
+type rekeyPair struct {
+	from string
+	to   string
+}
+
+// schemaTxSnapshot is the transaction catalog state at a savepoint.
+type schemaTxSnapshot struct {
+	version        uint64
+	staged         map[string]*Schema
+	stagedIndexes  map[string]*Index
+	tables         []string
+	indexList      []string
+	catalogDirty   bool
+	indexListDirty bool
+	rewrites       map[string][]rowRewrite
+	dataSources    map[string]string
+	drops          map[string]bool
+	renames        map[string]string
+	rekeys         int
+}
+
+func newSchemaTx(s *Session) *SchemaTx {
+	return &SchemaTx{
+		base:             s.schema,
+		sess:             s,
+		staged:           make(map[string]*Schema),
+		stagedIndexes:    make(map[string]*Index),
+		committedSchemas: make(map[string]*Schema),
+		committedIndexes: make(map[string]*Index),
+		reads:            make(map[string]uint64),
+		rewrites:         make(map[string][]rowRewrite),
+		dataSources:      make(map[string]string),
+		drops:            make(map[string]bool),
+		renames:          make(map[string]string),
+		version:          s.schema.Version(),
+	}
+}
+
+// snapshot captures the catalog overlay. The maps are copied shallowly: staged
+// definitions are cloned before they are mutated, so the captured pointers stay
+// immutable.
+func (tx *SchemaTx) snapshot() schemaTxSnapshot {
+	snap := schemaTxSnapshot{
+		version:        tx.version,
+		staged:         make(map[string]*Schema, len(tx.staged)),
+		stagedIndexes:  make(map[string]*Index, len(tx.stagedIndexes)),
+		tables:         append([]string(nil), tx.tables...),
+		indexList:      append([]string(nil), tx.indexList...),
+		catalogDirty:   tx.catalogDirty,
+		indexListDirty: tx.indexListDirty,
+		rewrites:       make(map[string][]rowRewrite, len(tx.rewrites)),
+		dataSources:    make(map[string]string, len(tx.dataSources)),
+		drops:          make(map[string]bool, len(tx.drops)),
+		renames:        make(map[string]string, len(tx.renames)),
+		rekeys:         len(tx.rekeys),
+	}
+	for k, v := range tx.staged {
+		snap.staged[k] = v
+	}
+	for k, v := range tx.stagedIndexes {
+		snap.stagedIndexes[k] = v
+	}
+	for k, v := range tx.rewrites {
+		snap.rewrites[k] = append([]rowRewrite(nil), v...)
+	}
+	for k, v := range tx.dataSources {
+		snap.dataSources[k] = v
+	}
+	for k, v := range tx.drops {
+		snap.drops[k] = v
+	}
+	for k, v := range tx.renames {
+		snap.renames[k] = v
+	}
+	return snap
+}
+
+// restore returns the overlay to a snapshot and undoes the overlay re-keyings
+// performed after it. The caller holds the session lock.
+func (tx *SchemaTx) restore(snap schemaTxSnapshot) {
+	for i := len(tx.rekeys) - 1; i >= snap.rekeys; i-- {
+		tx.sess.rekeyOverlayLocked(tx.rekeys[i].to, tx.rekeys[i].from)
+	}
+	tx.rekeys = tx.rekeys[:snap.rekeys]
+	tx.version = snap.version
+	tx.staged = snap.staged
+	tx.stagedIndexes = snap.stagedIndexes
+	tx.tables = snap.tables
+	tx.indexList = snap.indexList
+	tx.catalogDirty = snap.catalogDirty
+	tx.indexListDirty = snap.indexListDirty
+	tx.rewrites = snap.rewrites
+	tx.dataSources = snap.dataSources
+	tx.drops = snap.drops
+	tx.renames = snap.renames
+}
+
+// bump advances the transaction's catalog version so the executor's analyzer
+// catalog resynchronizes after every staged change.
+func (tx *SchemaTx) bump() {
+	tx.version++
+}
+
+func (tx *SchemaTx) recordRead(key string, lsn uint64) {
+	if _, ok := tx.reads[key]; !ok {
+		tx.reads[key] = lsn
+	}
+}
+
+// refreshCommitted drops the cached committed definitions before the commit's
+// final validation. Another session's DDL may have committed while this
+// transaction was open, so the rows it stages must be validated against the
+// catalog that is current under the commit gates; the staged overlay itself is
+// kept. The catalog lists keep their recorded LSN: a staged list write must
+// still fail its compare check rather than overwrite the concurrent change.
+func (tx *SchemaTx) refreshCommitted() {
+	tx.committedSchemas = make(map[string]*Schema)
+	tx.committedIndexes = make(map[string]*Index)
+	if !tx.indexListDirty {
+		tx.indexList = nil
+	}
+}
+
+// getSchema returns the transaction's view of a table.
+func (tx *SchemaTx) getSchema(name string) (*Schema, error) {
+	lower := strings.ToLower(name)
+	if schema, ok := tx.staged[lower]; ok {
+		if schema == nil {
+			return nil, fmt.Errorf("table not found: %s", name)
+		}
+		return cloneSchema(schema), nil
+	}
+	return tx.loadSchema(name)
+}
+
+// loadSchema reads the committed definition and records the LSN it observed.
+func (tx *SchemaTx) loadSchema(name string) (*Schema, error) {
+	lower := strings.ToLower(name)
+	if schema, ok := tx.committedSchemas[lower]; ok {
+		return cloneSchema(schema), nil
+	}
+	key := tx.base.schemaKey(name)
+	data, lsn, found, err := tx.base.readDurable(key)
+	if err != nil {
+		return nil, err
+	}
+	tx.recordRead(key, lsn)
+	if !found {
+		return nil, fmt.Errorf("table not found: %s", name)
+	}
+	var schema Schema
+	if err := json.Unmarshal([]byte(data), &schema); err != nil {
+		return nil, fmt.Errorf("failed to parse schema: %w", err)
+	}
+	schema.normalizePrimaryKey()
+	tx.committedSchemas[lower] = &schema
+	return cloneSchema(&schema), nil
+}
+
+func (tx *SchemaTx) tableExists(name string) bool {
+	_, err := tx.getSchema(name)
+	return err == nil
+}
+
+// listTables returns the staged table catalog in creation order.
+func (tx *SchemaTx) listTables() ([]string, error) {
+	if tx.tables == nil {
+		tables, lsn, err := tx.base.catalogTablesDurable()
+		if err != nil {
+			return nil, err
+		}
+		tx.recordRead(tx.base.catalogKey(), lsn)
+		tx.tables = tables
+	}
+	return append([]string(nil), tx.tables...), nil
+}
+
+func (tx *SchemaTx) stageTableList(tables []string) {
+	tx.tables = tables
+	tx.catalogDirty = true
+}
+
+// getIndex returns the transaction's view of an index definition.
+func (tx *SchemaTx) getIndex(name string) (*Index, error) {
+	lower := strings.ToLower(name)
+	if index, ok := tx.stagedIndexes[lower]; ok {
+		if index == nil {
+			return nil, fmt.Errorf("%w: %s", ErrIndexNotFound, name)
+		}
+		return cloneIndex(index), nil
+	}
+	if index, ok := tx.committedIndexes[lower]; ok {
+		return cloneIndex(index), nil
+	}
+	key := tx.base.indexKey(name)
+	data, lsn, found, err := tx.base.readDurable(key)
+	if err != nil {
+		return nil, err
+	}
+	tx.recordRead(key, lsn)
+	if !found {
+		return nil, fmt.Errorf("%w: %s", ErrIndexNotFound, name)
+	}
+	var index Index
+	if err := json.Unmarshal([]byte(data), &index); err != nil {
+		return nil, fmt.Errorf("failed to parse index: %w", err)
+	}
+	tx.committedIndexes[lower] = &index
+	return cloneIndex(&index), nil
+}
+
+func (tx *SchemaTx) indexExists(name string) bool {
+	_, err := tx.getIndex(name)
+	return err == nil
+}
+
+func (tx *SchemaTx) listIndexes() ([]string, error) {
+	if tx.indexList == nil {
+		indexes, lsn, err := tx.base.indexListDurable()
+		if err != nil {
+			return nil, err
+		}
+		tx.recordRead(tx.base.indexListKey(), lsn)
+		tx.indexList = indexes
+	}
+	return append([]string(nil), tx.indexList...), nil
+}
+
+func (tx *SchemaTx) stageIndexList(indexes []string) {
+	tx.indexList = indexes
+	tx.indexListDirty = true
+}
+
+func (tx *SchemaTx) listTableIndexes(table string) ([]*Index, error) {
+	names, err := tx.listIndexes()
+	if err != nil {
+		return nil, err
+	}
+	var result []*Index
+	for _, name := range names {
+		idx, err := tx.getIndex(name)
+		if err != nil {
+			if err == ErrIndexNotFound || strings.Contains(err.Error(), ErrIndexNotFound.Error()) {
+				continue
+			}
+			return nil, err
+		}
+		if strings.EqualFold(idx.Table, table) {
+			result = append(result, idx)
+		}
+	}
+	return result, nil
+}
+
+// stageSchema records a replacement table definition.
+func (tx *SchemaTx) stageSchema(schema *Schema) {
+	lower := strings.ToLower(schema.Name)
+	delete(tx.committedSchemas, lower)
+	tx.staged[lower] = cloneSchema(schema)
+	if _, ok := tx.dataSources[lower]; !ok {
+		tx.dataSources[lower] = lower
+	}
+	tx.recordRead(tx.base.schemaKey(schema.Name), 0)
+	tx.bump()
+}
+
+// stageDroppedTable records that a name stopped being addressable.
+func (tx *SchemaTx) stageDroppedTable(lower string) {
+	delete(tx.committedSchemas, lower)
+	tx.staged[lower] = nil
+	tx.recordRead(tx.base.schemaKey(lower), 0)
+	tx.bump()
+}
+
+// createTable stages CREATE TABLE.
+func (tx *SchemaTx) createTable(schema *Schema) error {
+	if tx.tableExists(schema.Name) {
+		return fmt.Errorf("table already exists: %s", schema.Name)
+	}
+	lower := strings.ToLower(schema.Name)
+	schema = cloneSchema(schema)
+	schema.CreatedAt = time.Now()
+	schema.normalizePrimaryKey()
+	tx.staged[lower] = schema
+	tx.dataSources[lower] = ""
+	tables, err := tx.listTables()
+	if err != nil {
+		return err
+	}
+	for _, name := range tables {
+		if strings.EqualFold(name, schema.Name) {
+			return fmt.Errorf("table already exists: %s", schema.Name)
+		}
+	}
+	tx.stageTableList(append(tables, schema.Name))
+	tx.bump()
+	return nil
+}
+
+// createIndex stages CREATE INDEX.
+func (tx *SchemaTx) createIndex(index *Index) error {
+	if tx.indexExists(index.Name) {
+		return fmt.Errorf("index already exists: %s", index.Name)
+	}
+	if !tx.tableExists(index.Table) {
+		return fmt.Errorf("table not found: %s", index.Table)
+	}
+	index = cloneIndex(index)
+	index.CreatedAt = time.Now()
+	delete(tx.committedIndexes, strings.ToLower(index.Name))
+	tx.stagedIndexes[strings.ToLower(index.Name)] = index
+	names, err := tx.listIndexes()
+	if err != nil {
+		return err
+	}
+	tx.stageIndexList(append(names, index.Name))
+	tx.recordRead(tx.base.indexKey(index.Name), 0)
+	tx.bump()
+	return nil
+}
+
+// dropIndex stages DROP INDEX.
+func (tx *SchemaTx) dropIndex(name string) error {
+	index, err := tx.getIndex(name)
+	if err != nil {
+		return fmt.Errorf("index not found: %s", name)
+	}
+	lower := strings.ToLower(name)
+	delete(tx.committedIndexes, lower)
+	tx.stagedIndexes[lower] = nil
+	names, err := tx.listIndexes()
+	if err != nil {
+		return err
+	}
+	filtered := make([]string, 0, len(names))
+	for _, existing := range names {
+		if !strings.EqualFold(existing, name) {
+			filtered = append(filtered, existing)
+		}
+	}
+	tx.stageIndexList(filtered)
+	tx.recordRead(tx.base.indexKey(index.Name), 0)
+	tx.bump()
+	return nil
+}
+
+// dropTable stages DROP TABLE. The durable rows are removed by the commit; the
+// session's staged rows for the table are discarded immediately because the
+// table is no longer visible to this transaction.
+func (tx *SchemaTx) dropTable(name string) error {
+	lower := strings.ToLower(name)
+	if _, err := tx.getSchema(name); err != nil {
+		return fmt.Errorf("table not found: %s", name)
+	}
+	source := tx.durableSource(lower)
+	if source != "" {
+		tx.drops[source] = true
+	}
+	delete(tx.rewrites, lower)
+	delete(tx.dataSources, lower)
+	// A name that was renamed inside this transaction has no durable record of
+	// its own; the rename already deleted the original key at commit time.
+	if _, renamed := tx.renames[lower]; !renamed {
+		tx.recordRead(tx.base.schemaKey(lower), 0)
+	}
+	tx.stageDroppedTable(lower)
+
+	for _, idx := range tx.tableIndexes(lower) {
+		if err := tx.dropIndex(idx.Name); err != nil {
+			return err
+		}
+	}
+	tables, err := tx.listTables()
+	if err != nil {
+		return err
+	}
+	filtered := make([]string, 0, len(tables))
+	for _, existing := range tables {
+		if !strings.EqualFold(existing, name) {
+			filtered = append(filtered, existing)
+		}
+	}
+	tx.stageTableList(filtered)
+	tx.sess.dropOverlayTableLocked(lower)
+	tx.sess.markTableScanned(lower)
+	return nil
+}
+
+// durableSource returns the committed table name whose data prefix holds the
+// rows of a staged table, or "" when the table was created by this transaction.
+func (tx *SchemaTx) durableSource(lower string) string {
+	if source, ok := tx.dataSources[lower]; ok {
+		return source
+	}
+	return lower
+}
+
+// tableIndexes returns the staged index definitions of a table.
+func (tx *SchemaTx) tableIndexes(lower string) []*Index {
+	names, err := tx.listIndexes()
+	if err != nil {
+		return nil
+	}
+	var result []*Index
+	for _, name := range names {
+		idx, err := tx.getIndex(name)
+		if err != nil || !strings.EqualFold(idx.Table, lower) {
+			continue
+		}
+		result = append(result, idx)
+	}
+	return result
+}
+
+// addColumn stages ALTER TABLE ADD COLUMN. The new column, its CHECK
+// predicates, its UNIQUE index, and the default backfill of existing rows are
+// validated against the rows this session sees (durable rows plus its own
+// staged writes) before anything is staged, so a rejected DDL leaves the
+// transaction's catalog untouched.
+func (tx *SchemaTx) addColumn(table string, column Column, checks []CheckConstraint, unique *Index) error {
+	schema, err := tx.getSchema(table)
+	if err != nil {
+		return err
+	}
+	if err := checkColumnAbsent(schema, column.Name); err != nil {
+		return err
+	}
+	if unique != nil {
+		if !strings.EqualFold(unique.Table, schema.Name) {
+			return fmt.Errorf("index %s belongs to table %s, not %s", unique.Name, unique.Table, schema.Name)
+		}
+		if tx.indexExists(unique.Name) {
+			return fmt.Errorf("index already exists: %s", unique.Name)
+		}
+	}
+
+	now := tx.base.defaultNow()
+	// Validation covers the committed rows, mirroring the autocommit DDL path:
+	// rows this transaction staged are re-validated against the staged schema at
+	// commit (applyOverlaySchemaDefaults), because a staged row may still be
+	// rewritten or removed before then.
+	rows, err := tx.durableRows(table)
+	if err != nil {
+		return err
+	}
+	if !column.Nullable && !columnHasDefault(column) && len(rows) > 0 {
+		return &NotNullViolationError{Table: schema.Name, Column: column.Name}
+	}
+	eval := tx.base.checkEvaluator()
+	if len(checks) > 0 && eval == nil {
+		return fmt.Errorf("cannot validate CHECK constraints on %s: no CHECK evaluator installed", table)
+	}
+	seen := make(map[string]struct{})
+	for _, row := range rows {
+		value, present := rowColumnValue(row, column.Name)
+		if !present {
+			if fallback, has, defErr := tx.base.columnDefaultValue(column, now); defErr != nil {
+				return defErr
+			} else if has {
+				value = fallback
+				present = true
+			}
+		}
+		if len(checks) > 0 {
+			probe := cloneRow(row)
+			if present {
+				probe[column.Name] = value
+			}
+			for _, check := range checks {
+				holds, evalErr := eval(check.Expression, probe)
+				if evalErr != nil {
+					return fmt.Errorf("evaluate CHECK constraint %s on %s: %w", check.Label(), table, evalErr)
+				}
+				if !holds {
+					return &CheckViolationError{Table: table, Constraint: check.Label()}
+				}
+			}
+		}
+		if unique != nil && value != nil {
+			encoded := encodeUniqueScalar(value)
+			if _, duplicate := seen[encoded]; duplicate {
+				return &UniqueViolationError{Constraint: unique.Name}
+			}
+			seen[encoded] = struct{}{}
+		}
+	}
+
+	schema = cloneSchema(schema)
+	schema.Columns = append(schema.Columns, column)
+	schema.Checks = append(schema.Checks, checks...)
+	tx.stageSchema(schema)
+	if unique != nil {
+		tx.stagedIndexes[strings.ToLower(unique.Name)] = cloneIndex(unique)
+		names, err := tx.listIndexes()
+		if err != nil {
+			return err
+		}
+		tx.stageIndexList(append(names, unique.Name))
+		tx.recordRead(tx.base.indexKey(unique.Name), 0)
+	}
+	if columnHasDefault(column) {
+		value, has, err := tx.base.columnDefaultValue(column, now)
+		if err != nil {
+			return err
+		}
+		if has {
+			name := column.Name
+			tx.addRewrite(table, func(row Row) (bool, error) {
+				if _, ok := rowColumnValue(row, name); ok {
+					return false, nil
+				}
+				row[name] = value
+				return true, nil
+			})
+		}
+	}
+	tx.sess.markTableScanned(strings.ToLower(table))
+	return nil
+}
+
+// durableRows returns the committed rows of a table, ignoring this
+// transaction's staged rows and pending rewrites.
+func (tx *SchemaTx) durableRows(table string) ([]Row, error) {
+	var rows []Row
+	prefix := tx.sess.table.dataPrefix(tx.durableSource(strings.ToLower(table)))
+	err := tx.sess.table.scanRowsAtPrefix(prefix, func(_ string, row Row, _ uint64) (bool, error) {
+		rows = append(rows, row)
+		return false, nil
+	})
+	return rows, err
+}
+
+// dropColumn stages ALTER TABLE DROP COLUMN.
+func (tx *SchemaTx) dropColumn(table, columnName string) error {
+	schema, err := tx.getSchema(table)
+	if err != nil {
+		return err
+	}
+	if schema.IsPrimaryKeyColumn(columnName) {
+		return fmt.Errorf("cannot drop primary key column: %s", columnName)
+	}
+	columns := make([]Column, 0, len(schema.Columns)-1)
+	found := false
+	for _, column := range schema.Columns {
+		if strings.EqualFold(column.Name, columnName) {
+			found = true
+			continue
+		}
+		columns = append(columns, column)
+	}
+	if !found {
+		return fmt.Errorf("column not found: %s", columnName)
+	}
+	schema = cloneSchema(schema)
+	schema.Columns = columns
+	tx.stageSchema(schema)
+	tx.addRewrite(table, func(row Row) (bool, error) {
+		for key := range row {
+			if strings.EqualFold(key, columnName) {
+				delete(row, key)
+				return true, nil
+			}
+		}
+		return false, nil
+	})
+	tx.sess.markTableScanned(strings.ToLower(table))
+	return nil
+}
+
+// renameColumn stages ALTER TABLE RENAME COLUMN, including the column
+// references of the table's index definitions.
+func (tx *SchemaTx) renameColumn(table, oldName, newName string) error {
+	schema, err := tx.getSchema(table)
+	if err != nil {
+		return err
+	}
+	for _, column := range schema.Columns {
+		if strings.EqualFold(column.Name, newName) {
+			return fmt.Errorf("column already exists: %s", newName)
+		}
+	}
+	schema = cloneSchema(schema)
+	found := false
+	for i := range schema.Columns {
+		if !strings.EqualFold(schema.Columns[i].Name, oldName) {
+			continue
+		}
+		schema.Columns[i].Name = newName
+		found = true
+		for j, pk := range schema.PrimaryKeyColumns {
+			if strings.EqualFold(pk, oldName) {
+				schema.PrimaryKeyColumns[j] = newName
+			}
+		}
+		if strings.EqualFold(schema.PrimaryKey, oldName) {
+			schema.PrimaryKey = newName
+		}
+		break
+	}
+	if !found {
+		return fmt.Errorf("column not found: %s", oldName)
+	}
+	// CHECK predicates persist SQL text and are not rewritten; the executor
+	// rejects a rename that one depends on, matching the autocommit path.
+	tx.stageSchema(schema)
+	for _, idx := range tx.tableIndexes(strings.ToLower(table)) {
+		changed := false
+		updated := cloneIndex(idx)
+		for i := range updated.Columns {
+			if updated.Columns[i].Expression == "" && strings.EqualFold(updated.Columns[i].Name, oldName) {
+				updated.Columns[i].Name = newName
+				changed = true
+			}
+		}
+		if !changed {
+			continue
+		}
+		tx.stagedIndexes[strings.ToLower(updated.Name)] = updated
+		tx.recordRead(tx.base.indexKey(updated.Name), 0)
+	}
+	tx.addRewrite(table, func(row Row) (bool, error) {
+		for key, value := range row {
+			if strings.EqualFold(key, oldName) {
+				delete(row, key)
+				row[newName] = value
+				return true, nil
+			}
+		}
+		return false, nil
+	})
+	tx.sess.markTableScanned(strings.ToLower(table))
+	return nil
+}
+
+// renameTable stages ALTER TABLE RENAME TABLE. Rows stay under their committed
+// data prefix until commit; the session overlay is re-keyed immediately so the
+// transaction reads its own writes under the new name.
+func (tx *SchemaTx) renameTable(oldName, newName string) error {
+	schema, err := tx.getSchema(oldName)
+	if err != nil {
+		return err
+	}
+	if tx.tableExists(newName) {
+		return fmt.Errorf("table already exists: %s", newName)
+	}
+	oldLower := strings.ToLower(oldName)
+	newLower := strings.ToLower(newName)
+	source := tx.durableSource(oldLower)
+
+	schema = cloneSchema(schema)
+	schema.Name = newName
+	delete(tx.committedSchemas, oldLower)
+	delete(tx.committedSchemas, newLower)
+	tx.staged[oldLower] = nil
+	tx.staged[newLower] = schema
+	if source == "" {
+		tx.dataSources[newLower] = ""
+	} else {
+		tx.dataSources[newLower] = source
+		tx.renames[source] = newName
+	}
+	delete(tx.dataSources, oldLower)
+	// A drop staged before the rename keeps its durable source; the rename
+	// moves that source instead, so the drop no longer applies to it.
+	delete(tx.drops, newLower)
+	if rewrites, ok := tx.rewrites[oldLower]; ok {
+		delete(tx.rewrites, oldLower)
+		tx.rewrites[newLower] = rewrites
+	}
+
+	for _, idx := range tx.tableIndexes(oldLower) {
+		updated := cloneIndex(idx)
+		updated.Table = newName
+		tx.stagedIndexes[strings.ToLower(updated.Name)] = updated
+		tx.recordRead(tx.base.indexKey(updated.Name), 0)
+	}
+
+	tables, err := tx.listTables()
+	if err != nil {
+		return err
+	}
+	found := false
+	for i, name := range tables {
+		if strings.EqualFold(name, oldName) {
+			tables[i] = newName
+			found = true
+			break
+		}
+	}
+	if !found {
+		return fmt.Errorf("table not found in catalog: %s", oldName)
+	}
+	tx.stageTableList(tables)
+
+	tx.rekeys = append(tx.rekeys, rekeyPair{from: oldLower, to: newLower})
+	tx.sess.rekeyOverlayLocked(oldLower, newLower)
+	source = tx.durableSource(newLower)
+	if source != "" {
+		tx.sess.markTableScanned(source)
+	}
+	tx.bump()
+	return nil
+}
+
+// addRewrite appends a row transformation for a table.
+func (tx *SchemaTx) addRewrite(table string, rewrite rowRewrite) {
+	lower := strings.ToLower(table)
+	tx.rewrites[lower] = append(tx.rewrites[lower], rewrite)
+}
+
+// rewriteRow applies every pending transformation of a table to a row.
+func (tx *SchemaTx) rewriteRow(table string, row Row) (bool, error) {
+	rewrites, ok := tx.rewrites[strings.ToLower(table)]
+	if !ok {
+		return false, nil
+	}
+	changed := false
+	for _, rewrite := range rewrites {
+		applied, err := rewrite(row)
+		if err != nil {
+			return changed, err
+		}
+		changed = changed || applied
+	}
+	return changed, nil
+}
+
+// commitCatalogOps returns the durable catalog writes of the transaction and
+// the compare checks that keep them from overwriting a concurrent commit. The
+// caller holds the session lock and the affected table gates.
+func (tx *SchemaTx) commitCatalogOps() ([]BatchOp, []CompareCheck, error) {
+	var ops []BatchOp
+	keys := make([]string, 0, len(tx.staged)+len(tx.stagedIndexes)+2)
+
+	for lower, schema := range tx.staged {
+		if schema == nil {
+			if _, renamed := tx.renames[lower]; renamed {
+				// RENAME TABLE names the source through renames; the delete of
+				// the old schema record is emitted below.
+				continue
+			}
+			ops = append(ops,
+				BatchOp{Op: batchDelete, Key: []byte(tx.base.schemaKey(lower))},
+				BatchOp{Op: batchDelete, Key: []byte(tx.base.rowIDKey(lower))},
+			)
+			// The ROWID key is an in-memory allocator input, not catalog state a
+			// concurrent statement can change, so its delete needs no compare.
+			keys = append(keys, tx.base.schemaKey(lower))
+			continue
+		}
+		data, err := json.Marshal(schema)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to serialize schema: %w", err)
+		}
+		key := tx.base.schemaKey(schema.Name)
+		ops = append(ops, BatchOp{Op: batchPut, Key: []byte(key), Value: data})
+		keys = append(keys, key)
+	}
+	// A staged table definition whose name replaced an older one deletes the
+	// old record in the same batch.
+	for source, newName := range tx.renames {
+		ops = append(ops, BatchOp{Op: batchDelete, Key: []byte(tx.base.schemaKey(source))})
+		keys = append(keys, tx.base.schemaKey(source))
+		if !strings.EqualFold(source, newName) {
+			ops = append(ops, BatchOp{Op: batchDelete, Key: []byte(tx.base.rowIDKey(source))})
+		}
+	}
+
+	for lower, index := range tx.stagedIndexes {
+		if index == nil {
+			ops = append(ops, BatchOp{Op: batchDelete, Key: []byte(tx.base.indexKey(lower))})
+			keys = append(keys, tx.base.indexKey(lower))
+			continue
+		}
+		data, err := json.Marshal(index)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to serialize index: %w", err)
+		}
+		key := tx.base.indexKey(index.Name)
+		ops = append(ops, BatchOp{Op: batchPut, Key: []byte(key), Value: data})
+		keys = append(keys, key)
+	}
+
+	if tx.catalogDirty {
+		data, err := json.Marshal(tx.tables)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to serialize catalog: %w", err)
+		}
+		ops = append(ops, BatchOp{Op: batchPut, Key: []byte(tx.base.catalogKey()), Value: data})
+		keys = append(keys, tx.base.catalogKey())
+	}
+	if tx.indexListDirty {
+		data, err := json.Marshal(tx.indexList)
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to serialize index list: %w", err)
+		}
+		ops = append(ops, BatchOp{Op: batchPut, Key: []byte(tx.base.indexListKey()), Value: data})
+		keys = append(keys, tx.base.indexListKey())
+	}
+
+	checks := make([]CompareCheck, 0, len(keys))
+	seen := make(map[string]bool, len(keys))
+	for _, key := range keys {
+		if seen[key] {
+			continue
+		}
+		seen[key] = true
+		checks = append(checks, CompareCheck{Key: []byte(key), LSN: tx.reads[key]})
+	}
+	return ops, checks, nil
+}
+
+// commitDataOps returns the durable row writes of the transaction: dropped
+// tables lose every row, renamed tables move to their new prefix, and ALTER
+// TABLE transformations are applied to the committed row images. Rows this
+// session staged in its overlay are published from the overlay instead, so they
+// are skipped here. The caller holds the session lock and the exclusive table
+// gates of every affected table.
+func (tx *SchemaTx) commitDataOps() ([]BatchOp, map[string]map[string]*overlayEntry, error) {
+	entries := make(map[string]map[string]*overlayEntry)
+	var ops []BatchOp
+	if len(tx.drops) == 0 && len(tx.renames) == 0 && len(tx.rewrites) == 0 {
+		return nil, nil, nil
+	}
+
+	affected := make(map[string]bool)
+	for table := range tx.drops {
+		affected[table] = true
+	}
+	for table := range tx.renames {
+		affected[table] = true
+	}
+	for table := range tx.rewrites {
+		if source := tx.durableSource(table); source != "" && !tx.drops[source] {
+			affected[source] = true
+		}
+	}
+	names := make([]string, 0, len(affected))
+	for table := range affected {
+		names = append(names, table)
+	}
+	sort.Strings(names)
+
+	for _, source := range names {
+		if tx.drops[source] {
+			dropped, err := tx.scanRowsForDDL(source, func(key string, row Row) (bool, Row, error) {
+				if tx.sess.overlayHolds(strings.ToLower(source), key) {
+					return false, nil, nil
+				}
+				return true, nil, nil
+			})
+			if err != nil {
+				return nil, nil, err
+			}
+			ops = append(ops, dropped...)
+			continue
+		}
+
+		newName, renamed := tx.renames[source]
+		if renamed {
+			updated, err := tx.scanRowsForDDL(source, func(key string, row Row) (bool, Row, error) {
+				if _, err := tx.rewriteRow(newName, row); err != nil {
+					return false, nil, err
+				}
+				newKey := tx.sess.table.dataKey(newName, mustRowPK(tx, newName, row))
+				if tx.sess.overlayHolds(strings.ToLower(newName), newKey) {
+					return false, nil, nil
+				}
+				return true, row, nil
+			})
+			if err != nil {
+				return nil, nil, err
+			}
+			lower := strings.ToLower(newName)
+			if entries[lower] == nil {
+				entries[lower] = make(map[string]*overlayEntry)
+			}
+			for _, op := range updated {
+				oldKey := op.Key
+				suffix := strings.TrimPrefix(string(oldKey), tx.sess.table.dataPrefix(source))
+				newKey := []byte(tx.sess.table.dataPrefix(newName) + suffix)
+				row, err := decodeRow(op.Value)
+				if err != nil {
+					return nil, nil, err
+				}
+				entries[lower][string(newKey)] = &overlayEntry{row: row}
+				ops = append(ops,
+					BatchOp{Op: batchPut, Key: newKey, Value: op.Value},
+					BatchOp{Op: batchDelete, Key: oldKey},
+				)
+			}
+			continue
+		}
+
+		rewrites := tx.rewrites[source]
+		if len(rewrites) == 0 {
+			continue
+		}
+		updated, err := tx.scanRowsForDDL(source, func(key string, row Row) (bool, Row, error) {
+			if tx.sess.overlayHolds(strings.ToLower(source), key) {
+				return false, nil, nil
+			}
+			changed, err := tx.rewriteRow(source, row)
+			if err != nil {
+				return false, nil, err
+			}
+			return changed, row, nil
+		})
+		if err != nil {
+			return nil, nil, err
+		}
+		lower := strings.ToLower(source)
+		if entries[lower] == nil {
+			entries[lower] = make(map[string]*overlayEntry)
+		}
+		for _, op := range updated {
+			row, err := decodeRow(op.Value)
+			if err != nil {
+				return nil, nil, err
+			}
+			entries[lower][string(op.Key)] = &overlayEntry{row: row}
+			ops = append(ops, op)
+		}
+	}
+	return ops, entries, nil
+}
+
+// mustRowPK derives the primary-key string of a row the transaction's staged
+// schema describes.
+func mustRowPK(tx *SchemaTx, table string, row Row) string {
+	schema, err := tx.getSchema(table)
+	if err != nil {
+		return ""
+	}
+	pk, err := schema.PrimaryKeyForRow(row)
+	if err != nil {
+		return ""
+	}
+	return pk
+}
+
+// scanRowsForDDL streams a durable table's rows through transform. When
+// transform reports a change the row is re-encoded and returned as a put under
+// its existing key; when it reports removal the key is returned as a delete.
+func (tx *SchemaTx) scanRowsForDDL(table string, transform func(key string, row Row) (write bool, replacement Row, err error)) ([]BatchOp, error) {
+	var ops []BatchOp
+	prefix := []byte(tx.sess.table.dataPrefix(table))
+	err := tx.base.pool.WithClient(func(client *KVClient) (retErr error) {
+		cursor, err := client.Scan(prefix)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if cerr := cursor.Close(); retErr == nil {
+				retErr = cerr
+			}
+		}()
+		for {
+			entries, done, err := cursor.Next()
+			if err != nil {
+				return err
+			}
+			for _, entry := range entries {
+				row, err := decodeRow(entry.Value)
+				if err != nil {
+					return err
+				}
+				write, replacement, err := transform(string(entry.Key), row)
+				if err != nil {
+					return err
+				}
+				if !write {
+					continue
+				}
+				if replacement == nil {
+					ops = append(ops, BatchOp{Op: batchDelete, Key: append([]byte(nil), entry.Key...)})
+					continue
+				}
+				value, err := encodeRow(replacement)
+				if err != nil {
+					return err
+				}
+				ops = append(ops, BatchOp{Op: batchPut, Key: append([]byte(nil), entry.Key...), Value: value})
+			}
+			if done {
+				return nil
+			}
+		}
+	})
+	return ops, err
+}
+
+// touchedTables lists the lowercased table names whose caches the commit must
+// invalidate, including the source names rows moved away from.
+func (tx *SchemaTx) touchedTables() []string {
+	set := make(map[string]bool)
+	for lower, schema := range tx.staged {
+		set[lower] = true
+		if schema != nil {
+			set[strings.ToLower(schema.Name)] = true
+			if source, ok := tx.dataSources[lower]; ok && source != "" {
+				set[source] = true
+			}
+		}
+	}
+	for table := range tx.drops {
+		set[table] = true
+	}
+	for table := range tx.renames {
+		set[table] = true
+	}
+	for table := range tx.rewrites {
+		set[table] = true
+		if source := tx.durableSource(table); source != "" {
+			set[source] = true
+		}
+	}
+	names := make([]string, 0, len(set))
+	for table := range set {
+		names = append(names, table)
+	}
+	sort.Strings(names)
+	return names
+}
+
+// touchedIndexes lists the lowercased index names whose caches the commit must
+// reset.
+func (tx *SchemaTx) touchedIndexes() []string {
+	names := make([]string, 0, len(tx.stagedIndexes))
+	for lower := range tx.stagedIndexes {
+		names = append(names, lower)
+	}
+	sort.Strings(names)
+	return names
+}
+
+// hasDataEffects reports whether the commit must rewrite or remove durable rows.
+func (tx *SchemaTx) hasDataEffects() bool {
+	return len(tx.drops) > 0 || len(tx.renames) > 0 || len(tx.rewrites) > 0
+}
+
+// hasDDL reports whether the transaction staged a catalog change that the
+// commit has to publish.
+func (tx *SchemaTx) hasDDL() bool {
+	return len(tx.staged) > 0 || len(tx.stagedIndexes) > 0 || tx.catalogDirty || tx.indexListDirty || tx.hasDataEffects()
+}
+
+// Session catalog access
+//
+// A Session is the catalog view of one SQL connection. Outside an explicit
+// transaction every accessor delegates to the shared SchemaManager; while a
+// transaction is open the accessors resolve against its SchemaTx overlay, so
+// the session reads its own DDL without publishing it.
+
+// GetSchema returns the table definition this session observes.
+func (s *Session) GetSchema(name string) (*Schema, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.getSchemaLocked(name)
+}
+
+// getSchemaLocked resolves a table definition (caller holds the session lock).
+func (s *Session) getSchemaLocked(name string) (*Schema, error) {
+	if s.schemaTx != nil {
+		return s.schemaTx.getSchema(name)
+	}
+	return s.schema.GetSchema(name)
+}
+
+// TableExists reports whether the session can resolve a table.
+func (s *Session) TableExists(name string) bool {
+	_, err := s.GetSchema(name)
+	return err == nil
+}
+
+// ListTables returns the table catalog this session observes.
+func (s *Session) ListTables() ([]string, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx != nil {
+		return s.schemaTx.listTables()
+	}
+	return s.schema.ListTables()
+}
+
+// GetIndex returns an index definition this session observes.
+func (s *Session) GetIndex(name string) (*Index, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx != nil {
+		return s.schemaTx.getIndex(name)
+	}
+	return s.schema.GetIndex(name)
+}
+
+// IndexExists reports whether the session can resolve an index.
+func (s *Session) IndexExists(name string) bool {
+	_, err := s.GetIndex(name)
+	return err == nil
+}
+
+// ListIndexes returns the index catalog this session observes.
+func (s *Session) ListIndexes() ([]string, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx != nil {
+		return s.schemaTx.listIndexes()
+	}
+	return s.schema.ListIndexes()
+}
+
+// ListTableIndexes returns the index definitions of a table as this session
+// observes them.
+func (s *Session) ListTableIndexes(table string) ([]*Index, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx != nil {
+		return s.schemaTx.listTableIndexes(table)
+	}
+	return s.schema.ListTableIndexes(table)
+}
+
+// Version identifies the catalog generation this session observes, including
+// staged DDL, so the executor resynchronizes its analyzer catalog after a
+// transactional DDL statement and again after the overlay is discarded.
+func (s *Session) Version() uint64 {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.versionLocked()
+}
+
+func (s *Session) versionLocked() uint64 {
+	if s.schemaTx != nil {
+		return s.schemaTx.version
+	}
+	return s.schema.Version()
+}
+
+// GetNextRowID allocates from the shared per-table ROWID counter. The counter is
+// derived from durable rows and is not part of the catalog, so a transaction
+// uses the same allocator as autocommit.
+func (s *Session) GetNextRowID(table string) (int64, error) {
+	return s.schema.GetNextRowID(table)
+}
+
+// UpdateMaxRowID raises the shared per-table ROWID counter.
+func (s *Session) UpdateMaxRowID(table string, rowid int64) error {
+	return s.schema.UpdateMaxRowID(table, rowid)
+}
+
+// CreateTable stages CREATE TABLE inside a transaction and publishes it
+// immediately otherwise.
+func (s *Session) CreateTable(schema *Schema) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.CreateTable(schema)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error { return tx.createTable(schema) })
+}
+
+// DropTable stages DROP TABLE inside a transaction and publishes it otherwise.
+func (s *Session) DropTable(name string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.DropTable(name)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error { return tx.dropTable(name) })
+}
+
+// CreateIndex stages CREATE INDEX inside a transaction and publishes it
+// otherwise. A UNIQUE index is validated against the rows the session observes,
+// so a transaction that staged duplicate rows fails the statement, not the
+// commit.
+func (s *Session) CreateIndex(index *Index) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		if index.Unique {
+			return s.table.CreateUniqueIndex(index)
+		}
+		return s.schema.CreateIndex(index)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error {
+		if err := tx.createIndex(index); err != nil {
+			return err
+		}
+		if !index.Unique {
+			return nil
+		}
+		return s.validateUniqueIndexLocked(index)
+	})
+}
+
+// validateUniqueIndexLocked rejects a staged UNIQUE index whose indexed values
+// already collide in the rows this session observes (caller holds the session
+// lock).
+func (s *Session) validateUniqueIndexLocked(index *Index) error {
+	rows, err := s.selectLocked(index.Table, nil)
+	if err != nil {
+		return err
+	}
+	seen := make(map[string]struct{}, len(rows))
+	for _, row := range rows {
+		encoded, isNull, err := s.table.encodeUniqueValue(row, index.Columns)
+		if err != nil {
+			return err
+		}
+		if isNull {
+			continue
+		}
+		if _, duplicate := seen[encoded]; duplicate {
+			return &UniqueViolationError{Constraint: index.Name}
+		}
+		seen[encoded] = struct{}{}
+	}
+	return nil
+}
+
+// DropIndex stages DROP INDEX inside a transaction and publishes it otherwise.
+func (s *Session) DropIndex(name string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.DropIndex(name)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error { return tx.dropIndex(name) })
+}
+
+// AddColumn stages ALTER TABLE ADD COLUMN inside a transaction and publishes it
+// otherwise.
+func (s *Session) AddColumn(table string, column Column, checks []CheckConstraint, unique *Index) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.AddColumn(table, column, checks, unique)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error { return tx.addColumn(table, column, checks, unique) })
+}
+
+// DropColumn stages ALTER TABLE DROP COLUMN inside a transaction and publishes
+// it otherwise.
+func (s *Session) DropColumn(table, columnName string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.DropColumn(table, columnName)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error { return tx.dropColumn(table, columnName) })
+}
+
+// RenameTable stages ALTER TABLE RENAME TABLE inside a transaction and
+// publishes it otherwise.
+func (s *Session) RenameTable(oldName, newName string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.RenameTable(oldName, newName)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error { return tx.renameTable(oldName, newName) })
+}
+
+// RenameColumn stages ALTER TABLE RENAME COLUMN inside a transaction and
+// publishes it otherwise.
+func (s *Session) RenameColumn(table, oldName, newName string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.RenameColumn(table, oldName, newName)
+	}
+	return s.stageDDL(func(tx *SchemaTx) error { return tx.renameColumn(table, oldName, newName) })
+}
+
+// stageDDL runs one DDL statement against the transaction overlay and restores
+// the catalog, overlay, and read set if it fails, so a rejected statement
+// leaves the transaction exactly as it was (caller holds the session lock).
+func (s *Session) stageDDL(apply func(tx *SchemaTx) error) error {
+	savepoint := s.savepointLocked()
+	if err := apply(s.schemaTx); err != nil {
+		s.rollbackToSavepointLocked(savepoint)
+		return err
+	}
+	return nil
+}
+
+// overlayHolds reports whether the session staged the given durable key of a
+// table.
+func (s *Session) overlayHolds(lowerTable, key string) bool {
+	_, ok := s.overlay[lowerTable][key]
+	return ok
+}
+
+// markTableScanned pins the table's current generation as a read so the commit
+// fails with a serialization error if another session writes the table between
+// the DDL statement and the commit.
+func (s *Session) markTableScanned(table string) {
+	lower := strings.ToLower(table)
+	if _, ok := s.scanGens[lower]; !ok {
+		s.scanGens[lower] = s.table.generation(lower)
+	}
+}
+
+// dropOverlayTableLocked discards the staged rows of a table the transaction
+// dropped, including their mutation-log entries (caller holds the session lock).
+func (s *Session) dropOverlayTableLocked(lower string) {
+	delete(s.overlay, lower)
+	log := s.log[:0]
+	for _, mutation := range s.log {
+		if mutation.table != lower {
+			log = append(log, mutation)
+		}
+	}
+	s.log = log
+	delete(s.scanGens, lower)
+	for key, predicate := range s.predicateGens {
+		if predicate.table == lower {
+			delete(s.predicateGens, key)
+		}
+	}
+	prefix := s.table.dataPrefix(lower)
+	for key := range s.reads {
+		if strings.HasPrefix(key, prefix) {
+			delete(s.reads, key)
+		}
+	}
+}
+
+// rekeyOverlayLocked moves the staged rows of a renamed table to the new data
+// prefix so the transaction reads its own rows back under the new name
+// (caller holds the session lock).
+func (s *Session) rekeyOverlayLocked(oldTable, newTable string) {
+	oldLower := strings.ToLower(oldTable)
+	newLower := strings.ToLower(newTable)
+	if oldLower == newLower {
+		return
+	}
+	oldPrefix := s.table.dataPrefix(oldLower)
+	newPrefix := s.table.dataPrefix(newLower)
+
+	if entries, ok := s.overlay[oldLower]; ok {
+		delete(s.overlay, oldLower)
+		moved := make(map[string]*overlayEntry, len(entries))
+		for key, entry := range entries {
+			moved[newPrefix+strings.TrimPrefix(key, oldPrefix)] = entry
+		}
+		s.overlay[newLower] = moved
+	}
+	for i := range s.log {
+		if s.log[i].table != oldLower {
+			continue
+		}
+		s.log[i].table = newLower
+		s.log[i].key = newPrefix + strings.TrimPrefix(s.log[i].key, oldPrefix)
+	}
+	if gen, ok := s.scanGens[oldLower]; ok {
+		delete(s.scanGens, oldLower)
+		s.scanGens[newLower] = gen
+	}
+	reads := make(map[string]uint64, len(s.reads))
+	for key, lsn := range s.reads {
+		if strings.HasPrefix(key, oldPrefix) {
+			reads[newPrefix+strings.TrimPrefix(key, oldPrefix)] = lsn
+			continue
+		}
+		reads[key] = lsn
+	}
+	s.reads = reads
+	predicates := make(map[string]predicateRead, len(s.predicateGens))
+	for key, predicate := range s.predicateGens {
+		if predicate.table == oldLower {
+			predicate.table = newLower
+		}
+		if strings.HasPrefix(key, oldPrefix) {
+			key = newPrefix + strings.TrimPrefix(key, oldPrefix)
+		}
+		predicates[key] = predicate
+	}
+	s.predicateGens = predicates
+}
+
+// ForeignKeyParticipants reports whether a write to table can affect referential
+// integrity as this session observes the catalog.
+func (s *Session) ForeignKeyParticipants(table string) (bool, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.ForeignKeyParticipants(table)
+	}
+	catalog, err := s.foreignKeyCatalogLocked()
+	if err != nil {
+		return false, err
+	}
+	return catalog.participants[strings.ToLower(table)], nil
+}
+
+// ForeignKeyColumnDependency returns the constraints that keep a column in
+// place, as this session observes the catalog.
+func (s *Session) ForeignKeyColumnDependency(table, column string) ([]string, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.ForeignKeyColumnDependency(table, column)
+	}
+	catalog, err := s.foreignKeyCatalogLocked()
+	if err != nil {
+		return nil, err
+	}
+	schema, err := s.getSchemaLocked(table)
+	if err != nil {
+		return nil, err
+	}
+	seen := make(map[string]bool)
+	var labels []string
+	add := func(child string, fk ForeignKeyConstraint) {
+		label := foreignKeyReferenceName(child, fk)
+		if seen[label] {
+			return
+		}
+		seen[label] = true
+		labels = append(labels, label)
+	}
+	for _, fk := range schema.ForeignKeys {
+		for _, name := range fk.Columns {
+			if strings.EqualFold(name, column) {
+				add(schema.Name, fk)
+				break
+			}
+		}
+	}
+	for _, ref := range catalog.referencing[strings.ToLower(table)] {
+		for _, name := range ref.FK.RefColumns {
+			if strings.EqualFold(name, column) {
+				add(ref.Table, ref.FK)
+				break
+			}
+		}
+	}
+	sort.Strings(labels)
+	return labels, nil
+}
+
+// ForeignKeyTableDependency returns the constraints that name table as their
+// referenced parent, as this session observes the catalog.
+func (s *Session) ForeignKeyTableDependency(table string) ([]string, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.ForeignKeyTableDependency(table)
+	}
+	catalog, err := s.foreignKeyCatalogLocked()
+	if err != nil {
+		return nil, err
+	}
+	seen := make(map[string]bool)
+	var labels []string
+	for _, ref := range catalog.referencing[strings.ToLower(table)] {
+		label := foreignKeyReferenceName(ref.Table, ref.FK)
+		if seen[label] {
+			continue
+		}
+		seen[label] = true
+		labels = append(labels, label)
+	}
+	sort.Strings(labels)
+	return labels, nil
+}
+
+// ForeignKeyClosure returns the constrained component of the given tables as
+// this session observes the catalog.
+func (s *Session) ForeignKeyClosure(tables []string) (map[string]bool, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx == nil {
+		return s.schema.ForeignKeyClosure(tables)
+	}
+	catalog, err := s.foreignKeyCatalogLocked()
+	if err != nil {
+		return nil, err
+	}
+	return catalog.closure(tables), nil
+}
+
+// foreignKeyCatalogLocked builds the foreign-key metadata of the session's
+// catalog view (caller holds the session lock).
+func (s *Session) foreignKeyCatalogLocked() (*foreignKeyCatalog, error) {
+	version := s.versionLocked()
+	if s.fkCatalog != nil && s.fkVersion == version {
+		return s.fkCatalog, nil
+	}
+	tables, err := s.schemaTx.listTables()
+	if err != nil {
+		return nil, err
+	}
+	catalog, err := buildForeignKeyCatalog(version, tables, func(name string) (*Schema, error) {
+		return s.getSchemaLocked(name)
+	})
+	if err != nil {
+		return nil, err
+	}
+	s.fkCatalog = catalog
+	s.fkVersion = version
+	return catalog, nil
+}

+ 73 - 0
pkg/storage/table.go

@@ -334,6 +334,72 @@ func (m *TableManager) scanRowsWithLSN(table string, fn rowWithLSNVisitFunc) err
 	})
 }
 
+// scanRowsAtPrefix streams the rows under an explicit data prefix together with
+// their keys and durable LSNs. A transaction uses it when the rows of a table
+// still live under an older name because RENAME TABLE defers moving them to the
+// commit.
+func (m *TableManager) scanRowsAtPrefix(prefix string, fn func(key string, row Row, lsn uint64) (stop bool, err error)) error {
+	return m.pool.WithClient(func(client *KVClient) (retErr error) {
+		cursor, err := client.ScanWithLimit([]byte(prefix), scanPageSize)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if err := cursor.Close(); retErr == nil {
+				retErr = err
+			}
+		}()
+		for {
+			entries, done, err := cursor.Next()
+			if err != nil {
+				return err
+			}
+			for _, e := range entries {
+				row, err := decodeRow(e.Value)
+				if err != nil {
+					return err
+				}
+				stop, err := fn(string(e.Key), row, e.LSN)
+				if err != nil {
+					return err
+				}
+				if stop {
+					return nil
+				}
+			}
+			if done {
+				return nil
+			}
+		}
+	})
+}
+
+// getKeyWithLSN reads and decodes a row by its explicit durable key.
+func (m *TableManager) getKeyWithLSN(key string) (Row, uint64, error) {
+	var value []byte
+	var lsn uint64
+	err := m.pool.WithClient(func(c *KVClient) error {
+		res, err := c.Get([]byte(key))
+		if err != nil {
+			return err
+		}
+		value = res.Value
+		lsn = res.LSN
+		return nil
+	})
+	if err != nil {
+		if err == ErrKeyNotFound {
+			return nil, 0, ErrKeyNotFound
+		}
+		return nil, 0, err
+	}
+	row, err := decodeRow(value)
+	if err != nil {
+		return nil, 0, fmt.Errorf("failed to parse row: %w", err)
+	}
+	return row, lsn, nil
+}
+
 // scanCountKeys counts the durable rows of table using a key-only scan so row
 // values are never pulled across the wire. It is used for first-time COUNT(*)
 // derivation.
@@ -480,6 +546,13 @@ func (m *TableManager) prepareInsert(table string, row Row) (Row, string, error)
 	if err != nil {
 		return nil, "", err
 	}
+	return m.prepareInsertWithSchema(table, schema, row)
+}
+
+// prepareInsertWithSchema is prepareInsert against a caller-supplied schema, so
+// a transaction normalizes rows with the catalog it can see, including a table
+// it created or altered in the open transaction.
+func (m *TableManager) prepareInsertWithSchema(table string, schema *Schema, row Row) (Row, string, error) {
 	normalizedRow, key, _, err := m.normalizeInsertRow(table, schema, row)
 	if err != nil {
 		return nil, "", err

+ 429 - 37
pkg/storage/tx.go

@@ -42,6 +42,17 @@ type Session struct {
 	// use predicateGens so writes to other index values do not cause conflicts.
 	scanGens      map[string]uint64
 	predicateGens map[string]predicateRead
+
+	// schemaTx is the transaction-scoped catalog overlay. It exists only while a
+	// transaction is open: DDL stages into it instead of publishing, and commit
+	// merges it into the same batch write as the staged rows. A nil overlay means
+	// every catalog read goes straight to the shared SchemaManager.
+	schemaTx *SchemaTx
+
+	// fkCatalog caches the foreign-key metadata of the schemaTx view, keyed by
+	// the catalog version that produced it.
+	fkCatalog *foreignKeyCatalog
+	fkVersion uint64
 }
 
 type predicateRead struct {
@@ -86,6 +97,9 @@ func (s *Session) Begin() error {
 	s.reads = make(map[string]uint64)
 	s.scanGens = make(map[string]uint64)
 	s.predicateGens = make(map[string]predicateRead)
+	s.schemaTx = newSchemaTx(s)
+	s.fkCatalog = nil
+	s.fkVersion = 0
 	return nil
 }
 
@@ -120,6 +134,88 @@ func (s *Session) RollbackTo(pos int) {
 	s.aborted = false
 }
 
+// SessionSavepoint captures the complete session state at a SQL SAVEPOINT: the
+// staged rows with their mutation log and read set, and the staged DDL catalog.
+// A rollback restores all of it, so DDL issued after a savepoint is undone with
+// the rows that accompanied it.
+type SessionSavepoint struct {
+	datalog       int
+	overlay       map[string]map[string]*overlayEntry
+	mutationLog   []txMutation
+	reads         map[string]uint64
+	scanGens      map[string]uint64
+	predicateGens map[string]predicateRead
+	schema        *schemaTxSnapshot
+}
+
+// Savepoint captures the session state a later RollbackToSavepoint restores.
+func (s *Session) Savepoint() *SessionSavepoint {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.savepointLocked()
+}
+
+// savepointLocked captures the session state (caller holds the session lock).
+func (s *Session) savepointLocked() *SessionSavepoint {
+	sp := &SessionSavepoint{
+		datalog:       len(s.log),
+		overlay:       make(map[string]map[string]*overlayEntry, len(s.overlay)),
+		mutationLog:   append([]txMutation(nil), s.log...),
+		reads:         copyLSNMap(s.reads),
+		scanGens:      copyLSNMap(s.scanGens),
+		predicateGens: make(map[string]predicateRead, len(s.predicateGens)),
+	}
+	for table, entries := range s.overlay {
+		staged := make(map[string]*overlayEntry, len(entries))
+		for key, entry := range entries {
+			staged[key] = entry
+		}
+		sp.overlay[table] = staged
+	}
+	for key, predicate := range s.predicateGens {
+		sp.predicateGens[key] = predicate
+	}
+	if s.schemaTx != nil {
+		snapshot := s.schemaTx.snapshot()
+		sp.schema = &snapshot
+	}
+	return sp
+}
+
+// RollbackToSavepoint restores the state captured by Savepoint.
+func (s *Session) RollbackToSavepoint(sp *SessionSavepoint) {
+	if sp == nil {
+		return
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.rollbackToSavepointLocked(sp)
+	s.aborted = false
+}
+
+// rollbackToSavepointLocked restores a savepoint (caller holds the session
+// lock).
+func (s *Session) rollbackToSavepointLocked(sp *SessionSavepoint) {
+	s.overlay = sp.overlay
+	s.log = sp.mutationLog
+	s.reads = sp.reads
+	s.scanGens = sp.scanGens
+	s.predicateGens = sp.predicateGens
+	if s.schemaTx != nil && sp.schema != nil {
+		s.schemaTx.restore(*sp.schema)
+	}
+	s.fkCatalog = nil
+	s.fkVersion = 0
+}
+
+func copyLSNMap(source map[string]uint64) map[string]uint64 {
+	cloned := make(map[string]uint64, len(source))
+	for key, value := range source {
+		cloned[key] = value
+	}
+	return cloned
+}
+
 // Rollback discards the transaction without writing anything durable.
 func (s *Session) Rollback() error {
 	s.mu.Lock()
@@ -153,6 +249,12 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 		return 0, fmt.Errorf("current transaction is aborted")
 	}
 
+	// Staged DDL is published by this same batch write, so the commit is atomic
+	// across catalog changes and rows. hasDDL also forces the exclusive table
+	// gates for the affected tables below, because the commit rewrites or
+	// removes their durable rows.
+	hasDDL := s.schemaTx != nil && s.schemaTx.hasDDL()
+
 	// Referential integrity spans tables: a commit must validate the final
 	// child rows against the final parent rows, including rows another table
 	// staged in this same transaction. Take the foreign-key write gate before
@@ -185,6 +287,15 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 	}
 	defer unlockAll()
 
+	// Another session may have published DDL while this transaction was open.
+	// Re-read the committed catalog under the gates so the final row validation
+	// below sees the catalog that is actually in effect.
+	if s.schemaTx != nil {
+		s.schemaTx.refreshCommitted()
+		s.fkCatalog = nil
+		s.fkVersion = 0
+	}
+
 	// A staged row was normalized against the schema as of its INSERT or UPDATE,
 	// which a concurrent ADD COLUMN can predate. Re-apply the schema that is
 	// current under the held gates before any derived state is computed: the CAS
@@ -212,6 +323,32 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 		}
 	}
 
+	// Compute the durable effects of staged DDL before derived state is planned:
+	// the rows an ALTER TABLE rewrites and a RENAME TABLE moves are part of the
+	// transaction's final row state, so uniqueness and cache planning must see
+	// them. The catalog writes travel in the same batch as everything else.
+	var ddlOps []BatchOp
+	var ddlChecks []CompareCheck
+	var ddlEntries map[string]map[string]*overlayEntry
+	var touchedTables, touchedIndexes []string
+	if hasDDL {
+		catalogOps, catalogChecks, err := s.schemaTx.commitCatalogOps()
+		if err != nil {
+			s.resetLocked()
+			return 0, err
+		}
+		dataOps, dataEntries, err := s.schemaTx.commitDataOps()
+		if err != nil {
+			s.resetLocked()
+			return 0, err
+		}
+		ddlOps = append(catalogOps, dataOps...)
+		ddlChecks = catalogChecks
+		ddlEntries = dataEntries
+		touchedTables = s.schemaTx.touchedTables()
+		touchedIndexes = s.schemaTx.touchedIndexes()
+	}
+
 	// Validate the final overlay against every foreign key in the constrained
 	// component. The per-table gates acquired above exclude concurrent writers,
 	// so this sees a stable state; checking the final state (rather than each
@@ -228,7 +365,8 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 	// with a "UNIQUE constraint failed" error rather than ErrSerialization.
 	uniquePlans := make(map[string]uniqueCachePlan, len(uniqueTables))
 	for t := range uniqueTables {
-		plan, err := s.table.prepareUniqueRows(t, s.overlay[t])
+		entries := s.finalRowEntries(t, ddlEntries)
+		plan, err := s.table.prepareUniqueRowsWith(t, entries, s.uniqueIndexesLocked(t))
 		if err != nil {
 			s.resetLocked()
 			return 0, err
@@ -247,15 +385,17 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 		exclusiveTables[predicate.table] = true
 	}
 	cachePlans := make(map[string]*tableCachePlan)
-	for table, entries := range s.overlay {
-		if exclusiveTables[table] {
-			plan, err := s.table.prepareTableCache(table, entries, s.reads)
-			if err != nil {
-				s.resetLocked()
-				return 0, err
-			}
-			cachePlans[table] = plan
+	for table := range s.overlay {
+		if !exclusiveTables[table] {
+			continue
 		}
+		entries := s.finalRowEntries(table, ddlEntries)
+		plan, err := s.table.prepareTableCache(table, entries, s.reads)
+		if err != nil {
+			s.resetLocked()
+			return 0, err
+		}
+		cachePlans[table] = plan
 	}
 
 	// Build the compare set from every observed key.
@@ -263,7 +403,6 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 	for key, lsn := range s.reads {
 		checks = append(checks, CompareCheck{Key: []byte(key), LSN: lsn})
 	}
-	sort.Slice(checks, func(i, j int) bool { return string(checks[i].Key) < string(checks[j].Key) })
 
 	// Build the batch ops from the staged overlay.
 	ops := make([]BatchOp, 0)
@@ -281,6 +420,13 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 			}
 		}
 	}
+
+	if hasDDL {
+		ops = append(ops, ddlOps...)
+		checks = append(checks, ddlChecks...)
+	}
+
+	sort.Slice(checks, func(i, j int) bool { return string(checks[i].Key) < string(checks[j].Key) })
 	sort.Slice(ops, func(i, j int) bool { return string(ops[i].Key) < string(ops[j].Key) })
 
 	// A read-only transaction has nothing to write; commit trivially.
@@ -324,11 +470,41 @@ func (s *Session) CommitWithLSN() (uint64, error) {
 		s.table.bumpIndexPredicateWildcard(t)
 		s.table.bumpGeneration(t)
 	}
+	if hasDDL {
+		s.publishCommittedDDL(touchedTables, touchedIndexes)
+	}
 
 	s.resetLocked()
 	return commitLSN, nil
 }
 
+// publishCommittedDDL publishes a committed catalog change to the rest of the
+// process. The durable batch is already written, so this only drops the derived
+// state that described the previous catalog: the shared schema and index caches,
+// the row-derived caches of every touched table, and the index caches of every
+// touched index. Other sessions reload lazily from the committed records.
+func (s *Session) publishCommittedDDL(tables, indexes []string) {
+	s.schema.invalidateCommittedDDL()
+	for _, table := range tables {
+		s.table.InvalidateCache(table)
+		s.table.bumpIndexPredicateWildcard(table)
+		s.table.bumpGeneration(table)
+	}
+	committed, err := s.schema.ListIndexes()
+	if err != nil {
+		committed = nil
+	}
+	known := make(map[string]bool, len(committed))
+	for _, name := range committed {
+		known[strings.ToLower(name)] = true
+	}
+	for _, index := range indexes {
+		if !known[index] {
+			s.table.ClearIndex(index, "", nil) //nolint:errcheck
+		}
+	}
+}
+
 // acquireCommitLocks acquires the per-table gates needed to commit the staged
 // transaction, returning the held locks, whether each is exclusive, an unlock
 // function, and the set of written tables that carry a UNIQUE index. A written
@@ -361,13 +537,20 @@ func (s *Session) acquireCommitLocks() (locks []*sync.RWMutex, exclusive []bool,
 		for _, predicate := range s.predicateGens {
 			affected[predicate.table] = true
 		}
+		// Staged DDL rewrites or removes durable rows and publishes catalog
+		// records, so the commit needs the exclusive gate of every touched table.
+		if s.schemaTx != nil && s.schemaTx.hasDDL() {
+			for _, t := range s.schemaTx.touchedTables() {
+				affected[t] = true
+			}
+		}
 
 		// Tables written in this transaction that carry a UNIQUE index need the
 		// exclusive gate so their final overlay can be validated against durable
 		// rows without racing another writer.
 		uniqueTables = make(map[string]bool)
 		for t := range s.overlay {
-			uniq, err := s.table.hasUniqueIndex(t)
+			uniq, err := s.hasUniqueIndexLocked(t)
 			if err != nil {
 				return nil, nil, nil, nil, err
 			}
@@ -409,7 +592,7 @@ func (s *Session) acquireCommitLocks() (locks []*sync.RWMutex, exclusive []bool,
 			if _, written := s.overlay[t]; !written {
 				continue
 			}
-			uniq, err := s.table.hasUniqueIndex(t)
+			uniq, err := s.hasUniqueIndexLocked(t)
 			if err != nil {
 				unlock(locks, exclusive)
 				return nil, nil, nil, nil, err
@@ -435,7 +618,7 @@ func (s *Session) acquireCommitLocks() (locks []*sync.RWMutex, exclusive []bool,
 // so a commit cannot persist a row that violates the schema now in effect.
 func (s *Session) applyOverlaySchemaDefaults() error {
 	for table, entries := range s.overlay {
-		schema, err := s.schema.GetSchema(table)
+		schema, err := s.getSchemaLocked(table)
 		if err != nil {
 			return err
 		}
@@ -443,6 +626,9 @@ func (s *Session) applyOverlaySchemaDefaults() error {
 			if entry.absent {
 				continue
 			}
+			if err := s.applyStagedRewritesLocked(table, entry.row); err != nil {
+				return err
+			}
 			if err := s.schema.applySchemaDefaults(schema, entry.row); err != nil {
 				return err
 			}
@@ -454,6 +640,61 @@ func (s *Session) applyOverlaySchemaDefaults() error {
 	return nil
 }
 
+// finalRowEntries merges the rows a staged DDL rewrite or rename produces into
+// the table's staged entries, so uniqueness and cache planning validate the row
+// image the commit is about to publish (caller holds the session lock).
+func (s *Session) finalRowEntries(table string, ddlEntries map[string]map[string]*overlayEntry) map[string]*overlayEntry {
+	staged := s.overlay[table]
+	rewritten := ddlEntries[table]
+	if len(rewritten) == 0 {
+		return staged
+	}
+	merged := make(map[string]*overlayEntry, len(staged)+len(rewritten))
+	for key, entry := range staged {
+		merged[key] = entry
+	}
+	for key, entry := range rewritten {
+		merged[key] = entry
+	}
+	return merged
+}
+
+// uniqueIndexesLocked returns the UNIQUE indexes of a table as this session
+// observes them (caller holds the session lock).
+func (s *Session) uniqueIndexesLocked(table string) []*Index {
+	var indexes []*Index
+	if s.schemaTx != nil {
+		indexes, _ = s.schemaTx.listTableIndexes(table)
+	} else {
+		indexes, _ = s.schema.ListTableIndexes(table)
+	}
+	unique := indexes[:0]
+	for _, idx := range indexes {
+		if idx.Unique {
+			unique = append(unique, idx)
+		}
+	}
+	return unique
+}
+
+// hasUniqueIndexLocked reports whether the session's view of a table carries a
+// UNIQUE index, so a transaction validates rows against an index it staged.
+func (s *Session) hasUniqueIndexLocked(table string) (bool, error) {
+	if s.schemaTx == nil {
+		return s.table.hasUniqueIndex(table)
+	}
+	indexes, err := s.schemaTx.listTableIndexes(table)
+	if err != nil {
+		return false, err
+	}
+	for _, idx := range indexes {
+		if idx.Unique {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
 func (s *Session) resetLocked() {
 	s.inTx = false
 	s.aborted = false
@@ -462,6 +703,9 @@ func (s *Session) resetLocked() {
 	s.reads = make(map[string]uint64)
 	s.scanGens = make(map[string]uint64)
 	s.predicateGens = make(map[string]predicateRead)
+	s.schemaTx = nil
+	s.fkCatalog = nil
+	s.fkVersion = 0
 }
 
 func (s *Session) stagePut(table, key string, row Row) {
@@ -498,26 +742,63 @@ func (s *Session) GetByPK(table, pk string) (Row, error) {
 	if !s.inTx {
 		return s.table.GetByPK(table, pk)
 	}
-	key := s.table.dataKey(table, pk)
+	stagedKey := s.table.dataKey(table, pk)
 	tl := strings.ToLower(table)
-	if e, ok := s.overlay[tl][key]; ok {
+	if e, ok := s.overlay[tl][stagedKey]; ok {
 		if e.absent {
 			return nil, ErrKeyNotFound
 		}
 		return cloneRow(e.row), nil
 	}
-	row, lsn, err := s.table.getByPKWithLSN(table, pk)
+	durableKey := s.durableDataKeyLocked(table, pk)
+	row, lsn, err := s.table.getKeyWithLSN(durableKey)
 	if err != nil {
 		if err == ErrKeyNotFound {
-			s.reads[key] = 0
+			s.reads[durableKey] = 0
 			return nil, ErrKeyNotFound
 		}
 		return nil, err
 	}
-	s.reads[key] = lsn
+	s.reads[durableKey] = lsn
+	if err := s.applyStagedRewritesLocked(table, row); err != nil {
+		return nil, err
+	}
 	return row, nil
 }
 
+// durableSourceLocked returns the committed table name whose data prefix holds
+// the rows of a table this transaction sees. RENAME TABLE moves the definition
+// immediately but leaves rows under the committed prefix until the commit, so
+// reads must follow the source (caller holds the session lock).
+func (s *Session) durableSourceLocked(table string) string {
+	if s.schemaTx == nil {
+		return table
+	}
+	lower := strings.ToLower(table)
+	source, ok := s.schemaTx.dataSources[lower]
+	if !ok || source == "" {
+		return table
+	}
+	return source
+}
+
+// hasStagedSourceLocked reports whether the table's durable rows live under a
+// different (older) name because RENAME TABLE is staged.
+func (s *Session) hasStagedSourceLocked(table string) bool {
+	if s.schemaTx == nil {
+		return false
+	}
+	lower := strings.ToLower(table)
+	source, ok := s.schemaTx.dataSources[lower]
+	return ok && source != "" && source != lower
+}
+
+// durableDataKeyLocked returns the key a row is durably stored under, following
+// a staged rename (caller holds the session lock).
+func (s *Session) durableDataKeyLocked(table, pk string) string {
+	return s.table.dataKey(s.durableSourceLocked(table), pk)
+}
+
 // Select scans a table, merging the staged overlay so a transaction sees its
 // own writes, and records per-row LSNs plus the table generation.
 func (s *Session) Select(table string, filter func(Row) bool) ([]Row, error) {
@@ -547,7 +828,7 @@ func (s *Session) SelectLimited(table string, filter func(Row) bool, maxRows int
 }
 
 func (s *Session) selectLocked(table string, filter func(Row) bool) ([]Row, error) {
-	schema, err := s.schema.GetSchema(table)
+	schema, err := s.getSchemaLocked(table)
 	if err != nil {
 		return nil, err
 	}
@@ -559,18 +840,29 @@ func (s *Session) selectLocked(table string, filter func(Row) bool) ([]Row, erro
 		s.scanGens[tl] = s.table.generation(tl)
 	}
 	overlay := s.overlay[tl]
+	source := s.durableSourceLocked(table)
+	sourceLower := strings.ToLower(source)
+	if sourceLower != tl {
+		// A concurrent write to the committed name must invalidate the commit
+		// just like a write to the staged name.
+		if _, ok := s.scanGens[sourceLower]; !ok {
+			s.scanGens[sourceLower] = s.table.generation(sourceLower)
+		}
+	}
 
 	var rows []Row
-	err = s.table.scanRowsWithLSN(table, func(row Row, lsn uint64) (bool, error) {
+	err = s.table.scanRowsAtPrefix(s.table.dataPrefix(source), func(durableKey string, row Row, lsn uint64) (bool, error) {
 		pk, err := schema.PrimaryKeyForRow(row)
 		if err != nil {
 			return true, err
 		}
-		key := s.table.dataKey(table, pk)
-		if _, ok := overlay[key]; ok {
+		if _, ok := overlay[s.table.dataKey(table, pk)]; ok {
 			return false, nil
 		}
-		s.reads[key] = lsn
+		s.reads[durableKey] = lsn
+		if err := s.applyStagedRewritesLocked(table, row); err != nil {
+			return true, err
+		}
 		if filter == nil || filter(row) {
 			rows = append(rows, row)
 		}
@@ -583,13 +875,28 @@ func (s *Session) selectLocked(table string, filter func(Row) bool) ([]Row, erro
 		if e.absent {
 			continue
 		}
-		if filter == nil || filter(e.row) {
-			rows = append(rows, cloneRow(e.row))
+		row := cloneRow(e.row)
+		if err := s.applyStagedRewritesLocked(table, row); err != nil {
+			return nil, err
+		}
+		if filter == nil || filter(row) {
+			rows = append(rows, row)
 		}
 	}
 	return rows, nil
 }
 
+// applyStagedRewritesLocked applies the transaction's pending ALTER TABLE row
+// transformations, so the session reads its own DDL before the commit writes
+// the transformed images (caller holds the session lock).
+func (s *Session) applyStagedRewritesLocked(table string, row Row) error {
+	if s.schemaTx == nil {
+		return nil
+	}
+	_, err := s.schemaTx.rewriteRow(table, row)
+	return err
+}
+
 // SelectByIndex reads only matching durable rows while capturing their LSNs,
 // then merges the transaction overlay. The table generation protects against
 // matching rows being inserted or removed after the lookup.
@@ -600,12 +907,19 @@ func (s *Session) SelectByIndex(table, indexName string, colValue interface{}) (
 		return s.table.SelectByIndex(table, indexName, colValue)
 	}
 	tableKey := strings.ToLower(table)
-	index, err := s.schema.GetIndex(indexName)
+	index, err := s.getIndexLocked(indexName)
 	if err != nil {
 		return nil, err
 	}
 	col := index.Columns[0].Name
 	want := formatIndexValue(colValue)
+	if s.hasStagedSourceLocked(table) || !s.indexIsCommittedLocked(index.Name) {
+		// The derived index caches describe the committed catalog: an index this
+		// transaction created has no entries yet, and a renamed table's entries
+		// are keyed under the old storage name. Either way, scan the rows the
+		// session observes instead.
+		return s.filterByIndexLocked(table, index, want)
+	}
 	versions, predicate, err := s.table.selectByIndexWithLSN(table, indexName, colValue)
 	if err != nil {
 		return nil, err
@@ -623,6 +937,9 @@ func (s *Session) SelectByIndex(table, indexName string, colValue interface{}) (
 			continue
 		}
 		s.reads[version.key] = version.lsn
+		if err := s.applyStagedRewritesLocked(table, version.row); err != nil {
+			return nil, err
+		}
 		rows = append(rows, version.row)
 	}
 	for _, entry := range overlay {
@@ -653,6 +970,9 @@ func (s *Session) SelectByIndexKey(table string, idx *Index, valueKey string) ([
 	}
 
 	tableKey := strings.ToLower(table)
+	if s.hasStagedSourceLocked(table) || !s.indexIsCommittedLocked(idx.Name) {
+		return s.filterByIndexLocked(table, idx, valueKey)
+	}
 	versions, predicate, err := s.table.selectByIndexKeyWithLSN(table, idx.Name, valueKey)
 	if err != nil {
 		return nil, err
@@ -670,6 +990,9 @@ func (s *Session) SelectByIndexKey(table string, idx *Index, valueKey string) ([
 			continue
 		}
 		s.reads[version.key] = version.lsn
+		if err := s.applyStagedRewritesLocked(table, version.row); err != nil {
+			return nil, err
+		}
 		rows = append(rows, version.row)
 	}
 	for _, entry := range overlay {
@@ -687,6 +1010,45 @@ func (s *Session) SelectByIndexKey(table string, idx *Index, valueKey string) ([
 	return rows, nil
 }
 
+// filterByIndexLocked returns the rows this session observes whose index key
+// equals valueKey, without consulting the derived index caches. It is the
+// fallback for a table renamed in this transaction, whose committed index
+// entries still name the old storage prefix (caller holds the session lock).
+func (s *Session) filterByIndexLocked(table string, idx *Index, valueKey string) ([]Row, error) {
+	rows, err := s.selectLocked(table, nil)
+	if err != nil {
+		return nil, err
+	}
+	matched := make([]Row, 0, len(rows))
+	for _, row := range rows {
+		key, err := s.table.IndexRowKey(idx, row)
+		if err != nil {
+			return nil, err
+		}
+		if key == valueKey {
+			matched = append(matched, row)
+		}
+	}
+	return matched, nil
+}
+
+// indexIsCommittedLocked reports whether the shared table manager can resolve an
+// index, and therefore whether its derived index caches cover it (caller holds
+// the session lock).
+func (s *Session) indexIsCommittedLocked(name string) bool {
+	_, err := s.schema.GetIndex(name)
+	return err == nil
+}
+
+// getIndexLocked resolves an index definition against the session catalog
+// (caller holds the session lock).
+func (s *Session) getIndexLocked(name string) (*Index, error) {
+	if s.schemaTx != nil {
+		return s.schemaTx.getIndex(name)
+	}
+	return s.schema.GetIndex(name)
+}
+
 // CountFast returns the exact row count, observing the staged overlay in a
 // transaction.
 func (s *Session) CountFast(table string) (int, error) {
@@ -749,7 +1111,11 @@ func (s *Session) InsertBulkWithLastRowID(table string, rows []Row) (int, int64,
 
 // insertLocked is the transaction insert helper (caller holds s.mu).
 func (s *Session) insertLocked(table string, row Row) (int64, error) {
-	nr, key, err := s.table.prepareInsert(table, row)
+	schema, err := s.getSchemaLocked(table)
+	if err != nil {
+		return 0, err
+	}
+	nr, key, err := s.table.prepareInsertWithSchema(table, schema, row)
 	if err != nil {
 		return 0, err
 	}
@@ -764,15 +1130,16 @@ func (s *Session) insertLocked(table string, row Row) (int64, error) {
 			return 0, &PrimaryKeyViolationError{Table: table, Key: pk}
 		}
 	} else {
-		_, lsn, err := s.table.getByPKWithLSN(table, pk)
+		durableKey := s.durableDataKeyLocked(table, pk)
+		_, lsn, err := s.table.getKeyWithLSN(durableKey)
 		if err == nil {
-			s.reads[key] = lsn
+			s.reads[durableKey] = lsn
 			return 0, &PrimaryKeyViolationError{Table: table, Key: pk}
 		}
-		if err != ErrKeyNotFound {
+		if err != nil && err != ErrKeyNotFound {
 			return 0, err
 		}
-		s.reads[key] = 0
+		s.reads[durableKey] = 0
 	}
 
 	s.stagePut(table, key, nr)
@@ -817,7 +1184,7 @@ func (s *Session) updateByPKStaged(table, pk string, updateFn func(Row) (Row, er
 // updateByPKLocked stages a point update. The caller holds the session lock and
 // a transaction.
 func (s *Session) updateByPKLocked(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
-	schema, err := s.schema.GetSchema(table)
+	schema, err := s.getSchemaLocked(table)
 	if err != nil {
 		return nil, false, err
 	}
@@ -939,6 +1306,9 @@ func (s *Session) getByPKLocked(table, pk string) (Row, error) {
 		return nil, err
 	}
 	s.reads[key] = lsn
+	if err := s.applyStagedRewritesLocked(table, row); err != nil {
+		return nil, err
+	}
 	return row, nil
 }
 
@@ -951,7 +1321,7 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 	}
 	defer s.mu.Unlock()
 
-	schema, err := s.schema.GetSchema(table)
+	schema, err := s.getSchemaLocked(table)
 	if err != nil {
 		return 0, err
 	}
@@ -999,7 +1369,7 @@ func (s *Session) Delete(table string, filter func(Row) bool) (int, error) {
 	}
 	defer s.mu.Unlock()
 
-	schema, err := s.schema.GetSchema(table)
+	schema, err := s.getSchemaLocked(table)
 	if err != nil {
 		return 0, err
 	}
@@ -1025,17 +1395,39 @@ func (s *Session) Delete(table string, filter func(Row) bool) (int, error) {
 	return count, nil
 }
 
-// ClearIndex passes through to the durable table manager.
+// ClearIndex drops the in-memory entries of an index. Inside a transaction the
+// drop is staged in the catalog overlay instead: the committed index (and its
+// cache) stays intact for every other session, and the commit clears it once the
+// catalog change is durable.
 func (s *Session) ClearIndex(indexName, tableName string, columns []string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx != nil && s.schemaTx.hasDDL() {
+		return nil
+	}
 	return s.table.ClearIndex(indexName, tableName, columns)
 }
 
-// InvalidateCache passes through to the durable table manager.
+// InvalidateCache drops the derived caches of a table. Inside a transaction the
+// durable rows are still current, so nothing is invalidated until the commit
+// publishes the catalog change.
 func (s *Session) InvalidateCache(table string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx != nil {
+		return
+	}
 	s.table.InvalidateCache(table)
 }
 
-// BuildIndex passes through to the durable table manager.
+// BuildIndex builds index entries for every existing row. Inside a transaction
+// the index is only staged; its entries are derived lazily from durable rows
+// once the commit publishes it and the session is merged in afterwards.
 func (s *Session) BuildIndex(indexName, tableName string, columns []string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.schemaTx != nil {
+		return nil
+	}
 	return s.table.BuildIndex(indexName, tableName, columns)
 }

+ 7 - 0
pkg/storage/unique_cache.go

@@ -25,6 +25,13 @@ func (m *TableManager) prepareUniqueRows(table string, entries map[string]*overl
 	if err != nil {
 		return nil, err
 	}
+	return m.prepareUniqueRowsWith(table, entries, indexes)
+}
+
+// prepareUniqueRowsWith is prepareUniqueRows against an explicit UNIQUE index
+// list, so a transaction validates its staged rows against the indexes it can
+// see rather than the committed catalog alone.
+func (m *TableManager) prepareUniqueRowsWith(table string, entries map[string]*overlayEntry, indexes []*Index) (uniqueCachePlan, error) {
 	if len(indexes) == 0 {
 		return nil, nil
 	}