Skip to main content

mongreldb_core/
catalog_cmds.rs

1//! Versioned catalog commands (spec §10.6, S1F-001).
2//!
3//! Every logical catalog mutation is expressed as a versioned
4//! [`CatalogCommand`] wrapped in a [`CatalogCommandRecord`]. The record carries
5//! an explicit encoding `version` (spec §4.10 — decode fails closed on an
6//! unknown version, unknown variant, or unknown field) and a monotonic
7//! `catalog_version` assigned by the catalog the command is applied to.
8//!
9//! # Durability model
10//!
11//! The CATALOG file is demoted from sole authority to a checkpoint. Command
12//! records ride inside the existing persistence mechanics with **no on-disk
13//! format change**:
14//!
15//! - `Catalog` retains a bounded in-struct history (`command_log`), so the
16//!   existing `DdlOp::CatalogSnapshot` WAL payload (`DdlOp::encode_catalog`)
17//!   and the `<root>/CATALOG` checkpoint both carry the applied command
18//!   records automatically.
19//! - [`Catalog::apply_command`] validates, applies, bumps `catalog_version`,
20//!   and appends the record to the bounded history;
21//!   [`Catalog::apply_command_and_checkpoint`] then rewrites the checkpoint
22//!   through the existing atomic write path.
23//!
24//! Application is deterministic: [`apply`] is a pure function from
25//! `(&Catalog, &CatalogCommand)` to a resolved [`CatalogDelta`], and
26//! `CatalogDelta::apply_to` replays the resolved change verbatim. Replaying
27//! the same record against the same catalog version is an idempotent no-op
28//! (see [`Catalog::apply_command`]).
29//!
30//! # Authorization boundary (spec §4.6) — landed design
31//!
32//! [`required_permission`] is the **permission map** for every command:
33//!
34//! - table/column/index, trigger/procedure, and materialized-view commands
35//!   require [`Permission::Ddl`];
36//! - user/role/grant/revoke, RLS-policy/column-mask, resource-group, and
37//!   job-definition commands require [`Permission::Admin`].
38//!
39//! **Enforcement sits on the emitter, not in pure apply.** `Database`
40//! mutation entry points call `require` / `require_for` against the caller's
41//! principal (using the same map) before proposing or applying catalog work.
42//! [`apply`] / [`Catalog::apply_command`] remain deterministic and
43//! principal-free so a replica can replay an already-authorized record
44//! without re-evaluating session identity. Emitters that propose
45//! `CatalogCommand`s MUST check `required_permission` against the current
46//! principal so revocation stays effective without reopening the core.
47//!
48//! # Canonical type aliases
49//!
50//! - [`ResourceGroupDef`] is a compatibility alias for
51//!   [`crate::resource::ResourceGroup`]. `SetResourceGroup` keeps name-only
52//!   validation; full group invariants live in
53//!   [`crate::resource::ResourceGroup::validate`].
54//! - [`JobDefinition`] is the catalog-level job-definition record; `kind` /
55//!   `state` re-export the S1F-002 types in [`crate::jobs`]. `SetJobState`
56//!   only guards terminal states; the full transition machine belongs to the
57//!   `JOBS` registry.
58//!
59//! Released CATALOG files carry none of these keys (serde-defaulted fields),
60//! so they open unchanged.
61//!
62//! # Surfaces not expressed as catalog commands
63//!
64//! Databases (the catalog is single-database; `db_epoch`/id counters remain
65//! engine-managed), external tables, hidden CTAS building-table states,
66//! SQLite pragmas (`user_version`/`application_id`), and `require_auth`
67//! bootstrap stay on direct `Database`/`Catalog` fields. `db_epoch` is NOT
68//! bumped by command application — epochs stay the commit sequencer's
69//! authority; `catalog_version` orders commands.
70
71use crate::auth::{Permission, RoleEntry, UserEntry};
72use crate::catalog::{Catalog, CatalogEntry, MaterializedViewEntry, TableState};
73use crate::error::{MongrelError, Result};
74use crate::procedure::{ProcedureEntry, StoredProcedure};
75use crate::schema::{ColumnDef, IndexDef, Schema, TypeId};
76use crate::security::{ColumnMask, MaskStrategy, RowPolicy, SecurityCatalog, SecurityExpr};
77use crate::trigger::{StoredTrigger, TriggerEntry, TriggerTarget};
78use serde::{Deserialize, Serialize};
79
80/// Encoding format version of [`CatalogCommandRecord`] (spec §4.10). Unknown
81/// versions fail closed on decode.
82pub const CATALOG_COMMAND_FORMAT_VERSION: u16 = 1;
83
84/// Bound on the retained command-history tail in [`Catalog::command_log`].
85/// Keeps CATALOG checkpoints and `CatalogSnapshot` WAL payloads bounded while
86/// retaining ample `commands_since` history for single-node operation.
87pub const COMMAND_HISTORY_LIMIT: usize = 256;
88
89/// The catalog-level resource-group payload: the canonical S1E-002
90/// [`crate::resource::ResourceGroup`], aliased for the catalog code that named
91/// the forward-reference placeholder. Its serde shape is the durable contract;
92/// released CATALOG files carry no `resource_groups` key and open with the
93/// field serde-defaulted to empty.
94pub type ResourceGroupDef = crate::resource::ResourceGroup;
95
96pub use crate::jobs::{JobKind, JobState};
97
98/// Persistent job definition recorded in the catalog (spec §10.6, S1F-001).
99/// `kind`/`state` are the canonical S1F-002 types re-exported above; the live
100/// state machine and per-job progress live in the `JOBS` registry
101/// ([`crate::jobs::JobRegistry`]).
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(deny_unknown_fields)]
104pub struct JobDefinition {
105    /// Unique, caller-allocated job id. Never reused.
106    pub job_id: u64,
107    pub kind: JobKind,
108    pub state: JobState,
109    /// Primary target (table, index, or materialized-view name), if any.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub target: Option<String>,
112    pub created_epoch: u64,
113    pub updated_epoch: u64,
114}
115
116/// One logical catalog mutation (spec §10.6, S1F-001).
117///
118/// Variants carry already-validated payloads (mirroring how `DdlOp` records
119/// carry resolved `ColumnDef`/schema JSON): deep semantic validation
120/// (expression trees, SQL typing) happens at the emitting layer, while
121/// [`apply`] enforces the catalog-level structural invariants fail-closed
122/// (existence, uniqueness, id allocation, reference integrity).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum CatalogCommand {
125    // ── Tables and columns ────────────────────────────────────────────
126    /// Create a live table. `table_id` is allocated deterministically from
127    /// `next_table_id` at apply time and stamped as `schema.schema_id`.
128    CreateTable {
129        name: String,
130        schema: Schema,
131        created_epoch: u64,
132    },
133    /// Logically drop a live table. Cascades to triggers targeting the
134    /// table, the same-named materialized-view definition, RLS state,
135    /// policies, masks, and table-scoped role permissions (mirrors
136    /// `Database::drop_table`).
137    DropTable {
138        name: String,
139        at_epoch: u64,
140    },
141    /// Rename a live table. Retargets triggers and renames the same-named
142    /// materialized-view definition. `name == new_name` is a recorded no-op
143    /// (mirrors `Database::rename_table`).
144    RenameTable {
145        name: String,
146        new_name: String,
147        at_epoch: u64,
148    },
149    /// Replace one existing column definition (same `id`) with an
150    /// already-validated one. Mirrors `DdlOp::AlterTable`.
151    AlterColumn {
152        table: String,
153        column: ColumnDef,
154    },
155    /// Add a column with a caller-allocated, unused `id`.
156    AddColumn {
157        table: String,
158        column: ColumnDef,
159    },
160    /// Drop a column by name; indexes referencing it are dropped too
161    /// (mirrors the SQL `ALTER TABLE ... DROP COLUMN` rebuild path).
162    DropColumn {
163        table: String,
164        column: String,
165    },
166    // ── Indexes ───────────────────────────────────────────────────────
167    /// Add a secondary index definition to a live table.
168    AddIndex {
169        table: String,
170        index: IndexDef,
171    },
172    /// Remove one index by exact name. Compound SQL index names expand to
173    /// one command per [`IndexDef`] at the emitting layer.
174    RemoveIndex {
175        table: String,
176        name: String,
177    },
178    /// Atomically replace one index definition with another. Publication is
179    /// compare-and-swap on `expected_schema_sequence` (`Schema::schema_id`):
180    /// concurrent DDL that advanced the sequence fails closed with
181    /// [`MongrelError::Conflict`] and leaves the newer schema untouched.
182    /// This is a single durable command — never compose from
183    /// [`Self::AddIndex`] + [`Self::RemoveIndex`].
184    ReplaceIndex {
185        table: String,
186        expected_schema_sequence: u64,
187        expected_old_name: String,
188        new_definition: IndexDef,
189    },
190    // ── Users, roles, grants ──────────────────────────────────────────
191    /// Create a user. `id` is allocated deterministically from
192    /// `next_user_id` (minimum 1) at apply time.
193    CreateUser {
194        username: String,
195        password_hash: String,
196        is_admin: bool,
197        created_epoch: u64,
198    },
199    DropUser {
200        username: String,
201    },
202    AlterUserPassword {
203        username: String,
204        password_hash: String,
205    },
206    SetUserAdmin {
207        username: String,
208        is_admin: bool,
209    },
210    CreateRole {
211        name: String,
212        created_epoch: u64,
213    },
214    /// Drop a role and strip it from every user (mirrors
215    /// `Database::drop_role`).
216    DropRole {
217        name: String,
218    },
219    /// Grant a role to a user. Idempotent: granting an already-held role is
220    /// a recorded no-op (mirrors `Database::grant_role`).
221    GrantRole {
222        username: String,
223        role: String,
224    },
225    /// Revoke a role from a user. Idempotent no-op when not held.
226    RevokeRole {
227        username: String,
228        role: String,
229    },
230    /// Grant a permission to a role, merging column lists for column-scoped
231    /// grants. Idempotent no-op when the merged set is unchanged.
232    GrantPermission {
233        role: String,
234        permission: Permission,
235    },
236    /// Revoke a permission from a role. Idempotent no-op when unchanged.
237    RevokePermission {
238        role: String,
239        permission: Permission,
240    },
241    // ── Row-level security and masks ──────────────────────────────────
242    EnableRls {
243        table: String,
244    },
245    DisableRls {
246        table: String,
247    },
248    /// Create or replace a row policy keyed by `(table, name)`.
249    SetRowPolicy {
250        policy: RowPolicy,
251    },
252    DropRowPolicy {
253        table: String,
254        name: String,
255    },
256    /// Create or replace a column mask keyed by `(table, name)`.
257    SetColumnMask {
258        mask: ColumnMask,
259    },
260    DropColumnMask {
261        table: String,
262        name: String,
263    },
264    /// Wholesale security-catalog replacement (RLS tables, policies, masks),
265    /// mirroring `Database::set_security_catalog`. Validated against the
266    /// candidate catalog exactly like the legacy path.
267    SetSecurityCatalog {
268        security: SecurityCatalog,
269    },
270    // ── Triggers and procedures ───────────────────────────────────────
271    CreateTrigger {
272        trigger: StoredTrigger,
273    },
274    DropTrigger {
275        name: String,
276    },
277    CreateProcedure {
278        procedure: StoredProcedure,
279    },
280    DropProcedure {
281        name: String,
282    },
283    // ── Materialized views ────────────────────────────────────────────
284    /// Create or replace a materialized-view definition. The backing live
285    /// table must already exist (mirrors `Database::set_materialized_view`).
286    CreateMaterializedView {
287        definition: MaterializedViewEntry,
288    },
289    /// Drop only the definition; the physical table is dropped separately
290    /// via [`CatalogCommand::DropTable`] (which also cascades definitions).
291    DropMaterializedView {
292        name: String,
293    },
294    /// Record refresh bookkeeping: bump `last_refresh_epoch` and, for
295    /// incremental views, advance the CDC checkpoint when provided.
296    RefreshMaterializedView {
297        name: String,
298        at_epoch: u64,
299        #[serde(default, skip_serializing_if = "Option::is_none")]
300        checkpoint_event_id: Option<String>,
301    },
302    // ── Resource groups (forward reference) ───────────────────────────
303    /// Create or replace a resource group.
304    SetResourceGroup {
305        group: ResourceGroupDef,
306    },
307    RemoveResourceGroup {
308        name: String,
309    },
310    // ── Job definitions (forward reference) ───────────────────────────
311    /// Submit a new persistent job. `job_id` must be unused.
312    SubmitJob {
313        job: JobDefinition,
314    },
315    /// Record a job state change. Transitions out of terminal states
316    /// (`Succeeded`/`Failed`) fail closed; S1F-002 owns the full machine.
317    SetJobState {
318        job_id: u64,
319        state: JobState,
320        at_epoch: u64,
321    },
322    // ── Serde-appended replacements (decode of older records unaffected) ──
323    /// Create or replace a trigger, keyed by name. `trigger` is the resolved
324    /// image: the emitter stamps `created_epoch`/`updated_epoch`/`version`
325    /// from the commit epoch (and bumps `version` on replacement) before
326    /// proposing, so replay is verbatim (mirrors
327    /// `Database::create_or_replace_trigger`).
328    ReplaceTrigger {
329        trigger: StoredTrigger,
330    },
331    /// Create or replace a stored procedure, keyed by name; the payload is
332    /// the epoch-resolved image (mirrors
333    /// `Database::create_or_replace_procedure`).
334    ReplaceProcedure {
335        procedure: StoredProcedure,
336    },
337    /// Create a user with both Argon2id and SCRAM-SHA-256 credentials.
338    CreateUserWithScram {
339        username: String,
340        password_hash: String,
341        scram_sha_256: crate::security_hardening::ScramVerifier,
342        is_admin: bool,
343        created_epoch: u64,
344    },
345    /// Replace both password credential forms atomically.
346    AlterUserPasswordWithScram {
347        username: String,
348        password_hash: String,
349        scram_sha_256: crate::security_hardening::ScramVerifier,
350    },
351    /// Create a user with native SCRAM and MySQL 8 compatibility verifiers.
352    CreateUserWithAuthVerifiers {
353        username: String,
354        password_hash: String,
355        scram_sha_256: crate::security_hardening::ScramVerifier,
356        mysql_caching_sha2: crate::auth::MysqlCachingSha2Verifier,
357        is_admin: bool,
358        created_epoch: u64,
359    },
360    /// Replace all password verifier forms atomically.
361    AlterUserPasswordWithAuthVerifiers {
362        username: String,
363        password_hash: String,
364        scram_sha_256: crate::security_hardening::ScramVerifier,
365        mysql_caching_sha2: crate::auth::MysqlCachingSha2Verifier,
366    },
367}
368
369/// A [`CatalogCommand`] plus its versioning envelope (spec §4.10).
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct CatalogCommandRecord {
373    /// Encoding format version; must equal [`CATALOG_COMMAND_FORMAT_VERSION`].
374    pub version: u16,
375    /// Monotonic catalog version this record assigns (`previous + 1`).
376    pub catalog_version: u64,
377    /// The mutation itself.
378    pub command: CatalogCommand,
379}
380
381impl CatalogCommandRecord {
382    /// Build the next record for `catalog` carrying `command`.
383    pub fn next(catalog: &Catalog, command: CatalogCommand) -> Self {
384        Self {
385            version: CATALOG_COMMAND_FORMAT_VERSION,
386            catalog_version: catalog.catalog_version.saturating_add(1),
387            command,
388        }
389    }
390}
391
392/// The resolved, deterministic effect of [`apply`].
393///
394/// Every variant carries fully-resolved values (allocated ids, complete
395/// replacement schemas/security catalogs) so `apply_to` is a mechanical
396/// replay with no further decisions.
397#[derive(Debug, Clone)]
398pub enum CatalogDelta {
399    /// Nothing changed: idempotent re-application or an inherently no-op
400    /// command (e.g. granting an already-held role).
401    NoOp,
402    TableCreated {
403        entry: CatalogEntry,
404    },
405    TableDropped {
406        table_id: u64,
407        name: String,
408        at_epoch: u64,
409    },
410    TableRenamed {
411        table_id: u64,
412        old_name: String,
413        new_name: String,
414        at_epoch: u64,
415    },
416    /// Column and index mutations resolve to a whole-schema replacement.
417    SchemaReplaced {
418        table_id: u64,
419        schema: Schema,
420    },
421    /// Create, password change, admin flag, and grant/revoke-role all
422    /// resolve to a user upsert keyed by username.
423    UserUpserted(UserEntry),
424    UserRemoved {
425        username: String,
426    },
427    /// Create and grant/revoke-permission resolve to a role upsert by name.
428    RoleUpserted(RoleEntry),
429    RoleRemoved {
430        name: String,
431    },
432    /// RLS/policy/mask mutations resolve to a wholesale security-catalog
433    /// replacement (mirrors `Database::set_security_catalog`).
434    SecurityReplaced {
435        security: SecurityCatalog,
436    },
437    TriggerUpserted(TriggerEntry),
438    TriggerRemoved {
439        name: String,
440    },
441    ProcedureUpserted(ProcedureEntry),
442    ProcedureRemoved {
443        name: String,
444    },
445    MaterializedViewUpserted(MaterializedViewEntry),
446    MaterializedViewRemoved {
447        name: String,
448    },
449    ResourceGroupUpserted(ResourceGroupDef),
450    ResourceGroupRemoved {
451        name: String,
452    },
453    JobUpserted(JobDefinition),
454}
455
456impl CatalogDelta {
457    /// Replay the resolved change onto `catalog`. Deterministic: every
458    /// decision was made by [`apply`].
459    pub(crate) fn apply_to(&self, catalog: &mut Catalog) -> Result<()> {
460        match self {
461            CatalogDelta::NoOp => {}
462            CatalogDelta::TableCreated { entry } => {
463                catalog.next_table_id = entry.table_id.checked_add(1).ok_or_else(|| {
464                    MongrelError::InvalidArgument("table id space exhausted".into())
465                })?;
466                catalog.tables.push(entry.clone());
467            }
468            CatalogDelta::TableDropped {
469                table_id,
470                name,
471                at_epoch,
472            } => {
473                let entry = catalog
474                    .tables
475                    .iter_mut()
476                    .find(|table| table.table_id == *table_id)
477                    .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
478                entry.state = TableState::Dropped {
479                    at_epoch: *at_epoch,
480                };
481                catalog.triggers.retain(|trigger| {
482                    !matches!(
483                        &trigger.trigger.target,
484                        TriggerTarget::Table(target) if target == name
485                    )
486                });
487                catalog
488                    .materialized_views
489                    .retain(|definition| definition.name != *name);
490                catalog.security.rls_tables.retain(|table| table != name);
491                catalog
492                    .security
493                    .policies
494                    .retain(|policy| policy.table != *name);
495                catalog.security.masks.retain(|mask| mask.table != *name);
496                for role in &mut catalog.roles {
497                    role.permissions
498                        .retain(|permission| permission_table(permission) != Some(name.as_str()));
499                }
500                advance_security_version(catalog)?;
501            }
502            CatalogDelta::TableRenamed {
503                table_id,
504                old_name,
505                new_name,
506                at_epoch,
507            } => {
508                let entry = catalog
509                    .tables
510                    .iter_mut()
511                    .find(|table| table.table_id == *table_id)
512                    .ok_or_else(|| {
513                        MongrelError::NotFound(format!("table {old_name:?} not found"))
514                    })?;
515                entry.name = new_name.clone();
516                for trigger in &mut catalog.triggers {
517                    if matches!(
518                        &trigger.trigger.target,
519                        TriggerTarget::Table(target) if target == old_name
520                    ) {
521                        trigger.trigger = trigger.trigger.retarget_table(new_name, *at_epoch)?;
522                    }
523                }
524                if let Some(definition) = catalog
525                    .materialized_views
526                    .iter_mut()
527                    .find(|definition| definition.name == *old_name)
528                {
529                    definition.name = new_name.clone();
530                }
531                // Mirrors `Database::rename_table`: table-scoped security
532                // state and role permissions follow the rename, and the
533                // security version advances.
534                for table in &mut catalog.security.rls_tables {
535                    if table == old_name {
536                        *table = new_name.clone();
537                    }
538                }
539                for policy in &mut catalog.security.policies {
540                    if policy.table == *old_name {
541                        policy.table = new_name.clone();
542                    }
543                }
544                for mask in &mut catalog.security.masks {
545                    if mask.table == *old_name {
546                        mask.table = new_name.clone();
547                    }
548                }
549                for role in &mut catalog.roles {
550                    for permission in &mut role.permissions {
551                        rename_permission_table(permission, old_name, new_name);
552                    }
553                }
554                advance_security_version(catalog)?;
555            }
556            CatalogDelta::SchemaReplaced { table_id, schema } => {
557                let entry = catalog
558                    .tables
559                    .iter_mut()
560                    .find(|table| table.table_id == *table_id)
561                    .ok_or_else(|| {
562                        MongrelError::NotFound(format!("table id {table_id} not found"))
563                    })?;
564                entry.schema = schema.clone();
565            }
566            CatalogDelta::UserUpserted(entry) => {
567                match catalog
568                    .users
569                    .iter_mut()
570                    .find(|user| user.username == entry.username)
571                {
572                    Some(existing) => *existing = entry.clone(),
573                    None => {
574                        let next = entry.id.checked_add(1).ok_or_else(|| {
575                            MongrelError::Full("user-id namespace exhausted".into())
576                        })?;
577                        catalog.users.push(entry.clone());
578                        catalog.next_user_id = catalog.next_user_id.max(next);
579                    }
580                }
581                advance_security_version(catalog)?;
582            }
583            CatalogDelta::UserRemoved { username } => {
584                catalog.users.retain(|user| user.username != *username);
585                advance_security_version(catalog)?;
586            }
587            CatalogDelta::RoleUpserted(entry) => {
588                match catalog
589                    .roles
590                    .iter_mut()
591                    .find(|role| role.name == entry.name)
592                {
593                    Some(existing) => *existing = entry.clone(),
594                    None => catalog.roles.push(entry.clone()),
595                }
596                advance_security_version(catalog)?;
597            }
598            CatalogDelta::RoleRemoved { name } => {
599                catalog.roles.retain(|role| role.name != *name);
600                for user in &mut catalog.users {
601                    user.roles.retain(|role| role != name);
602                }
603                advance_security_version(catalog)?;
604            }
605            CatalogDelta::SecurityReplaced { security } => {
606                catalog.security = security.clone();
607                advance_security_version(catalog)?;
608            }
609            CatalogDelta::TriggerUpserted(entry) => {
610                match catalog
611                    .triggers
612                    .iter_mut()
613                    .find(|trigger| trigger.trigger.name == entry.trigger.name)
614                {
615                    Some(existing) => *existing = entry.clone(),
616                    None => catalog.triggers.push(entry.clone()),
617                }
618            }
619            CatalogDelta::TriggerRemoved { name } => {
620                catalog
621                    .triggers
622                    .retain(|trigger| trigger.trigger.name != *name);
623            }
624            CatalogDelta::ProcedureUpserted(entry) => {
625                match catalog
626                    .procedures
627                    .iter_mut()
628                    .find(|procedure| procedure.procedure.name == entry.procedure.name)
629                {
630                    Some(existing) => *existing = entry.clone(),
631                    None => catalog.procedures.push(entry.clone()),
632                }
633            }
634            CatalogDelta::ProcedureRemoved { name } => {
635                catalog
636                    .procedures
637                    .retain(|procedure| procedure.procedure.name != *name);
638            }
639            CatalogDelta::MaterializedViewUpserted(definition) => {
640                match catalog
641                    .materialized_views
642                    .iter_mut()
643                    .find(|existing| existing.name == definition.name)
644                {
645                    Some(existing) => *existing = definition.clone(),
646                    None => catalog.materialized_views.push(definition.clone()),
647                }
648            }
649            CatalogDelta::MaterializedViewRemoved { name } => {
650                catalog
651                    .materialized_views
652                    .retain(|definition| definition.name != *name);
653            }
654            CatalogDelta::ResourceGroupUpserted(group) => {
655                match catalog
656                    .resource_groups
657                    .iter_mut()
658                    .find(|existing| existing.name == group.name)
659                {
660                    Some(existing) => *existing = group.clone(),
661                    None => catalog.resource_groups.push(group.clone()),
662                }
663            }
664            CatalogDelta::ResourceGroupRemoved { name } => {
665                catalog.resource_groups.retain(|group| group.name != *name);
666            }
667            CatalogDelta::JobUpserted(job) => {
668                match catalog
669                    .job_definitions
670                    .iter_mut()
671                    .find(|existing| existing.job_id == job.job_id)
672                {
673                    Some(existing) => *existing = job.clone(),
674                    None => catalog.job_definitions.push(job.clone()),
675                }
676            }
677        }
678        Ok(())
679    }
680}
681
682/// The permission the emitting layer must hold to propose `command`
683/// (spec §4.6). Pure map: `Database` enforces via `require` / `require_for`
684/// before mutation; apply paths do not re-check principals (see module docs).
685pub fn required_permission(command: &CatalogCommand) -> Permission {
686    match command {
687        CatalogCommand::CreateTable { .. }
688        | CatalogCommand::DropTable { .. }
689        | CatalogCommand::RenameTable { .. }
690        | CatalogCommand::AlterColumn { .. }
691        | CatalogCommand::AddColumn { .. }
692        | CatalogCommand::DropColumn { .. }
693        | CatalogCommand::AddIndex { .. }
694        | CatalogCommand::RemoveIndex { .. }
695        | CatalogCommand::ReplaceIndex { .. }
696        | CatalogCommand::CreateTrigger { .. }
697        | CatalogCommand::DropTrigger { .. }
698        | CatalogCommand::CreateProcedure { .. }
699        | CatalogCommand::DropProcedure { .. }
700        | CatalogCommand::ReplaceTrigger { .. }
701        | CatalogCommand::ReplaceProcedure { .. }
702        | CatalogCommand::CreateMaterializedView { .. }
703        | CatalogCommand::DropMaterializedView { .. }
704        | CatalogCommand::RefreshMaterializedView { .. } => Permission::Ddl,
705        CatalogCommand::CreateUser { .. }
706        | CatalogCommand::CreateUserWithScram { .. }
707        | CatalogCommand::CreateUserWithAuthVerifiers { .. }
708        | CatalogCommand::DropUser { .. }
709        | CatalogCommand::AlterUserPassword { .. }
710        | CatalogCommand::AlterUserPasswordWithScram { .. }
711        | CatalogCommand::AlterUserPasswordWithAuthVerifiers { .. }
712        | CatalogCommand::SetUserAdmin { .. }
713        | CatalogCommand::CreateRole { .. }
714        | CatalogCommand::DropRole { .. }
715        | CatalogCommand::GrantRole { .. }
716        | CatalogCommand::RevokeRole { .. }
717        | CatalogCommand::GrantPermission { .. }
718        | CatalogCommand::RevokePermission { .. }
719        | CatalogCommand::EnableRls { .. }
720        | CatalogCommand::DisableRls { .. }
721        | CatalogCommand::SetRowPolicy { .. }
722        | CatalogCommand::DropRowPolicy { .. }
723        | CatalogCommand::SetColumnMask { .. }
724        | CatalogCommand::DropColumnMask { .. }
725        | CatalogCommand::SetSecurityCatalog { .. }
726        | CatalogCommand::SetResourceGroup { .. }
727        | CatalogCommand::RemoveResourceGroup { .. }
728        | CatalogCommand::SubmitJob { .. }
729        | CatalogCommand::SetJobState { .. } => Permission::Admin,
730    }
731}
732
733/// Validate `command` against `catalog` and resolve its deterministic effect.
734/// Pure: `catalog` is never mutated; idempotent commands resolve to
735/// [`CatalogDelta::NoOp`].
736pub fn apply(catalog: &Catalog, command: &CatalogCommand) -> Result<CatalogDelta> {
737    match command {
738        CatalogCommand::CreateTable {
739            name,
740            schema,
741            created_epoch,
742        } => {
743            if name.is_empty() || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX) {
744                return Err(MongrelError::InvalidArgument(format!(
745                    "invalid table name {name:?}"
746                )));
747            }
748            if catalog.live(name).is_some() || catalog.building_for(name).is_some() {
749                return Err(MongrelError::InvalidArgument(format!(
750                    "table {name:?} already exists or is being built"
751                )));
752            }
753            let mut schema = schema.clone();
754            // Schema validation runs before the command can be recorded so a
755            // replayed command stream never carries an unopenable schema
756            // (mirrors `create_table_with_state`).
757            schema.validate_auto_increment()?;
758            schema.validate_defaults()?;
759            schema.validate_ai()?;
760            for index in &schema.indexes {
761                index.validate_options()?;
762            }
763            for constraint in &schema.constraints.checks {
764                constraint.expr.validate()?;
765            }
766            let table_id = catalog.next_table_id;
767            schema.schema_id = table_id;
768            Ok(CatalogDelta::TableCreated {
769                entry: CatalogEntry {
770                    table_id,
771                    name: name.clone(),
772                    schema,
773                    state: TableState::Live,
774                    created_epoch: *created_epoch,
775                },
776            })
777        }
778        CatalogCommand::DropTable { name, at_epoch } => {
779            let entry = catalog
780                .live(name)
781                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
782            Ok(CatalogDelta::TableDropped {
783                table_id: entry.table_id,
784                name: name.clone(),
785                at_epoch: *at_epoch,
786            })
787        }
788        CatalogCommand::RenameTable {
789            name,
790            new_name,
791            at_epoch,
792        } => {
793            if name == new_name {
794                return Ok(CatalogDelta::NoOp);
795            }
796            if new_name.is_empty()
797                || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
798                || new_name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
799            {
800                return Err(MongrelError::InvalidArgument(
801                    "invalid table rename identity".into(),
802                ));
803            }
804            let entry = catalog
805                .live(name)
806                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
807            if catalog.live(new_name).is_some() || catalog.building_for(new_name).is_some() {
808                return Err(MongrelError::InvalidArgument(format!(
809                    "a table named {new_name:?} already exists"
810                )));
811            }
812            Ok(CatalogDelta::TableRenamed {
813                table_id: entry.table_id,
814                old_name: name.clone(),
815                new_name: new_name.clone(),
816                at_epoch: *at_epoch,
817            })
818        }
819        CatalogCommand::AlterColumn { table, column } => {
820            let entry = live_entry(catalog, table)?;
821            let mut schema = entry.schema.clone();
822            let position = schema
823                .columns
824                .iter()
825                .position(|existing| existing.id == column.id)
826                .ok_or_else(|| {
827                    MongrelError::ColumnNotFound(format!("column id {} on {table}", column.id))
828                })?;
829            schema.columns[position] = column.clone();
830            // The engine (`Table::prepare_alter_column`) bumps `schema_id` on
831            // every applied alteration; the resolved delta mirrors it so a
832            // replayed command stream reproduces the same schema image.
833            schema.schema_id = schema
834                .schema_id
835                .checked_add(1)
836                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
837            Ok(CatalogDelta::SchemaReplaced {
838                table_id: entry.table_id,
839                schema,
840            })
841        }
842        CatalogCommand::AddColumn { table, column } => {
843            let entry = live_entry(catalog, table)?;
844            if entry.schema.column(&column.name).is_some() {
845                return Err(MongrelError::InvalidArgument(format!(
846                    "column {} already exists on {table}",
847                    column.name
848                )));
849            }
850            if entry
851                .schema
852                .columns
853                .iter()
854                .any(|existing| existing.id == column.id)
855            {
856                return Err(MongrelError::InvalidArgument(format!(
857                    "column id {} already used on {table}",
858                    column.id
859                )));
860            }
861            let mut schema = entry.schema.clone();
862            schema.columns.push(column.clone());
863            schema.validate_auto_increment()?;
864            schema.validate_defaults()?;
865            schema.validate_ai()?;
866            Ok(CatalogDelta::SchemaReplaced {
867                table_id: entry.table_id,
868                schema,
869            })
870        }
871        CatalogCommand::DropColumn { table, column } => {
872            let entry = live_entry(catalog, table)?;
873            let schema = &entry.schema;
874            let target = schema.column(column).ok_or_else(|| {
875                MongrelError::ColumnNotFound(format!("column {column} on {table}"))
876            })?;
877            let dropped_id = target.id;
878            let mut schema = schema.clone();
879            schema.columns.retain(|existing| existing.id != dropped_id);
880            schema.indexes.retain(|index| index.column_id != dropped_id);
881            Ok(CatalogDelta::SchemaReplaced {
882                table_id: entry.table_id,
883                schema,
884            })
885        }
886        CatalogCommand::AddIndex { table, index } => {
887            let entry = live_entry(catalog, table)?;
888            let schema = &entry.schema;
889            if schema
890                .indexes
891                .iter()
892                .any(|existing| existing.name == index.name)
893            {
894                return Err(MongrelError::InvalidArgument(format!(
895                    "index {} already exists on {table}",
896                    index.name
897                )));
898            }
899            index.validate_options()?;
900            let mut schema = schema.clone();
901            schema.indexes.push(index.clone());
902            bump_schema_sequence(&mut schema)?;
903            // validate_ai enforces column existence + kind/type compatibility.
904            schema.validate_ai()?;
905            Ok(CatalogDelta::SchemaReplaced {
906                table_id: entry.table_id,
907                schema,
908            })
909        }
910        CatalogCommand::RemoveIndex { table, name } => {
911            let entry = live_entry(catalog, table)?;
912            let schema = &entry.schema;
913            if !schema.indexes.iter().any(|index| index.name == *name) {
914                return Err(MongrelError::NotFound(format!(
915                    "index {name} does not exist on {table}"
916                )));
917            }
918            let mut schema = schema.clone();
919            schema.indexes.retain(|index| index.name != *name);
920            bump_schema_sequence(&mut schema)?;
921            Ok(CatalogDelta::SchemaReplaced {
922                table_id: entry.table_id,
923                schema,
924            })
925        }
926        CatalogCommand::ReplaceIndex {
927            table,
928            expected_schema_sequence,
929            expected_old_name,
930            new_definition,
931        } => {
932            let entry = live_entry(catalog, table)?;
933            let schema = &entry.schema;
934            if schema.schema_id != *expected_schema_sequence {
935                return Err(MongrelError::Conflict(format!(
936                    "index replace on {table}: expected schema sequence {expected_schema_sequence}, found {}",
937                    schema.schema_id
938                )));
939            }
940            if !schema
941                .indexes
942                .iter()
943                .any(|index| index.name == *expected_old_name)
944            {
945                return Err(MongrelError::NotFound(format!(
946                    "index {expected_old_name} does not exist on {table}"
947                )));
948            }
949            if new_definition.name != *expected_old_name
950                && schema
951                    .indexes
952                    .iter()
953                    .any(|index| index.name == new_definition.name)
954            {
955                return Err(MongrelError::InvalidArgument(format!(
956                    "index {} already exists on {table}",
957                    new_definition.name
958                )));
959            }
960            new_definition.validate_options()?;
961            let mut schema = schema.clone();
962            let position = schema
963                .indexes
964                .iter()
965                .position(|index| index.name == *expected_old_name)
966                .expect("presence checked above");
967            schema.indexes[position] = new_definition.clone();
968            bump_schema_sequence(&mut schema)?;
969            schema.validate_ai()?;
970            Ok(CatalogDelta::SchemaReplaced {
971                table_id: entry.table_id,
972                schema,
973            })
974        }
975        CatalogCommand::CreateUser {
976            username,
977            password_hash,
978            is_admin,
979            created_epoch,
980        } => {
981            if username.is_empty() {
982                return Err(MongrelError::InvalidArgument(
983                    "username must not be empty".into(),
984                ));
985            }
986            if catalog.users.iter().any(|user| user.username == *username) {
987                return Err(MongrelError::InvalidArgument(format!(
988                    "user {username:?} already exists"
989                )));
990            }
991            let id = catalog.next_user_id.max(1);
992            Ok(CatalogDelta::UserUpserted(UserEntry {
993                id,
994                username: username.clone(),
995                password_hash: password_hash.clone(),
996                scram_sha_256: None,
997                mysql_caching_sha2: None,
998                roles: Vec::new(),
999                is_admin: *is_admin,
1000                created_epoch: *created_epoch,
1001            }))
1002        }
1003        CatalogCommand::DropUser { username } => {
1004            if !catalog.users.iter().any(|user| user.username == *username) {
1005                return Err(MongrelError::NotFound(format!(
1006                    "user {username:?} not found"
1007                )));
1008            }
1009            Ok(CatalogDelta::UserRemoved {
1010                username: username.clone(),
1011            })
1012        }
1013        CatalogCommand::AlterUserPassword {
1014            username,
1015            password_hash,
1016        } => {
1017            let user = find_user(catalog, username)?;
1018            let mut user = user.clone();
1019            user.password_hash = password_hash.clone();
1020            user.scram_sha_256 = None;
1021            user.mysql_caching_sha2 = None;
1022            Ok(CatalogDelta::UserUpserted(user))
1023        }
1024        CatalogCommand::CreateUserWithScram {
1025            username,
1026            password_hash,
1027            scram_sha_256,
1028            is_admin,
1029            created_epoch,
1030        } => {
1031            if username.is_empty() {
1032                return Err(MongrelError::InvalidArgument(
1033                    "username must not be empty".into(),
1034                ));
1035            }
1036            if catalog.users.iter().any(|user| user.username == *username) {
1037                return Err(MongrelError::InvalidArgument(format!(
1038                    "user {username:?} already exists"
1039                )));
1040            }
1041            Ok(CatalogDelta::UserUpserted(UserEntry {
1042                id: catalog.next_user_id.max(1),
1043                username: username.clone(),
1044                password_hash: password_hash.clone(),
1045                scram_sha_256: Some(scram_sha_256.clone()),
1046                mysql_caching_sha2: None,
1047                roles: Vec::new(),
1048                is_admin: *is_admin,
1049                created_epoch: *created_epoch,
1050            }))
1051        }
1052        CatalogCommand::AlterUserPasswordWithScram {
1053            username,
1054            password_hash,
1055            scram_sha_256,
1056        } => {
1057            let mut user = find_user(catalog, username)?.clone();
1058            user.password_hash = password_hash.clone();
1059            user.scram_sha_256 = Some(scram_sha_256.clone());
1060            user.mysql_caching_sha2 = None;
1061            Ok(CatalogDelta::UserUpserted(user))
1062        }
1063        CatalogCommand::CreateUserWithAuthVerifiers {
1064            username,
1065            password_hash,
1066            scram_sha_256,
1067            mysql_caching_sha2,
1068            is_admin,
1069            created_epoch,
1070        } => {
1071            if username.is_empty() {
1072                return Err(MongrelError::InvalidArgument(
1073                    "username must not be empty".into(),
1074                ));
1075            }
1076            if catalog.users.iter().any(|user| user.username == *username) {
1077                return Err(MongrelError::InvalidArgument(format!(
1078                    "user {username:?} already exists"
1079                )));
1080            }
1081            Ok(CatalogDelta::UserUpserted(UserEntry {
1082                id: catalog.next_user_id.max(1),
1083                username: username.clone(),
1084                password_hash: password_hash.clone(),
1085                scram_sha_256: Some(scram_sha_256.clone()),
1086                mysql_caching_sha2: Some(mysql_caching_sha2.clone()),
1087                roles: Vec::new(),
1088                is_admin: *is_admin,
1089                created_epoch: *created_epoch,
1090            }))
1091        }
1092        CatalogCommand::AlterUserPasswordWithAuthVerifiers {
1093            username,
1094            password_hash,
1095            scram_sha_256,
1096            mysql_caching_sha2,
1097        } => {
1098            let mut user = find_user(catalog, username)?.clone();
1099            user.password_hash = password_hash.clone();
1100            user.scram_sha_256 = Some(scram_sha_256.clone());
1101            user.mysql_caching_sha2 = Some(mysql_caching_sha2.clone());
1102            Ok(CatalogDelta::UserUpserted(user))
1103        }
1104        CatalogCommand::SetUserAdmin { username, is_admin } => {
1105            let user = find_user(catalog, username)?;
1106            if user.is_admin == *is_admin {
1107                return Ok(CatalogDelta::NoOp);
1108            }
1109            let mut user = user.clone();
1110            user.is_admin = *is_admin;
1111            Ok(CatalogDelta::UserUpserted(user))
1112        }
1113        CatalogCommand::CreateRole {
1114            name,
1115            created_epoch,
1116        } => {
1117            if name.is_empty() {
1118                return Err(MongrelError::InvalidArgument(
1119                    "role name must not be empty".into(),
1120                ));
1121            }
1122            if catalog.roles.iter().any(|role| role.name == *name) {
1123                return Err(MongrelError::InvalidArgument(format!(
1124                    "role {name:?} already exists"
1125                )));
1126            }
1127            Ok(CatalogDelta::RoleUpserted(RoleEntry {
1128                name: name.clone(),
1129                permissions: Vec::new(),
1130                created_epoch: *created_epoch,
1131            }))
1132        }
1133        CatalogCommand::DropRole { name } => {
1134            if !catalog.roles.iter().any(|role| role.name == *name) {
1135                return Err(MongrelError::NotFound(format!("role {name:?} not found")));
1136            }
1137            Ok(CatalogDelta::RoleRemoved { name: name.clone() })
1138        }
1139        CatalogCommand::GrantRole { username, role } => {
1140            if !catalog.roles.iter().any(|entry| entry.name == *role) {
1141                return Err(MongrelError::NotFound(format!("role {role:?} not found")));
1142            }
1143            let user = find_user(catalog, username)?;
1144            if user.roles.iter().any(|held| held == role) {
1145                return Ok(CatalogDelta::NoOp);
1146            }
1147            let mut user = user.clone();
1148            user.roles.push(role.clone());
1149            Ok(CatalogDelta::UserUpserted(user))
1150        }
1151        CatalogCommand::RevokeRole { username, role } => {
1152            let user = find_user(catalog, username)?;
1153            if !user.roles.iter().any(|held| held == role) {
1154                return Ok(CatalogDelta::NoOp);
1155            }
1156            let mut user = user.clone();
1157            user.roles.retain(|held| held != role);
1158            Ok(CatalogDelta::UserUpserted(user))
1159        }
1160        CatalogCommand::GrantPermission { role, permission } => {
1161            let role = find_role(catalog, role)?;
1162            let mut resolved = role.clone();
1163            merge_permission(&mut resolved.permissions, permission.clone());
1164            if resolved.permissions == role.permissions {
1165                return Ok(CatalogDelta::NoOp);
1166            }
1167            Ok(CatalogDelta::RoleUpserted(resolved))
1168        }
1169        CatalogCommand::RevokePermission { role, permission } => {
1170            let role = find_role(catalog, role)?;
1171            let mut resolved = role.clone();
1172            revoke_permission_from(&mut resolved.permissions, permission);
1173            if resolved.permissions == role.permissions {
1174                return Ok(CatalogDelta::NoOp);
1175            }
1176            Ok(CatalogDelta::RoleUpserted(resolved))
1177        }
1178        CatalogCommand::EnableRls { table } => {
1179            live_entry(catalog, table)?;
1180            if catalog.security.rls_enabled(table) {
1181                return Ok(CatalogDelta::NoOp);
1182            }
1183            let mut security = catalog.security.clone();
1184            security.rls_tables.push(table.clone());
1185            Ok(CatalogDelta::SecurityReplaced { security })
1186        }
1187        CatalogCommand::DisableRls { table } => {
1188            if !catalog.security.rls_enabled(table) {
1189                return Ok(CatalogDelta::NoOp);
1190            }
1191            let mut security = catalog.security.clone();
1192            security.rls_tables.retain(|name| name != table);
1193            Ok(CatalogDelta::SecurityReplaced { security })
1194        }
1195        CatalogCommand::SetRowPolicy { policy } => {
1196            let entry = live_entry(catalog, &policy.table)?;
1197            if let Some(expression) = &policy.using {
1198                validate_policy_columns(expression, &entry.schema)?;
1199            }
1200            if let Some(expression) = &policy.with_check {
1201                validate_policy_columns(expression, &entry.schema)?;
1202            }
1203            let mut security = catalog.security.clone();
1204            match security
1205                .policies
1206                .iter_mut()
1207                .find(|existing| existing.table == policy.table && existing.name == policy.name)
1208            {
1209                Some(existing) => *existing = policy.clone(),
1210                None => security.policies.push(policy.clone()),
1211            }
1212            Ok(CatalogDelta::SecurityReplaced { security })
1213        }
1214        CatalogCommand::DropRowPolicy { table, name } => {
1215            if !catalog
1216                .security
1217                .policies
1218                .iter()
1219                .any(|policy| policy.table == *table && policy.name == *name)
1220            {
1221                return Err(MongrelError::NotFound(format!(
1222                    "policy {name:?} on {table:?} not found"
1223                )));
1224            }
1225            let mut security = catalog.security.clone();
1226            security
1227                .policies
1228                .retain(|policy| !(policy.table == *table && policy.name == *name));
1229            Ok(CatalogDelta::SecurityReplaced { security })
1230        }
1231        CatalogCommand::SetColumnMask { mask } => {
1232            let entry = live_entry(catalog, &mask.table)?;
1233            let column = entry
1234                .schema
1235                .columns
1236                .iter()
1237                .find(|column| column.id == mask.column)
1238                .ok_or_else(|| {
1239                    MongrelError::NotFound(format!(
1240                        "mask column {} on {:?} not found",
1241                        mask.column, mask.table
1242                    ))
1243                })?;
1244            if matches!(
1245                mask.strategy,
1246                MaskStrategy::Redact { .. } | MaskStrategy::Sha256
1247            ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
1248            {
1249                return Err(MongrelError::InvalidArgument(format!(
1250                    "mask {:?} requires a string/bytes column",
1251                    mask.name
1252                )));
1253            }
1254            let mut security = catalog.security.clone();
1255            match security
1256                .masks
1257                .iter_mut()
1258                .find(|existing| existing.table == mask.table && existing.name == mask.name)
1259            {
1260                Some(existing) => *existing = mask.clone(),
1261                None => security.masks.push(mask.clone()),
1262            }
1263            Ok(CatalogDelta::SecurityReplaced { security })
1264        }
1265        CatalogCommand::DropColumnMask { table, name } => {
1266            if !catalog
1267                .security
1268                .masks
1269                .iter()
1270                .any(|mask| mask.table == *table && mask.name == *name)
1271            {
1272                return Err(MongrelError::NotFound(format!(
1273                    "mask {name:?} on {table:?} not found"
1274                )));
1275            }
1276            let mut security = catalog.security.clone();
1277            security
1278                .masks
1279                .retain(|mask| !(mask.table == *table && mask.name == *name));
1280            Ok(CatalogDelta::SecurityReplaced { security })
1281        }
1282        CatalogCommand::SetSecurityCatalog { security } => {
1283            // Wholesale replacement: validated against the candidate catalog
1284            // with the same validator the legacy `set_security_catalog` path
1285            // runs, so a replayed command stream never carries an unopenable
1286            // security catalog.
1287            crate::database::validate_security_catalog(catalog, security)?;
1288            Ok(CatalogDelta::SecurityReplaced {
1289                security: security.clone(),
1290            })
1291        }
1292        CatalogCommand::CreateTrigger { trigger } => {
1293            trigger.validate()?;
1294            if let TriggerTarget::Table(target) = &trigger.target {
1295                if catalog.live(target).is_none() {
1296                    return Err(MongrelError::InvalidArgument(format!(
1297                        "trigger {:?} references unknown target table {target:?}",
1298                        trigger.name
1299                    )));
1300                }
1301            }
1302            if catalog
1303                .triggers
1304                .iter()
1305                .any(|entry| entry.trigger.name == trigger.name)
1306            {
1307                return Err(MongrelError::TriggerValidation(format!(
1308                    "trigger {:?} already exists",
1309                    trigger.name
1310                )));
1311            }
1312            Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1313                trigger.clone(),
1314            )))
1315        }
1316        CatalogCommand::DropTrigger { name } => {
1317            if !catalog
1318                .triggers
1319                .iter()
1320                .any(|entry| entry.trigger.name == *name)
1321            {
1322                return Err(MongrelError::NotFound(format!(
1323                    "trigger {name:?} not found"
1324                )));
1325            }
1326            Ok(CatalogDelta::TriggerRemoved { name: name.clone() })
1327        }
1328        CatalogCommand::CreateProcedure { procedure } => {
1329            procedure.validate()?;
1330            for step in &procedure.body.steps {
1331                if let Some(table) = step.table() {
1332                    if catalog.live(table).is_none() {
1333                        return Err(MongrelError::InvalidArgument(format!(
1334                            "procedure {:?} references unknown table {table:?}",
1335                            procedure.name
1336                        )));
1337                    }
1338                }
1339            }
1340            if catalog
1341                .procedures
1342                .iter()
1343                .any(|entry| entry.procedure.name == procedure.name)
1344            {
1345                return Err(MongrelError::InvalidArgument(format!(
1346                    "procedure {:?} already exists",
1347                    procedure.name
1348                )));
1349            }
1350            Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1351                procedure.clone(),
1352            )))
1353        }
1354        CatalogCommand::DropProcedure { name } => {
1355            if !catalog
1356                .procedures
1357                .iter()
1358                .any(|entry| entry.procedure.name == *name)
1359            {
1360                return Err(MongrelError::NotFound(format!(
1361                    "procedure {name:?} not found"
1362                )));
1363            }
1364            Ok(CatalogDelta::ProcedureRemoved { name: name.clone() })
1365        }
1366        CatalogCommand::ReplaceTrigger { trigger } => {
1367            // CreateTrigger's structural validation without the must-not-exist
1368            // rule: an existing entry is replaced by name. The payload is the
1369            // emitter-resolved image (epochs/version already stamped), so the
1370            // delta replays it verbatim.
1371            trigger.validate()?;
1372            if let TriggerTarget::Table(target) = &trigger.target {
1373                if catalog.live(target).is_none() {
1374                    return Err(MongrelError::InvalidArgument(format!(
1375                        "trigger {:?} references unknown target table {target:?}",
1376                        trigger.name
1377                    )));
1378                }
1379            }
1380            Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1381                trigger.clone(),
1382            )))
1383        }
1384        CatalogCommand::ReplaceProcedure { procedure } => {
1385            procedure.validate()?;
1386            for step in &procedure.body.steps {
1387                if let Some(table) = step.table() {
1388                    if catalog.live(table).is_none() {
1389                        return Err(MongrelError::InvalidArgument(format!(
1390                            "procedure {:?} references unknown table {table:?}",
1391                            procedure.name
1392                        )));
1393                    }
1394                }
1395            }
1396            Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1397                procedure.clone(),
1398            )))
1399        }
1400        CatalogCommand::CreateMaterializedView { definition } => {
1401            if definition.name.is_empty() || definition.query.trim().is_empty() {
1402                return Err(MongrelError::InvalidArgument(
1403                    "materialized view name and query must not be empty".into(),
1404                ));
1405            }
1406            if catalog.live(&definition.name).is_none() {
1407                return Err(MongrelError::NotFound(format!(
1408                    "materialized view table {:?} not found",
1409                    definition.name
1410                )));
1411            }
1412            Ok(CatalogDelta::MaterializedViewUpserted(definition.clone()))
1413        }
1414        CatalogCommand::DropMaterializedView { name } => {
1415            if !catalog
1416                .materialized_views
1417                .iter()
1418                .any(|definition| definition.name == *name)
1419            {
1420                return Err(MongrelError::NotFound(format!(
1421                    "materialized view {name:?} not found"
1422                )));
1423            }
1424            Ok(CatalogDelta::MaterializedViewRemoved { name: name.clone() })
1425        }
1426        CatalogCommand::RefreshMaterializedView {
1427            name,
1428            at_epoch,
1429            checkpoint_event_id,
1430        } => {
1431            let definition = catalog
1432                .materialized_views
1433                .iter()
1434                .find(|definition| definition.name == *name)
1435                .ok_or_else(|| {
1436                    MongrelError::NotFound(format!("materialized view {name:?} not found"))
1437                })?;
1438            let mut definition = definition.clone();
1439            definition.last_refresh_epoch = *at_epoch;
1440            if let Some(checkpoint) = checkpoint_event_id {
1441                let plan = definition.incremental.as_mut().ok_or_else(|| {
1442                    MongrelError::InvalidArgument(format!(
1443                        "materialized view {name:?} has no incremental plan"
1444                    ))
1445                })?;
1446                plan.checkpoint_event_id = checkpoint.clone();
1447            }
1448            Ok(CatalogDelta::MaterializedViewUpserted(definition))
1449        }
1450        CatalogCommand::SetResourceGroup { group } => {
1451            if group.name.is_empty() {
1452                return Err(MongrelError::InvalidArgument(
1453                    "resource group name must not be empty".into(),
1454                ));
1455            }
1456            Ok(CatalogDelta::ResourceGroupUpserted(group.clone()))
1457        }
1458        CatalogCommand::RemoveResourceGroup { name } => {
1459            if !catalog
1460                .resource_groups
1461                .iter()
1462                .any(|group| group.name == *name)
1463            {
1464                return Err(MongrelError::NotFound(format!(
1465                    "resource group {name:?} not found"
1466                )));
1467            }
1468            Ok(CatalogDelta::ResourceGroupRemoved { name: name.clone() })
1469        }
1470        CatalogCommand::SubmitJob { job } => {
1471            if catalog
1472                .job_definitions
1473                .iter()
1474                .any(|existing| existing.job_id == job.job_id)
1475            {
1476                return Err(MongrelError::InvalidArgument(format!(
1477                    "job id {} already exists",
1478                    job.job_id
1479                )));
1480            }
1481            Ok(CatalogDelta::JobUpserted(job.clone()))
1482        }
1483        CatalogCommand::SetJobState {
1484            job_id,
1485            state,
1486            at_epoch,
1487        } => {
1488            let job = catalog
1489                .job_definitions
1490                .iter()
1491                .find(|job| job.job_id == *job_id)
1492                .ok_or_else(|| MongrelError::NotFound(format!("job id {job_id} not found")))?;
1493            if job.state.is_terminal() {
1494                return Err(MongrelError::InvalidArgument(format!(
1495                    "job id {job_id} is in terminal state {:?}",
1496                    job.state
1497                )));
1498            }
1499            let mut job = job.clone();
1500            job.state = *state;
1501            job.updated_epoch = *at_epoch;
1502            Ok(CatalogDelta::JobUpserted(job))
1503        }
1504    }
1505}
1506
1507/// Encode a command record (JSON; deterministic field order).
1508pub fn encode_command(record: &CatalogCommandRecord) -> Result<Vec<u8>> {
1509    serde_json::to_vec(record)
1510        .map_err(|error| MongrelError::Other(format!("catalog command serialize: {error}")))
1511}
1512
1513/// Decode a command record, failing closed (spec §4.10) on malformed JSON,
1514/// unknown variants/fields, or an unsupported encoding version.
1515pub fn decode_command(bytes: &[u8]) -> Result<CatalogCommandRecord> {
1516    let record: CatalogCommandRecord = serde_json::from_slice(bytes)
1517        .map_err(|error| MongrelError::Other(format!("catalog command deserialize: {error}")))?;
1518    if record.version != CATALOG_COMMAND_FORMAT_VERSION {
1519        return Err(MongrelError::UnsupportedStorageVersion {
1520            component: "catalog command",
1521            found: record.version,
1522            supported: CATALOG_COMMAND_FORMAT_VERSION,
1523        });
1524    }
1525    Ok(record)
1526}
1527
1528fn live_entry<'a>(catalog: &'a Catalog, table: &str) -> Result<&'a CatalogEntry> {
1529    catalog
1530        .live(table)
1531        .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))
1532}
1533
1534/// Advance `Schema::schema_id` so concurrent index DDL can CAS against a
1535/// single sequence number at publication.
1536fn bump_schema_sequence(schema: &mut Schema) -> Result<()> {
1537    schema.schema_id = schema
1538        .schema_id
1539        .checked_add(1)
1540        .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
1541    Ok(())
1542}
1543
1544fn find_user<'a>(catalog: &'a Catalog, username: &str) -> Result<&'a UserEntry> {
1545    catalog
1546        .users
1547        .iter()
1548        .find(|user| user.username == username)
1549        .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))
1550}
1551
1552fn find_role<'a>(catalog: &'a Catalog, name: &str) -> Result<&'a RoleEntry> {
1553    catalog
1554        .roles
1555        .iter()
1556        .find(|role| role.name == name)
1557        .ok_or_else(|| MongrelError::NotFound(format!("role {name:?} not found")))
1558}
1559
1560/// Bump the optimistic authorization snapshot counter (mirrors
1561/// `advance_security_version` in `database.rs`; unified when command routing
1562/// lands).
1563fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
1564    catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
1565        MongrelError::Conflict("security catalog version space is exhausted".into())
1566    })?;
1567    Ok(())
1568}
1569
1570/// Table a permission references (mirrors `permission_table` in
1571/// `database.rs`; unified when command routing lands).
1572fn permission_table(permission: &Permission) -> Option<&str> {
1573    match permission {
1574        Permission::Select { table }
1575        | Permission::Insert { table }
1576        | Permission::Update { table }
1577        | Permission::Delete { table }
1578        | Permission::SelectColumns { table, .. }
1579        | Permission::InsertColumns { table, .. }
1580        | Permission::UpdateColumns { table, .. } => Some(table),
1581        Permission::All | Permission::Ddl | Permission::Admin => None,
1582    }
1583}
1584
1585/// Retarget a table-scoped permission on rename (mirrors
1586/// `rename_permission_table` in `database.rs`).
1587fn rename_permission_table(permission: &mut Permission, old: &str, new: &str) {
1588    let table = match permission {
1589        Permission::Select { table }
1590        | Permission::Insert { table }
1591        | Permission::Update { table }
1592        | Permission::Delete { table }
1593        | Permission::SelectColumns { table, .. }
1594        | Permission::InsertColumns { table, .. }
1595        | Permission::UpdateColumns { table, .. } => Some(table),
1596        Permission::All | Permission::Ddl | Permission::Admin => None,
1597    };
1598    if let Some(table) = table.filter(|table| table.as_str() == old) {
1599        *table = new.to_string();
1600    }
1601}
1602
1603/// Merge a granted permission, coalescing column-scoped grants on the same
1604/// table (mirrors `merge_permission` in `database.rs`).
1605fn merge_permission(permissions: &mut Vec<Permission>, permission: Permission) {
1606    let (kind, table, mut columns) = match permission {
1607        Permission::SelectColumns { table, columns } => (0, table, columns),
1608        Permission::InsertColumns { table, columns } => (1, table, columns),
1609        Permission::UpdateColumns { table, columns } => (2, table, columns),
1610        permission if !permissions.contains(&permission) => {
1611            permissions.push(permission);
1612            return;
1613        }
1614        _ => return,
1615    };
1616    for permission in permissions.iter_mut() {
1617        let existing = match permission {
1618            Permission::SelectColumns {
1619                table: existing_table,
1620                columns,
1621            } if kind == 0 && existing_table == &table => Some(columns),
1622            Permission::InsertColumns {
1623                table: existing_table,
1624                columns,
1625            } if kind == 1 && existing_table == &table => Some(columns),
1626            Permission::UpdateColumns {
1627                table: existing_table,
1628                columns,
1629            } if kind == 2 && existing_table == &table => Some(columns),
1630            _ => None,
1631        };
1632        if let Some(existing) = existing {
1633            existing.append(&mut columns);
1634            existing.sort();
1635            existing.dedup();
1636            return;
1637        }
1638    }
1639    columns.sort();
1640    columns.dedup();
1641    let permission = if kind == 0 {
1642        Permission::SelectColumns { table, columns }
1643    } else if kind == 1 {
1644        Permission::InsertColumns { table, columns }
1645    } else {
1646        Permission::UpdateColumns { table, columns }
1647    };
1648    permissions.push(permission);
1649}
1650
1651/// Remove a revoked permission, subtracting column-scoped grants (mirrors
1652/// `revoke_permission_from` in `database.rs`).
1653fn revoke_permission_from(permissions: &mut Vec<Permission>, revoked: &Permission) {
1654    let revoked_columns = match revoked {
1655        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
1656        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
1657        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
1658        _ => None,
1659    };
1660    let Some((kind, table, columns)) = revoked_columns else {
1661        permissions.retain(|permission| permission != revoked);
1662        return;
1663    };
1664    for permission in permissions.iter_mut() {
1665        let current = match permission {
1666            Permission::SelectColumns {
1667                table: current_table,
1668                columns,
1669            } if kind == 0 && current_table == table => Some(columns),
1670            Permission::InsertColumns {
1671                table: current_table,
1672                columns,
1673            } if kind == 1 && current_table == table => Some(columns),
1674            Permission::UpdateColumns {
1675                table: current_table,
1676                columns,
1677            } if kind == 2 && current_table == table => Some(columns),
1678            _ => None,
1679        };
1680        if let Some(current) = current {
1681            current.retain(|column| !columns.contains(column));
1682        }
1683    }
1684    permissions.retain(|permission| match permission {
1685        Permission::SelectColumns { columns, .. }
1686        | Permission::InsertColumns { columns, .. }
1687        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
1688        _ => true,
1689    });
1690}
1691
1692/// Fail closed when a policy expression references a column id the table
1693/// schema does not have (mirrors `validate_security_expression` in
1694/// `database.rs`).
1695fn validate_policy_columns(expression: &SecurityExpr, schema: &Schema) -> Result<()> {
1696    match expression {
1697        SecurityExpr::True => Ok(()),
1698        SecurityExpr::ColumnEqCurrentUser { column }
1699        | SecurityExpr::ColumnEqValue { column, .. } => {
1700            if schema
1701                .columns
1702                .iter()
1703                .any(|candidate| candidate.id == *column)
1704            {
1705                Ok(())
1706            } else {
1707                Err(MongrelError::InvalidArgument(format!(
1708                    "security expression references unknown column id {column}"
1709                )))
1710            }
1711        }
1712        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
1713            validate_policy_columns(left, schema)?;
1714            validate_policy_columns(right, schema)
1715        }
1716        SecurityExpr::Not { expression } => validate_policy_columns(expression, schema),
1717    }
1718}
1719
1720#[cfg(test)]
1721mod tests {
1722    use super::*;
1723    use crate::schema::ColumnFlags;
1724
1725    fn test_schema() -> Schema {
1726        Schema {
1727            schema_id: 0,
1728            columns: vec![
1729                ColumnDef {
1730                    id: 1,
1731                    name: "id".into(),
1732                    ty: TypeId::Int64,
1733                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
1734                    default_value: None,
1735                    embedding_source: None,
1736                },
1737                ColumnDef {
1738                    id: 2,
1739                    name: "secret".into(),
1740                    ty: TypeId::Bytes,
1741                    flags: ColumnFlags::empty(),
1742                    default_value: None,
1743                    embedding_source: None,
1744                },
1745            ],
1746            indexes: vec![],
1747            colocation: vec![],
1748            constraints: Default::default(),
1749            clustered: false,
1750        }
1751    }
1752
1753    fn catalog_with_table() -> Catalog {
1754        let mut catalog = Catalog::empty();
1755        let record = CatalogCommandRecord::next(
1756            &catalog,
1757            CatalogCommand::CreateTable {
1758                name: "t".into(),
1759                schema: test_schema(),
1760                created_epoch: 1,
1761            },
1762        );
1763        catalog.apply_command(&record).unwrap();
1764        catalog
1765    }
1766
1767    #[test]
1768    fn create_table_allocates_id_and_stamps_schema() {
1769        let catalog = catalog_with_table();
1770        assert_eq!(catalog.catalog_version(), 1);
1771        let entry = catalog.live("t").unwrap();
1772        assert_eq!(entry.table_id, 0);
1773        assert_eq!(entry.schema.schema_id, 0);
1774        assert_eq!(catalog.next_table_id, 1);
1775        // The same schema id must not collide with a second table.
1776        let mut catalog = catalog;
1777        let record = CatalogCommandRecord::next(
1778            &catalog,
1779            CatalogCommand::CreateTable {
1780                name: "u".into(),
1781                schema: test_schema(),
1782                created_epoch: 2,
1783            },
1784        );
1785        catalog.apply_command(&record).unwrap();
1786        assert_eq!(catalog.live("u").unwrap().table_id, 1);
1787        assert_eq!(catalog.live("u").unwrap().schema.schema_id, 1);
1788    }
1789
1790    #[test]
1791    fn duplicate_create_table_fails_closed() {
1792        let catalog = catalog_with_table();
1793        let delta = apply(
1794            &catalog,
1795            &CatalogCommand::CreateTable {
1796                name: "t".into(),
1797                schema: test_schema(),
1798                created_epoch: 9,
1799            },
1800        );
1801        assert!(matches!(delta, Err(MongrelError::InvalidArgument(_))));
1802    }
1803
1804    #[test]
1805    fn version_gap_and_replay_guard() {
1806        let mut catalog = catalog_with_table();
1807        // Gap: version 3 when the next expected is 2.
1808        let gap = CatalogCommandRecord {
1809            version: CATALOG_COMMAND_FORMAT_VERSION,
1810            catalog_version: 3,
1811            command: CatalogCommand::DisableRls { table: "t".into() },
1812        };
1813        assert!(matches!(
1814            catalog.apply_command(&gap),
1815            Err(MongrelError::Conflict(_))
1816        ));
1817        // Replay of the exact recorded command is an idempotent no-op.
1818        let recorded = catalog.commands_since(0)[0].clone();
1819        let delta = catalog.apply_command(&recorded).unwrap();
1820        assert!(matches!(delta, CatalogDelta::NoOp));
1821        assert_eq!(catalog.catalog_version(), 1);
1822        // A different command claiming an already-applied version conflicts.
1823        let conflicting = CatalogCommandRecord {
1824            version: CATALOG_COMMAND_FORMAT_VERSION,
1825            catalog_version: 1,
1826            command: CatalogCommand::CreateTable {
1827                name: "other".into(),
1828                schema: test_schema(),
1829                created_epoch: 5,
1830            },
1831        };
1832        assert!(matches!(
1833            catalog.apply_command(&conflicting),
1834            Err(MongrelError::Conflict(_))
1835        ));
1836    }
1837
1838    #[test]
1839    fn unsupported_encoding_version_fails_closed() {
1840        let mut catalog = catalog_with_table();
1841        let record = CatalogCommandRecord {
1842            version: CATALOG_COMMAND_FORMAT_VERSION + 1,
1843            catalog_version: 2,
1844            command: CatalogCommand::DisableRls { table: "t".into() },
1845        };
1846        assert!(matches!(
1847            catalog.apply_command(&record),
1848            Err(MongrelError::UnsupportedStorageVersion { .. })
1849        ));
1850    }
1851
1852    #[test]
1853    fn encode_decode_round_trip() {
1854        let catalog = catalog_with_table();
1855        let record = &catalog.commands_since(0)[0];
1856        let bytes = encode_command(record).unwrap();
1857        let decoded = decode_command(&bytes).unwrap();
1858        assert_eq!(decoded.catalog_version, record.catalog_version);
1859        assert_eq!(decoded.version, CATALOG_COMMAND_FORMAT_VERSION);
1860        assert_eq!(encode_command(&decoded).unwrap(), bytes);
1861    }
1862
1863    #[test]
1864    fn decode_rejects_unknown_version_and_variant() {
1865        let catalog = catalog_with_table();
1866        let bytes = encode_command(&catalog.commands_since(0)[0]).unwrap();
1867        let mut json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1868        json["version"] = serde_json::json!(99);
1869        assert!(matches!(
1870            decode_command(serde_json::to_string(&json).unwrap().as_bytes()),
1871            Err(MongrelError::UnsupportedStorageVersion { .. })
1872        ));
1873        let unknown = br#"{"version":1,"catalog_version":1,"command":{"NotACommand":{}}}"#;
1874        assert!(decode_command(unknown).is_err());
1875        let unknown_field =
1876            br#"{"version":1,"catalog_version":1,"command":{"DisableRls":{"table":"t"}},"x":1}"#;
1877        assert!(decode_command(unknown_field).is_err());
1878    }
1879
1880    #[test]
1881    fn drop_table_cascades_security_and_roles() {
1882        let mut catalog = catalog_with_table();
1883        for command in [
1884            CatalogCommand::EnableRls { table: "t".into() },
1885            CatalogCommand::SetRowPolicy {
1886                policy: RowPolicy {
1887                    name: "p".into(),
1888                    table: "t".into(),
1889                    command: crate::security::PolicyCommand::Select,
1890                    subjects: vec![],
1891                    permissive: true,
1892                    using: Some(SecurityExpr::True),
1893                    with_check: None,
1894                },
1895            },
1896            CatalogCommand::SetColumnMask {
1897                mask: ColumnMask {
1898                    name: "m".into(),
1899                    table: "t".into(),
1900                    column: 2,
1901                    strategy: MaskStrategy::Sha256,
1902                    exempt_subjects: vec![],
1903                },
1904            },
1905            CatalogCommand::CreateRole {
1906                name: "r".into(),
1907                created_epoch: 1,
1908            },
1909            CatalogCommand::GrantPermission {
1910                role: "r".into(),
1911                permission: Permission::Select { table: "t".into() },
1912            },
1913        ] {
1914            let record = CatalogCommandRecord::next(&catalog, command);
1915            catalog.apply_command(&record).unwrap();
1916        }
1917        let security_before = catalog.security.clone();
1918        assert!(security_before.rls_enabled("t"));
1919        let record = CatalogCommandRecord::next(
1920            &catalog,
1921            CatalogCommand::DropTable {
1922                name: "t".into(),
1923                at_epoch: 7,
1924            },
1925        );
1926        catalog.apply_command(&record).unwrap();
1927        assert!(catalog.live("t").is_none());
1928        assert!(catalog.security.policies.is_empty());
1929        assert!(catalog.security.masks.is_empty());
1930        assert!(!catalog.security.rls_enabled("t"));
1931        assert!(catalog.roles[0].permissions.is_empty());
1932        assert!(matches!(
1933            catalog.tables[0].state,
1934            TableState::Dropped { at_epoch: 7 }
1935        ));
1936    }
1937
1938    #[test]
1939    fn grant_and_revoke_permission_merge_columns() {
1940        let mut catalog = catalog_with_table();
1941        let commands = [
1942            CatalogCommand::CreateRole {
1943                name: "r".into(),
1944                created_epoch: 1,
1945            },
1946            CatalogCommand::GrantPermission {
1947                role: "r".into(),
1948                permission: Permission::SelectColumns {
1949                    table: "t".into(),
1950                    columns: vec!["id".into()],
1951                },
1952            },
1953            CatalogCommand::GrantPermission {
1954                role: "r".into(),
1955                permission: Permission::SelectColumns {
1956                    table: "t".into(),
1957                    columns: vec!["secret".into(), "id".into()],
1958                },
1959            },
1960        ];
1961        for command in commands {
1962            let record = CatalogCommandRecord::next(&catalog, command);
1963            catalog.apply_command(&record).unwrap();
1964        }
1965        assert_eq!(
1966            catalog.roles[0].permissions,
1967            vec![Permission::SelectColumns {
1968                table: "t".into(),
1969                columns: vec!["id".into(), "secret".into()],
1970            }]
1971        );
1972        let record = CatalogCommandRecord::next(
1973            &catalog,
1974            CatalogCommand::RevokePermission {
1975                role: "r".into(),
1976                permission: Permission::SelectColumns {
1977                    table: "t".into(),
1978                    columns: vec!["id".into()],
1979                },
1980            },
1981        );
1982        catalog.apply_command(&record).unwrap();
1983        assert_eq!(
1984            catalog.roles[0].permissions,
1985            vec![Permission::SelectColumns {
1986                table: "t".into(),
1987                columns: vec!["secret".into()],
1988            }]
1989        );
1990    }
1991
1992    #[test]
1993    fn job_state_terminal_guard() {
1994        let mut catalog = Catalog::empty();
1995        let submit = CatalogCommandRecord::next(
1996            &catalog,
1997            CatalogCommand::SubmitJob {
1998                job: JobDefinition {
1999                    job_id: 1,
2000                    kind: JobKind::IndexBuild,
2001                    state: JobState::Pending,
2002                    target: Some("t".into()),
2003                    created_epoch: 1,
2004                    updated_epoch: 1,
2005                },
2006            },
2007        );
2008        catalog.apply_command(&submit).unwrap();
2009        let run = CatalogCommandRecord::next(
2010            &catalog,
2011            CatalogCommand::SetJobState {
2012                job_id: 1,
2013                state: JobState::Succeeded,
2014                at_epoch: 2,
2015            },
2016        );
2017        catalog.apply_command(&run).unwrap();
2018        let late = CatalogCommandRecord::next(
2019            &catalog,
2020            CatalogCommand::SetJobState {
2021                job_id: 1,
2022                state: JobState::Running,
2023                at_epoch: 3,
2024            },
2025        );
2026        assert!(matches!(
2027            catalog.apply_command(&late),
2028            Err(MongrelError::InvalidArgument(_))
2029        ));
2030    }
2031
2032    #[test]
2033    fn history_is_bounded() {
2034        let mut catalog = Catalog::empty();
2035        for index in 0..(COMMAND_HISTORY_LIMIT + 10) {
2036            let record = CatalogCommandRecord::next(
2037                &catalog,
2038                CatalogCommand::SubmitJob {
2039                    job: JobDefinition {
2040                        job_id: index as u64 + 1,
2041                        kind: JobKind::SchemaValidation,
2042                        state: JobState::Pending,
2043                        target: None,
2044                        created_epoch: 0,
2045                        updated_epoch: 0,
2046                    },
2047                },
2048            );
2049            catalog.apply_command(&record).unwrap();
2050        }
2051        assert_eq!(catalog.command_log.len(), COMMAND_HISTORY_LIMIT);
2052        let retained = catalog.commands_since(0);
2053        assert_eq!(retained.len(), COMMAND_HISTORY_LIMIT);
2054        // The compacted prefix is gone; the tail is contiguous.
2055        let oldest = retained[0].catalog_version;
2056        assert_eq!(
2057            oldest,
2058            catalog.catalog_version() - COMMAND_HISTORY_LIMIT as u64 + 1
2059        );
2060        // Replaying a compacted command is treated as already applied.
2061        let compacted = CatalogCommandRecord {
2062            version: CATALOG_COMMAND_FORMAT_VERSION,
2063            catalog_version: 1,
2064            command: CatalogCommand::SubmitJob {
2065                job: JobDefinition {
2066                    job_id: 999_999,
2067                    kind: JobKind::KeyRotation,
2068                    state: JobState::Pending,
2069                    target: None,
2070                    created_epoch: 0,
2071                    updated_epoch: 0,
2072                },
2073            },
2074        };
2075        let delta = catalog.apply_command(&compacted).unwrap();
2076        assert!(matches!(delta, CatalogDelta::NoOp));
2077        assert!(catalog
2078            .job_definitions
2079            .iter()
2080            .all(|job| job.job_id != 999_999));
2081    }
2082
2083    #[test]
2084    fn required_permission_matches_legacy_gates() {
2085        assert_eq!(
2086            required_permission(&CatalogCommand::CreateTable {
2087                name: "t".into(),
2088                schema: test_schema(),
2089                created_epoch: 0,
2090            }),
2091            Permission::Ddl
2092        );
2093        assert_eq!(
2094            required_permission(&CatalogCommand::EnableRls { table: "t".into() }),
2095            Permission::Admin
2096        );
2097        assert_eq!(
2098            required_permission(&CatalogCommand::DropUser {
2099                username: "u".into()
2100            }),
2101            Permission::Admin
2102        );
2103        assert_eq!(
2104            required_permission(&CatalogCommand::DropTrigger { name: "t".into() }),
2105            Permission::Ddl
2106        );
2107    }
2108
2109    fn replacement_trigger(name: &str) -> StoredTrigger {
2110        StoredTrigger::new(
2111            name,
2112            crate::trigger::TriggerDefinition {
2113                target: TriggerTarget::Table("t".into()),
2114                timing: crate::trigger::TriggerTiming::After,
2115                event: crate::trigger::TriggerEvent::Insert,
2116                update_of: Vec::new(),
2117                target_columns: Vec::new(),
2118                when: None,
2119                program: crate::trigger::TriggerProgram { steps: Vec::new() },
2120            },
2121            0,
2122        )
2123        .unwrap()
2124    }
2125
2126    fn replacement_procedure(name: &str) -> StoredProcedure {
2127        StoredProcedure::new(
2128            name,
2129            crate::procedure::ProcedureMode::ReadOnly,
2130            Vec::new(),
2131            crate::procedure::ProcedureBody {
2132                steps: Vec::new(),
2133                return_value: crate::procedure::ProcedureValue::Literal(
2134                    crate::memtable::Value::Null,
2135                ),
2136            },
2137            0,
2138        )
2139        .unwrap()
2140    }
2141
2142    #[test]
2143    fn alter_column_delta_bumps_schema_id_like_the_engine() {
2144        let mut catalog = catalog_with_table();
2145        let mut column = catalog.live("t").unwrap().schema.columns[1].clone();
2146        column.flags = column.flags.with(crate::schema::ColumnFlags::NULLABLE);
2147        let record = CatalogCommandRecord::next(
2148            &catalog,
2149            CatalogCommand::AlterColumn {
2150                table: "t".into(),
2151                column: column.clone(),
2152            },
2153        );
2154        let delta = catalog.apply_command(&record).unwrap();
2155        // The engine bumps `schema_id` on every applied alteration; the
2156        // resolved delta reproduces that image so replay stays deterministic.
2157        let CatalogDelta::SchemaReplaced { table_id, schema } = delta else {
2158            panic!("expected SchemaReplaced");
2159        };
2160        assert_eq!(table_id, 0);
2161        assert_eq!(schema.schema_id, 1);
2162        assert_eq!(schema.columns[1], column);
2163        assert_eq!(catalog.live("t").unwrap().schema.schema_id, 1);
2164        // A column id the table does not have fails closed.
2165        let mut unknown = column;
2166        unknown.id = 99;
2167        assert!(matches!(
2168            apply(
2169                &catalog,
2170                &CatalogCommand::AlterColumn {
2171                    table: "t".into(),
2172                    column: unknown,
2173                },
2174            ),
2175            Err(MongrelError::ColumnNotFound(_))
2176        ));
2177    }
2178
2179    #[test]
2180    fn replace_trigger_upserts_and_validates_references() {
2181        let mut catalog = catalog_with_table();
2182        // Create-or-replace on an absent name inserts...
2183        let record = CatalogCommandRecord::next(
2184            &catalog,
2185            CatalogCommand::ReplaceTrigger {
2186                trigger: replacement_trigger("trg"),
2187            },
2188        );
2189        let delta = catalog.apply_command(&record).unwrap();
2190        assert!(matches!(delta, CatalogDelta::TriggerUpserted(_)));
2191        assert_eq!(catalog.triggers.len(), 1);
2192        // ...and on a present name replaces in place (no duplicate, no
2193        // must-not-exist rejection like CreateTrigger).
2194        let mut resolved = replacement_trigger("trg");
2195        resolved.version = 2;
2196        resolved.updated_epoch = 7;
2197        let record = CatalogCommandRecord::next(
2198            &catalog,
2199            CatalogCommand::ReplaceTrigger {
2200                trigger: resolved.clone(),
2201            },
2202        );
2203        catalog.apply_command(&record).unwrap();
2204        assert_eq!(catalog.triggers.len(), 1);
2205        assert_eq!(catalog.triggers[0].trigger.version, 2);
2206        assert_eq!(catalog.triggers[0].trigger.updated_epoch, 7);
2207        // A trigger targeting an unknown table fails closed.
2208        let mut dangling = replacement_trigger("trg2");
2209        dangling.target = TriggerTarget::Table("missing".into());
2210        assert!(matches!(
2211            apply(
2212                &catalog,
2213                &CatalogCommand::ReplaceTrigger { trigger: dangling }
2214            ),
2215            Err(MongrelError::InvalidArgument(_))
2216        ));
2217        assert_eq!(
2218            required_permission(&CatalogCommand::ReplaceTrigger {
2219                trigger: replacement_trigger("trg"),
2220            }),
2221            Permission::Ddl
2222        );
2223    }
2224
2225    #[test]
2226    fn replace_procedure_upserts_and_validates_references() {
2227        let mut catalog = catalog_with_table();
2228        let record = CatalogCommandRecord::next(
2229            &catalog,
2230            CatalogCommand::ReplaceProcedure {
2231                procedure: replacement_procedure("proc"),
2232            },
2233        );
2234        let delta = catalog.apply_command(&record).unwrap();
2235        assert!(matches!(delta, CatalogDelta::ProcedureUpserted(_)));
2236        assert_eq!(catalog.procedures.len(), 1);
2237        // Replacement is keyed by name and replays the resolved image
2238        // verbatim (no must-not-exist rejection like CreateProcedure).
2239        let mut resolved = replacement_procedure("proc");
2240        resolved.version = 3;
2241        resolved.updated_epoch = 9;
2242        let record = CatalogCommandRecord::next(
2243            &catalog,
2244            CatalogCommand::ReplaceProcedure {
2245                procedure: resolved.clone(),
2246            },
2247        );
2248        catalog.apply_command(&record).unwrap();
2249        assert_eq!(catalog.procedures.len(), 1);
2250        assert_eq!(catalog.procedures[0].procedure.version, 3);
2251        assert_eq!(catalog.procedures[0].procedure.updated_epoch, 9);
2252        assert_eq!(
2253            required_permission(&CatalogCommand::ReplaceProcedure {
2254                procedure: replacement_procedure("proc"),
2255            }),
2256            Permission::Ddl
2257        );
2258    }
2259
2260    #[test]
2261    fn serde_appended_variants_decode_older_records() {
2262        // Records encoded before ReplaceTrigger/ReplaceProcedure existed
2263        // decode unchanged (the new variants are appended, never renumbered).
2264        let legacy = br#"{"version":1,"catalog_version":1,"command":{"DropTrigger":{"name":"t"}}}"#;
2265        let record = decode_command(legacy).unwrap();
2266        assert!(matches!(
2267            record.command,
2268            CatalogCommand::DropTrigger { ref name } if name == "t"
2269        ));
2270        // The new variants round-trip through the versioned envelope.
2271        let record = CatalogCommandRecord {
2272            version: CATALOG_COMMAND_FORMAT_VERSION,
2273            catalog_version: 2,
2274            command: CatalogCommand::ReplaceTrigger {
2275                trigger: replacement_trigger("trg"),
2276            },
2277        };
2278        let bytes = encode_command(&record).unwrap();
2279        let decoded = decode_command(&bytes).unwrap();
2280        assert_eq!(encode_command(&decoded).unwrap(), bytes);
2281    }
2282}