lib/sql/src/catalog.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const simd = @import("simd");
   3 const file = @import("file.zig");
   4 const index_mod = @import("index.zig");
   5 const key = @import("key.zig");
   6 const page = @import("page.zig");
   7 const relation_mod = @import("relation.zig");
   8 const row = @import("row.zig");
   9 const space_mod = @import("space.zig");
  10 const table = @import("table.zig");
  11 const trace = @import("trace.zig");
  12 const tree = @import("tree.zig");
  13 const wal = @import("wal.zig");
  14 
  15 const Bytes = simd.ScalableTag(u8);
  16 
  17 const Allocator = std.mem.Allocator;
  18 
  19 const CatalogError = error{
  20     CatalogCorrupt,
  21     ObjectExists,
  22     RelationNotFound,
  23     SchemaVersionOverflow,
  24     TooManyColumns,
  25     UnsupportedCatalogFormat,
  26 };
  27 
  28 pub const Error = relation_mod.Error || table.Error || CatalogError;
  29 
  30 pub const default_meta_page: u32 = 1;
  31 pub const default_root_page: u32 = 2;
  32 pub const format_version: u32 = 1;
  33 
  34 const schema_kind: i64 = 0;
  35 const relation_kind: i64 = 1;
  36 const index_kind: i64 = 2;
  37 const stats_kind: i64 = 3;
  38 const schema_rowid: i64 = 0;
  39 const first_object_rowid: i64 = 1;
  40 const schema_row_columns: usize = 6;
  41 const catalog_row_columns: usize = 7;
  42 const schema_blob_bytes = 12;
  43 const summary_blob_bytes = 80;
  44 const distribution_header_bytes = 6;
  45 const distribution_prefix_header_bytes = 20;
  46 const distribution_sample_header_bytes = 26;
  47 const field_bytes = 4;
  48 const column_name_len_bytes = 2;
  49 const default_len_bytes = 2;
  50 const schema_name = "schema";
  51 const distribution_format: u32 = 3;
  52 
  53 pub const max_columns: usize = 64;
  54 pub const max_column_name_bytes: usize = page.size;
  55 pub const max_column_default_bytes: usize = page.size;
  56 pub const max_index_distribution_samples: usize = 32;
  57 
  58 pub const Options = struct {
  59     meta_page: u32 = default_meta_page,
  60     root_page: u32 = default_root_page,
  61 };
  62 
  63 pub const Schema = struct {
  64     format: u32 = format_version,
  65     version: u64 = 0,
  66 };
  67 
  68 pub const Commit = struct {
  69     storage: file.Commit,
  70     schema: Schema,
  71 };
  72 
  73 pub const IndexDefinition = struct {
  74     name: []const u8,
  75     fields: []const usize,
  76     columns: []const row.Column = &.{},
  77 };
  78 
  79 pub const ColumnDefinition = struct {
  80     name: []const u8,
  81     column: row.Column = .{},
  82     default: row.Value = .nil,
  83 };
  84 
  85 pub const RelationDefinition = struct {
  86     name: []const u8,
  87     columns: []const ColumnDefinition = &.{},
  88     indexes: []const IndexDefinition = &.{},
  89 };
  90 
  91 fn RelationHandleType(comptime Relation: type) type {
  92     return struct {
  93         allocator: Allocator,
  94         relation: Relation,
  95         specs: []relation_mod.IndexSpec,
  96         index_definitions: []IndexDefinition,
  97         fields: []usize,
  98         columns: []row.Column,
  99         definitions: []ColumnDefinition,
 100         index_names: []u8,
 101         names: []u8,
 102         defaults: []u8,
 103 
 104         pub fn deinit(self: *@This()) void {
 105             self.allocator.free(self.defaults);
 106             self.allocator.free(self.names);
 107             self.allocator.free(self.index_names);
 108             self.allocator.free(self.definitions);
 109             self.allocator.free(self.columns);
 110             self.allocator.free(self.fields);
 111             self.allocator.free(self.index_definitions);
 112             self.allocator.free(self.specs);
 113             self.* = undefined;
 114         }
 115     };
 116 }
 117 
 118 pub const RelationHandle = RelationHandleType(relation_mod.Relation);
 119 pub const ReadRelationHandle = RelationHandleType(relation_mod.Reader);
 120 
 121 pub const IndexStats = struct {
 122     name: []u8,
 123     root_page: u32,
 124     summary: tree.Summary,
 125     distribution: IndexDistribution = .{},
 126 };
 127 
 128 pub const IndexDistribution = struct {
 129     distinct_values: usize = 0,
 130     max_equal: usize = 0,
 131     samples: []IndexSample = &.{},
 132     sample_keys: []u8 = &.{},
 133     prefixes: []IndexPrefixDistribution = &.{},
 134 };
 135 
 136 pub const IndexPrefixDistribution = struct {
 137     field_count: usize = 0,
 138     distinct_values: usize = 0,
 139     max_equal: usize = 0,
 140     samples: []IndexSample = &.{},
 141     sample_keys: []u8 = &.{},
 142 };
 143 
 144 pub const IndexSample = struct {
 145     key: []const u8,
 146     less_than: usize,
 147     equal_count: usize,
 148     less_distinct: usize,
 149 };
 150 
 151 pub const RelationStats = struct {
 152     allocator: Allocator,
 153     table_root_page: u32,
 154     table: tree.Summary,
 155     indexes: []IndexStats,
 156 
 157     pub fn deinit(self: *RelationStats) void {
 158         for (self.indexes) |*index_stats| {
 159             self.allocator.free(index_stats.name);
 160             freeIndexDistribution(self.allocator, index_stats.distribution);
 161         }
 162         self.allocator.free(self.indexes);
 163         self.* = undefined;
 164     }
 165 
 166     pub fn index(self: *const RelationStats, name: []const u8) ?*const IndexStats {
 167         for (self.indexes) |*index_stats| {
 168             if (std.mem.eql(u8, index_stats.name, name)) return index_stats;
 169         }
 170         return null;
 171     }
 172 };
 173 
 174 pub const PreparedRelationStats = struct {
 175     allocator: Allocator,
 176     stats: RelationStats,
 177     distribution_blobs: [][]u8,
 178 
 179     pub fn deinit(self: *PreparedRelationStats) void {
 180         for (self.distribution_blobs) |bytes| {
 181             if (bytes.len != 0) self.allocator.free(bytes);
 182         }
 183         self.allocator.free(self.distribution_blobs);
 184         self.stats.deinit();
 185         self.* = undefined;
 186     }
 187 };
 188 
 189 const PreparedIndexStats = struct {
 190     stats: IndexStats,
 191     distribution_blob: []u8,
 192 };
 193 
 194 pub const MaterializedRelation = struct {
 195     relation: relation_mod.Relation,
 196     table_root: space_mod.RootSpec,
 197     index_roots: [relation_mod.max_indexes]space_mod.RootSpec = undefined,
 198     index_count: usize = 0,
 199     table_stats_rowid: ?i64 = null,
 200     index_stats_rowids: [relation_mod.max_indexes]i64 = undefined,
 201 };
 202 
 203 pub const Materialization = struct {
 204     allocator: Allocator,
 205     catalog: *const Catalog,
 206     roots: [space_mod.max_roots]space_mod.RootSpec,
 207     root_count: usize,
 208     schema: table.Table,
 209     write: tree.Write,
 210     current_schema: Schema,
 211     next_rowid: i64,
 212     schema_dirty: bool = false,
 213     dropped_relations: std.ArrayList([]u8) = .empty,
 214     created_objects: std.ArrayList([]u8) = .empty,
 215 
 216     pub fn deinit(self: *Materialization) void {
 217         for (self.created_objects.items) |name| self.allocator.free(name);
 218         self.created_objects.deinit(self.allocator);
 219         for (self.dropped_relations.items) |name| self.allocator.free(name);
 220         self.dropped_relations.deinit(self.allocator);
 221         self.write.deinit();
 222         self.* = undefined;
 223     }
 224 
 225     pub fn treeWrite(self: *Materialization) *tree.Write {
 226         return &self.write;
 227     }
 228 
 229     pub fn dropRelation(self: *Materialization, name: []const u8) Error!void {
 230         if (self.relationDropped(name)) return error.RelationNotFound;
 231         const owned_name = try self.allocator.dupe(u8, name);
 232         errdefer self.allocator.free(owned_name);
 233         try self.dropped_relations.ensureUnusedCapacity(self.allocator, 1);
 234 
 235         var found = false;
 236         var state = CatalogState{};
 237         var scan: table.Scan = undefined;
 238         try self.catalog.schema.scan(&scan, self.allocator, null, null);
 239         defer scan.deinit();
 240         while (try scan.next()) |entry| {
 241             var scratch = CatalogScratch{};
 242             switch (try state.record(entry, &scratch)) {
 243                 .schema => {},
 244                 .object => |object| {
 245                     if (std.mem.eql(u8, object.table_name, name)) {
 246                         if (object.kind == .relation) found = true;
 247                         try self.schema.deleteIn(&self.write, entry.rowid);
 248                         self.removeRoot(object.root_page);
 249                     }
 250                 },
 251                 .stats => |stats| {
 252                     if (std.mem.eql(u8, stats.table_name, name)) {
 253                         try self.schema.deleteIn(&self.write, entry.rowid);
 254                     }
 255                 },
 256             }
 257         }
 258         _ = try state.schemaOrDefault();
 259         if (!found) return error.RelationNotFound;
 260         self.dropped_relations.appendAssumeCapacity(owned_name);
 261         self.schema_dirty = true;
 262     }
 263 
 264     pub fn clearRelationStats(self: *Materialization, name: []const u8) Error!void {
 265         var found = false;
 266         var state = CatalogState{};
 267         var scan: table.Scan = undefined;
 268         try self.catalog.schema.scan(&scan, self.allocator, null, null);
 269         defer scan.deinit();
 270         while (try scan.next()) |entry| {
 271             var scratch = CatalogScratch{};
 272             switch (try state.record(entry, &scratch)) {
 273                 .schema => {},
 274                 .object => |object| {
 275                     if (object.kind == .relation and
 276                         std.mem.eql(u8, object.name, name))
 277                     {
 278                         found = true;
 279                     }
 280                 },
 281                 .stats => |stats| {
 282                     if (std.mem.eql(u8, stats.table_name, name)) {
 283                         try self.schema.deleteIn(&self.write, entry.rowid);
 284                     }
 285                 },
 286             }
 287         }
 288         _ = try state.schemaOrDefault();
 289         if (!found) return error.RelationNotFound;
 290     }
 291 
 292     pub fn createRelation(
 293         self: *Materialization,
 294         definition: RelationDefinition,
 295         prepared_stats: ?*const PreparedRelationStats,
 296     ) Error!MaterializedRelation {
 297         try self.prepareRelationCreation(definition, prepared_stats);
 298         var materialized = try self.allocateMaterializedRelation(definition);
 299         try self.writeRelationCatalog(definition, &materialized, prepared_stats);
 300         materialized.relation = try self.openMaterializedRelation(
 301             definition,
 302             &materialized,
 303         );
 304         self.schema_dirty = true;
 305         return materialized;
 306     }
 307 
 308     fn prepareRelationCreation(
 309         self: *Materialization,
 310         definition: RelationDefinition,
 311         prepared_stats: ?*const PreparedRelationStats,
 312     ) Error!void {
 313         try validateDefinition(definition);
 314         if (self.root_count + 1 + definition.indexes.len > self.roots.len) {
 315             return error.TooManyRoots;
 316         }
 317         const stats_rows = if (prepared_stats == null)
 318             @as(usize, 0)
 319         else
 320             1 + definition.indexes.len;
 321         const catalog_rows = 1 + definition.indexes.len + stats_rows;
 322         _ = std.math.add(
 323             i64,
 324             self.next_rowid,
 325             @intCast(catalog_rows),
 326         ) catch return error.CatalogCorrupt;
 327         try self.ensureObjectNameAvailable(definition.name);
 328         for (definition.indexes) |index_definition| {
 329             try self.ensureObjectNameAvailable(index_definition.name);
 330         }
 331         if (prepared_stats) |prepared| try validatePreparedStats(definition, prepared);
 332 
 333         try self.retainCreatedObject(definition.name);
 334         for (definition.indexes) |index_definition| {
 335             try self.retainCreatedObject(index_definition.name);
 336         }
 337     }
 338 
 339     fn allocateMaterializedRelation(
 340         self: *Materialization,
 341         definition: RelationDefinition,
 342     ) Error!MaterializedRelation {
 343         const table_root = try allocateCatalogRoot(&self.write, &self.roots, &self.root_count);
 344         var index_roots: [relation_mod.max_indexes]space_mod.RootSpec = undefined;
 345         for (definition.indexes, 0..) |_, index_offset| {
 346             index_roots[index_offset] = try allocateCatalogRoot(
 347                 &self.write,
 348                 &self.roots,
 349                 &self.root_count,
 350             );
 351         }
 352         return .{
 353             .relation = undefined,
 354             .table_root = table_root,
 355             .index_roots = index_roots,
 356             .index_count = definition.indexes.len,
 357         };
 358     }
 359 
 360     fn writeRelationCatalog(
 361         self: *Materialization,
 362         definition: RelationDefinition,
 363         materialized: *MaterializedRelation,
 364         prepared_stats: ?*const PreparedRelationStats,
 365     ) Error!void {
 366         var definitions_buffer: [page.size]u8 = undefined;
 367         const definitions = try encodeDefinitions(&definitions_buffer, definition.columns);
 368         try putCatalogRow(
 369             &self.schema,
 370             &self.write,
 371             self.takeRowid(),
 372             relation_kind,
 373             definition.name,
 374             definition.name,
 375             materialized.table_root.root_page,
 376             materialized.table_root.identity_page,
 377             definitions,
 378             "",
 379         );
 380         try self.writeIndexCatalog(definition, materialized);
 381         if (prepared_stats) |prepared| {
 382             try self.writePreparedStatsCatalog(definition, materialized, prepared);
 383         }
 384     }
 385 
 386     fn writeIndexCatalog(
 387         self: *Materialization,
 388         definition: RelationDefinition,
 389         materialized: *const MaterializedRelation,
 390     ) Error!void {
 391         for (
 392             definition.indexes,
 393             materialized.index_roots[0..materialized.index_count],
 394         ) |index_definition, index_root| {
 395             var fields_buffer: [relation_mod.max_index_fields * field_bytes]u8 = undefined;
 396             var collations_buffer: [relation_mod.max_index_fields]u8 = undefined;
 397             const fields = try encodeFields(&fields_buffer, index_definition.fields);
 398             const collations = try encodeCollations(
 399                 &collations_buffer,
 400                 index_definition.fields.len,
 401                 index_definition.columns,
 402             );
 403             try putCatalogRow(
 404                 &self.schema,
 405                 &self.write,
 406                 self.takeRowid(),
 407                 index_kind,
 408                 index_definition.name,
 409                 definition.name,
 410                 index_root.root_page,
 411                 index_root.identity_page,
 412                 fields,
 413                 collations,
 414             );
 415         }
 416     }
 417 
 418     fn writePreparedStatsCatalog(
 419         self: *Materialization,
 420         definition: RelationDefinition,
 421         materialized: *MaterializedRelation,
 422         prepared: *const PreparedRelationStats,
 423     ) Error!void {
 424         var summary_buffer: [summary_blob_bytes]u8 = undefined;
 425         materialized.table_stats_rowid = self.takeRowid();
 426         try putCatalogRow(
 427             &self.schema,
 428             &self.write,
 429             materialized.table_stats_rowid.?,
 430             stats_kind,
 431             definition.name,
 432             definition.name,
 433             materialized.table_root.root_page,
 434             0,
 435             try encodeSummary(&summary_buffer, prepared.stats.table),
 436             "",
 437         );
 438         for (
 439             definition.indexes,
 440             materialized.index_roots[0..materialized.index_count],
 441             prepared.stats.indexes,
 442             prepared.distribution_blobs,
 443             0..,
 444         ) |index_definition, index_root, index_stats, distribution_blob, index_offset| {
 445             materialized.index_stats_rowids[index_offset] = self.takeRowid();
 446             try putCatalogRow(
 447                 &self.schema,
 448                 &self.write,
 449                 materialized.index_stats_rowids[index_offset],
 450                 stats_kind,
 451                 index_definition.name,
 452                 definition.name,
 453                 index_root.root_page,
 454                 0,
 455                 try encodeSummary(&summary_buffer, index_stats.summary),
 456                 distribution_blob,
 457             );
 458         }
 459     }
 460 
 461     fn openMaterializedRelation(
 462         self: *Materialization,
 463         definition: RelationDefinition,
 464         materialized: *const MaterializedRelation,
 465     ) Error!relation_mod.Relation {
 466         const relation_space = try space_mod.Space.open(self.catalog.database, .{
 467             .meta_page = self.catalog.meta_page,
 468             .roots = self.roots[0..self.root_count],
 469             .reserved_page_max = self.write.reserved_page_max,
 470         });
 471         var specs: [relation_mod.max_indexes]relation_mod.IndexSpec = undefined;
 472         for (
 473             definition.indexes,
 474             materialized.index_roots[0..materialized.index_count],
 475             specs[0..definition.indexes.len],
 476         ) |index_definition, index_root, *spec| {
 477             spec.* = .{
 478                 .root_page = index_root.root_page,
 479                 .fields = index_definition.fields,
 480                 .columns = index_definition.columns,
 481             };
 482         }
 483         return try relation_mod.Relation.open(&relation_space, .{
 484             .table_root = materialized.table_root.root_page,
 485             .indexes = specs[0..definition.indexes.len],
 486         });
 487     }
 488 
 489     pub fn refreshRelationStats(
 490         self: *Materialization,
 491         definition: RelationDefinition,
 492         materialized: *const MaterializedRelation,
 493         prepared: *const PreparedRelationStats,
 494     ) Error!void {
 495         try validatePreparedStats(definition, prepared);
 496         if (materialized.index_count != definition.indexes.len) return error.CatalogCorrupt;
 497         const table_rowid = materialized.table_stats_rowid orelse return error.CatalogCorrupt;
 498         const table_summary = try materialized.relation.table.summarizeIn(&self.write);
 499         if (!sameLogicalSummary(table_summary, prepared.stats.table)) return error.CatalogCorrupt;
 500 
 501         var summary_buffer: [summary_blob_bytes]u8 = undefined;
 502         try putCatalogRow(
 503             &self.schema,
 504             &self.write,
 505             table_rowid,
 506             stats_kind,
 507             definition.name,
 508             definition.name,
 509             materialized.table_root.root_page,
 510             0,
 511             try encodeSummary(&summary_buffer, table_summary),
 512             "",
 513         );
 514 
 515         for (
 516             definition.indexes,
 517             materialized.index_roots[0..materialized.index_count],
 518             prepared.stats.indexes,
 519             prepared.distribution_blobs,
 520             0..,
 521         ) |index_definition, index_root, index_stats, distribution_blob, index_offset| {
 522             var relation_index = try materialized.relation.space.index(
 523                 index_root.root_page,
 524                 index_definition.columns,
 525             );
 526             const index_summary = try relation_index.summarizeIn(&self.write);
 527             if (!sameLogicalSummary(index_summary, index_stats.summary)) {
 528                 return error.CatalogCorrupt;
 529             }
 530             try putCatalogRow(
 531                 &self.schema,
 532                 &self.write,
 533                 materialized.index_stats_rowids[index_offset],
 534                 stats_kind,
 535                 index_definition.name,
 536                 definition.name,
 537                 index_root.root_page,
 538                 0,
 539                 try encodeSummary(&summary_buffer, index_summary),
 540                 distribution_blob,
 541             );
 542         }
 543     }
 544 
 545     pub fn commit(self: *Materialization, options: file.CommitOptions) Error!Commit {
 546         const next_schema = if (self.schema_dirty)
 547             try bumpSchemaVersion(self.current_schema)
 548         else
 549             self.current_schema;
 550         if (self.schema_dirty) try putSchemaRow(&self.schema, &self.write, next_schema);
 551         return .{
 552             .storage = try self.write.commit(options),
 553             .schema = next_schema,
 554         };
 555     }
 556 
 557     fn relationDropped(self: *const Materialization, name: []const u8) bool {
 558         for (self.dropped_relations.items) |dropped| {
 559             if (std.mem.eql(u8, dropped, name)) return true;
 560         }
 561         return false;
 562     }
 563 
 564     fn objectCreated(self: *const Materialization, name: []const u8) bool {
 565         for (self.created_objects.items) |created| {
 566             if (std.mem.eql(u8, created, name)) return true;
 567         }
 568         return false;
 569     }
 570 
 571     fn ensureObjectNameAvailable(self: *const Materialization, name: []const u8) Error!void {
 572         if (self.objectCreated(name)) return error.ObjectExists;
 573         var state = CatalogState{};
 574         var scan: table.Scan = undefined;
 575         try self.catalog.schema.scan(&scan, self.allocator, null, null);
 576         defer scan.deinit();
 577         while (try scan.next()) |entry| {
 578             var scratch = CatalogScratch{};
 579             switch (try state.record(entry, &scratch)) {
 580                 .schema, .stats => {},
 581                 .object => |object| {
 582                     if (std.mem.eql(u8, object.name, name) and
 583                         !self.relationDropped(object.table_name))
 584                     {
 585                         return error.ObjectExists;
 586                     }
 587                 },
 588             }
 589         }
 590         _ = try state.schemaOrDefault();
 591     }
 592 
 593     fn retainCreatedObject(self: *Materialization, name: []const u8) Error!void {
 594         const owned_name = try self.allocator.dupe(u8, name);
 595         errdefer self.allocator.free(owned_name);
 596         try self.created_objects.append(self.allocator, owned_name);
 597     }
 598 
 599     fn removeRoot(self: *Materialization, root_page: u32) void {
 600         var index: usize = 0;
 601         while (index < self.root_count) : (index += 1) {
 602             if (self.roots[index].root_page != root_page) continue;
 603             self.root_count -= 1;
 604             self.roots[index] = self.roots[self.root_count];
 605             return;
 606         }
 607         unreachable;
 608     }
 609 
 610     fn takeRowid(self: *Materialization) i64 {
 611         const rowid = self.next_rowid;
 612         self.next_rowid += 1;
 613         return rowid;
 614     }
 615 };
 616 
 617 pub const RelationNames = struct {
 618     allocator: Allocator,
 619     names: [][]u8,
 620 
 621     pub fn deinit(self: *RelationNames) void {
 622         for (self.names) |name| self.allocator.free(name);
 623         self.allocator.free(self.names);
 624         self.* = undefined;
 625     }
 626 };
 627 
 628 pub const Reader = struct {
 629     snapshot: file.Snapshot,
 630     meta_page: u32,
 631     root_page: u32,
 632     schema: table.Reader,
 633 
 634     pub fn open(snapshot: file.Snapshot, options: Options) Error!Reader {
 635         try validateOptions(options);
 636         return .{
 637             .snapshot = snapshot,
 638             .meta_page = options.meta_page,
 639             .root_page = options.root_page,
 640             .schema = try table.Reader.open(snapshot, .{
 641                 .tree = catalogTreeOptions(options),
 642             }),
 643         };
 644     }
 645 
 646     pub fn schemaState(self: *const Reader, allocator: Allocator) Error!Schema {
 647         return try readSchemaState(&self.schema, allocator);
 648     }
 649 
 650     pub fn openRelation(
 651         self: *const Reader,
 652         allocator: Allocator,
 653         name: []const u8,
 654     ) Error!ReadRelationHandle {
 655         return try openRelationFrom(
 656             ReadRelationHandle,
 657             space_mod.Reader,
 658             relation_mod.Reader,
 659             self.snapshot,
 660             &self.schema,
 661             self.meta_page,
 662             self.root_page,
 663             allocator,
 664             name,
 665         );
 666     }
 667 
 668     pub fn relationNames(self: *const Reader, allocator: Allocator) Error!RelationNames {
 669         return try readRelationNames(&self.schema, allocator);
 670     }
 671 
 672     pub fn relationStats(
 673         self: *const Reader,
 674         allocator: Allocator,
 675         name: []const u8,
 676     ) Error!?RelationStats {
 677         return try readRelationStats(&self.schema, allocator, name);
 678     }
 679 
 680     /// Returns what `schemaState`, `openRelation` and `relationStats` return
 681     /// for `name`, read in one catalog pass.
 682     pub fn readRelation(
 683         self: *const Reader,
 684         allocator: Allocator,
 685         name: []const u8,
 686     ) Error!ReadRelationState {
 687         return try readRelationFrom(
 688             ReadRelationHandle,
 689             space_mod.Reader,
 690             relation_mod.Reader,
 691             self.snapshot,
 692             &self.schema,
 693             self.meta_page,
 694             self.root_page,
 695             allocator,
 696             name,
 697         );
 698     }
 699 };
 700 
 701 pub const Catalog = struct {
 702     database: *file.Database,
 703     meta_page: u32,
 704     root_page: u32,
 705     schema: table.Table,
 706 
 707     pub fn open(database: *file.Database, options: Options) Error!Catalog {
 708         try validateOptions(options);
 709         return .{
 710             .database = database,
 711             .meta_page = options.meta_page,
 712             .root_page = options.root_page,
 713             .schema = try table.Table.open(database, .{
 714                 .tree = catalogTreeOptions(options),
 715             }),
 716         };
 717     }
 718 
 719     pub fn schemaState(self: *const Catalog, allocator: Allocator) Error!Schema {
 720         return try readSchemaState(&self.schema, allocator);
 721     }
 722 
 723     pub fn beginMaterialization(self: *const Catalog, allocator: Allocator) Error!Materialization {
 724         var roots: [space_mod.max_roots]space_mod.RootSpec = undefined;
 725         var root_count: usize = 0;
 726         try addRoot(&roots, &root_count, .{ .root_page = self.root_page });
 727 
 728         var state = CatalogState{};
 729         var scan: table.Scan = undefined;
 730         try self.schema.scan(&scan, allocator, null, null);
 731         defer scan.deinit();
 732         while (try scan.next()) |entry| {
 733             var scratch = CatalogScratch{};
 734             switch (try state.record(entry, &scratch)) {
 735                 .schema, .stats => {},
 736                 .object => |object| try addRoot(&roots, &root_count, .{
 737                     .root_page = object.root_page,
 738                     .identity_page = object.identity_page,
 739                 }),
 740             }
 741         }
 742         const current_schema = try state.schemaOrDefault();
 743         if (state.max_rowid == std.math.maxInt(i64)) return error.CatalogCorrupt;
 744 
 745         const space = try space_mod.Space.open(self.database, .{
 746             .meta_page = self.meta_page,
 747             .roots = roots[0..root_count],
 748         });
 749         var write = try space.beginWrite();
 750         errdefer write.deinit();
 751         return .{
 752             .allocator = allocator,
 753             .catalog = self,
 754             .roots = roots,
 755             .root_count = root_count,
 756             .schema = try space.rowidTable(self.root_page),
 757             .write = write,
 758             .current_schema = current_schema,
 759             .next_rowid = @max(state.max_rowid + 1, first_object_rowid),
 760         };
 761     }
 762 
 763     pub fn prepareRelationStats(
 764         _: *const Catalog,
 765         allocator: Allocator,
 766         definition: RelationDefinition,
 767         puts: []const relation_mod.Edit.Put,
 768         table_summary: tree.Summary,
 769         index_summaries: []const tree.Summary,
 770     ) Error!PreparedRelationStats {
 771         try validateDefinition(definition);
 772         if (definition.indexes.len != index_summaries.len) return error.CatalogCorrupt;
 773 
 774         var previous_rowid: ?i64 = null;
 775         for (puts) |put| {
 776             _ = try row.View.init(put.bytes);
 777             if (previous_rowid) |previous| {
 778                 if (put.rowid <= previous) return error.CatalogCorrupt;
 779             }
 780             previous_rowid = put.rowid;
 781         }
 782 
 783         const indexes = try allocator.alloc(IndexStats, definition.indexes.len);
 784         var index_count: usize = 0;
 785         errdefer {
 786             for (indexes[0..index_count]) |*index_stats| {
 787                 allocator.free(index_stats.name);
 788                 freeIndexDistribution(allocator, index_stats.distribution);
 789             }
 790             allocator.free(indexes);
 791         }
 792 
 793         const distribution_blobs = try allocator.alloc([]u8, definition.indexes.len);
 794         var blob_count: usize = 0;
 795         errdefer {
 796             for (distribution_blobs[0..blob_count]) |bytes| {
 797                 if (bytes.len != 0) allocator.free(bytes);
 798             }
 799             allocator.free(distribution_blobs);
 800         }
 801 
 802         for (
 803             definition.indexes,
 804             index_summaries,
 805             indexes,
 806             distribution_blobs,
 807             0..,
 808         ) |index_definition, summary, *index_stats, *distribution_blob, index_offset| {
 809             const prepared = try prepareIndexStats(
 810                 allocator,
 811                 index_definition,
 812                 puts,
 813                 summary,
 814                 index_offset,
 815             );
 816             index_stats.* = prepared.stats;
 817             distribution_blob.* = prepared.distribution_blob;
 818             index_count += 1;
 819             blob_count += 1;
 820         }
 821 
 822         return .{
 823             .allocator = allocator,
 824             .stats = .{
 825                 .allocator = allocator,
 826                 .table_root_page = 0,
 827                 .table = table_summary,
 828                 .indexes = indexes,
 829             },
 830             .distribution_blobs = distribution_blobs,
 831         };
 832     }
 833 
 834     pub fn createRelation(self: *const Catalog, allocator: Allocator, definition: RelationDefinition, options: file.CommitOptions) Error!Commit {
 835         const phase = trace.scope("catalog.create_relation");
 836         defer phase.end();
 837 
 838         try validateDefinition(definition);
 839 
 840         var roots: [space_mod.max_roots]space_mod.RootSpec = undefined;
 841         var root_count: usize = 0;
 842         try addRoot(&roots, &root_count, .{ .root_page = self.root_page });
 843 
 844         var state = CatalogState{};
 845         {
 846             var scan: table.Scan = undefined;
 847             try self.schema.scan(&scan, allocator, null, null);
 848             defer scan.deinit();
 849             while (try scan.next()) |entry| {
 850                 var scratch = CatalogScratch{};
 851                 switch (try state.record(entry, &scratch)) {
 852                     .schema => {},
 853                     .object => |object| {
 854                         if (std.mem.eql(u8, object.name, definition.name)) return error.ObjectExists;
 855                         for (definition.indexes) |index_definition| {
 856                             if (std.mem.eql(u8, object.name, index_definition.name)) return error.ObjectExists;
 857                         }
 858                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
 859                     },
 860                     .stats => {},
 861                 }
 862             }
 863         }
 864         const next_schema = try bumpSchemaVersion(try state.schemaOrDefault());
 865 
 866         const space = try space_mod.Space.open(self.database, .{
 867             .meta_page = self.meta_page,
 868             .roots = roots[0..root_count],
 869         });
 870         var schema = try space.rowidTable(self.root_page);
 871         var write = try space.beginWrite();
 872         defer write.deinit();
 873 
 874         const table_root = try allocateCatalogRoot(&write, &roots, &root_count);
 875         var index_roots: [relation_mod.max_indexes]space_mod.RootSpec = undefined;
 876         for (definition.indexes, 0..) |_, index_offset| {
 877             index_roots[index_offset] = try allocateCatalogRoot(&write, &roots, &root_count);
 878         }
 879 
 880         try putSchemaRow(&schema, &write, next_schema);
 881 
 882         var definitions_buffer: [page.size]u8 = undefined;
 883         const definitions = try encodeDefinitions(&definitions_buffer, definition.columns);
 884         var next_rowid = @max(state.max_rowid + 1, first_object_rowid);
 885         try putCatalogRow(&schema, &write, next_rowid, relation_kind, definition.name, definition.name, table_root.root_page, table_root.identity_page, definitions, "");
 886         next_rowid += 1;
 887 
 888         for (definition.indexes, 0..) |index_definition, index_offset| {
 889             var fields_buffer: [relation_mod.max_index_fields * field_bytes]u8 = undefined;
 890             var collations_buffer: [relation_mod.max_index_fields]u8 = undefined;
 891             const fields = try encodeFields(&fields_buffer, index_definition.fields);
 892             const collations = try encodeCollations(&collations_buffer, index_definition.fields.len, index_definition.columns);
 893             try putCatalogRow(
 894                 &schema,
 895                 &write,
 896                 next_rowid,
 897                 index_kind,
 898                 index_definition.name,
 899                 definition.name,
 900                 index_roots[index_offset].root_page,
 901                 index_roots[index_offset].identity_page,
 902                 fields,
 903                 collations,
 904             );
 905             next_rowid += 1;
 906         }
 907 
 908         return .{
 909             .storage = try write.commit(options),
 910             .schema = next_schema,
 911         };
 912     }
 913 
 914     pub fn createIndex(self: *const Catalog, allocator: Allocator, table_name: []const u8, definition: IndexDefinition, options: file.CommitOptions) Error!Commit {
 915         const phase = trace.scope("catalog.create_index");
 916         defer phase.end();
 917 
 918         if (definition.name.len == 0) return error.CatalogCorrupt;
 919         if (definition.fields.len == 0 or definition.fields.len > relation_mod.max_index_fields) return error.TooManyIndexFields;
 920         if (definition.columns.len > definition.fields.len) return error.CatalogCorrupt;
 921 
 922         var roots: [space_mod.max_roots]space_mod.RootSpec = undefined;
 923         var root_count: usize = 0;
 924         try addRoot(&roots, &root_count, .{ .root_page = self.root_page });
 925 
 926         var table_root: ?u32 = null;
 927         var table_definitions: [max_columns]ColumnDefinition = undefined;
 928         var table_names: [max_column_name_bytes]u8 = undefined;
 929         var table_defaults: [max_column_default_bytes]u8 = undefined;
 930         var table_definition_count: usize = 0;
 931         var table_index_count: usize = 0;
 932         var old_stats: std.ArrayList(i64) = .empty;
 933         defer old_stats.deinit(allocator);
 934         var state = CatalogState{};
 935         var scan: table.Scan = undefined;
 936         try self.schema.scan(&scan, allocator, null, null);
 937         defer scan.deinit();
 938         while (try scan.next()) |entry| {
 939             var scratch = CatalogScratch{};
 940             switch (try state.record(entry, &scratch)) {
 941                 .schema => {},
 942                 .object => |object| switch (object.kind) {
 943                     .relation => {
 944                         if (std.mem.eql(u8, object.name, definition.name)) return error.ObjectExists;
 945                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
 946                         if (std.mem.eql(u8, object.name, table_name)) {
 947                             if (table_root != null) return error.CatalogCorrupt;
 948                             table_root = object.root_page;
 949                             table_definition_count = object.definitions.len;
 950                             _ = try copyDefinitions(&table_definitions, &table_names, &table_defaults, object.definitions);
 951                         }
 952                     },
 953                     .index => {
 954                         if (std.mem.eql(u8, object.name, definition.name)) return error.ObjectExists;
 955                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
 956                         if (std.mem.eql(u8, object.table_name, table_name)) table_index_count += 1;
 957                     },
 958                     .schema => unreachable,
 959                     .stats => unreachable,
 960                 },
 961                 .stats => |stats| {
 962                     if (std.mem.eql(u8, stats.table_name, table_name)) try old_stats.append(allocator, entry.rowid);
 963                 },
 964             }
 965         }
 966         const next_schema = try bumpSchemaVersion(try state.schemaOrDefault());
 967         const root_page = table_root orelse return error.RelationNotFound;
 968         if (table_index_count >= relation_mod.max_indexes) return error.TooManyIndexes;
 969         for (definition.fields) |field| {
 970             if (field >= table_definition_count) return error.ColumnOutOfBounds;
 971         }
 972 
 973         const space = try space_mod.Space.open(self.database, .{
 974             .meta_page = self.meta_page,
 975             .roots = roots[0..root_count],
 976         });
 977         var table_rows = try space.rowidTable(root_page);
 978         var schema = try space.rowidTable(self.root_page);
 979         var write = try space.beginWrite();
 980         defer write.deinit();
 981 
 982         const index_root = try allocateCatalogRoot(&write, &roots, &root_count);
 983         const index_space = try space_mod.Space.open(self.database, .{
 984             .meta_page = self.meta_page,
 985             .roots = roots[0..root_count],
 986         });
 987         var relation_index = try index_space.index(index_root.root_page, definition.columns);
 988         var table_scan: table.Scan = undefined;
 989         try table_rows.scan(&table_scan, allocator, null, null);
 990         defer table_scan.deinit();
 991         while (try table_scan.next()) |entry| {
 992             const view = try entry.view();
 993             var projected: [relation_mod.max_index_fields]row.Value = undefined;
 994             try relation_index.putIn(&write, entry.rowid, try view.project(definition.fields, &projected));
 995         }
 996 
 997         for (old_stats.items) |rowid| try schema.deleteIn(&write, rowid);
 998         try putSchemaRow(&schema, &write, next_schema);
 999 
1000         var fields_buffer: [relation_mod.max_index_fields * field_bytes]u8 = undefined;
1001         var collations_buffer: [relation_mod.max_index_fields]u8 = undefined;
1002         const fields = try encodeFields(&fields_buffer, definition.fields);
1003         const collations = try encodeCollations(&collations_buffer, definition.fields.len, definition.columns);
1004         try putCatalogRow(
1005             &schema,
1006             &write,
1007             @max(state.max_rowid + 1, first_object_rowid),
1008             index_kind,
1009             definition.name,
1010             table_name,
1011             index_root.root_page,
1012             index_root.identity_page,
1013             fields,
1014             collations,
1015         );
1016 
1017         return .{
1018             .storage = try write.commit(options),
1019             .schema = next_schema,
1020         };
1021     }
1022 
1023     pub fn dropRelation(self: *const Catalog, allocator: Allocator, name: []const u8, options: file.CommitOptions) Error!Commit {
1024         const phase = trace.scope("catalog.drop_relation");
1025         defer phase.end();
1026 
1027         var roots: [space_mod.max_roots]space_mod.RootSpec = undefined;
1028         var root_count: usize = 0;
1029         try addRoot(&roots, &root_count, .{ .root_page = self.root_page });
1030 
1031         var rowids: std.ArrayList(i64) = .empty;
1032         defer rowids.deinit(allocator);
1033         var found_relation = false;
1034         var state = CatalogState{};
1035 
1036         var scan: table.Scan = undefined;
1037         try self.schema.scan(&scan, allocator, null, null);
1038         defer scan.deinit();
1039         while (try scan.next()) |entry| {
1040             var scratch = CatalogScratch{};
1041             switch (try state.record(entry, &scratch)) {
1042                 .schema => {},
1043                 .object => |object| switch (object.kind) {
1044                     .relation => {
1045                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
1046                         if (std.mem.eql(u8, object.name, name)) {
1047                             if (found_relation) return error.CatalogCorrupt;
1048                             found_relation = true;
1049                             try rowids.append(allocator, entry.rowid);
1050                         }
1051                     },
1052                     .index => {
1053                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
1054                         if (std.mem.eql(u8, object.table_name, name)) try rowids.append(allocator, entry.rowid);
1055                     },
1056                     .schema => unreachable,
1057                     .stats => unreachable,
1058                 },
1059                 .stats => |stats| {
1060                     if (std.mem.eql(u8, stats.table_name, name)) try rowids.append(allocator, entry.rowid);
1061                 },
1062             }
1063         }
1064         const next_schema = try bumpSchemaVersion(try state.schemaOrDefault());
1065         if (!found_relation) return error.RelationNotFound;
1066 
1067         const space = try space_mod.Space.open(self.database, .{
1068             .meta_page = self.meta_page,
1069             .roots = roots[0..root_count],
1070         });
1071         var schema = try space.rowidTable(self.root_page);
1072         var write = try space.beginWrite();
1073         defer write.deinit();
1074 
1075         for (rowids.items) |rowid| try schema.deleteIn(&write, rowid);
1076         try putSchemaRow(&schema, &write, next_schema);
1077 
1078         return .{
1079             .storage = try write.commit(options),
1080             .schema = next_schema,
1081         };
1082     }
1083 
1084     pub fn openRelation(
1085         self: *const Catalog,
1086         allocator: Allocator,
1087         name: []const u8,
1088     ) Error!RelationHandle {
1089         return try openRelationFrom(
1090             RelationHandle,
1091             space_mod.Space,
1092             relation_mod.Relation,
1093             self.database,
1094             &self.schema,
1095             self.meta_page,
1096             self.root_page,
1097             allocator,
1098             name,
1099         );
1100     }
1101 
1102     pub fn relationNames(self: *const Catalog, allocator: Allocator) Error!RelationNames {
1103         return try readRelationNames(&self.schema, allocator);
1104     }
1105 
1106     pub fn analyzeRelation(self: *const Catalog, allocator: Allocator, name: []const u8, options: file.CommitOptions) Error!Commit {
1107         const phase = trace.scope("catalog.analyze_relation");
1108         defer phase.end();
1109 
1110         var roots: [space_mod.max_roots]space_mod.RootSpec = undefined;
1111         var root_count: usize = 0;
1112         try addRoot(&roots, &root_count, .{ .root_page = self.root_page });
1113 
1114         var table_root: ?u32 = null;
1115         var indexes: std.ArrayList(AnalyzeIndex) = .empty;
1116         defer indexes.deinit(allocator);
1117         defer freeAnalyzeIndexes(allocator, indexes.items);
1118         var old_stats: std.ArrayList(i64) = .empty;
1119         defer old_stats.deinit(allocator);
1120         var state = CatalogState{};
1121 
1122         var scan: table.Scan = undefined;
1123         try self.schema.scan(&scan, allocator, null, null);
1124         defer scan.deinit();
1125         while (try scan.next()) |entry| {
1126             var scratch = CatalogScratch{};
1127             switch (try state.record(entry, &scratch)) {
1128                 .schema => {},
1129                 .object => |object| switch (object.kind) {
1130                     .relation => {
1131                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
1132                         if (std.mem.eql(u8, object.name, name)) table_root = object.root_page;
1133                     },
1134                     .index => {
1135                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
1136                         if (std.mem.eql(u8, object.table_name, name)) {
1137                             if (indexes.items.len >= relation_mod.max_indexes) return error.TooManyIndexes;
1138                             const owned_name = try allocator.dupe(u8, object.name);
1139                             var analyzed_index = AnalyzeIndex{
1140                                 .name = owned_name,
1141                                 .root_page = object.root_page,
1142                                 .field_count = object.fields.len,
1143                             };
1144                             @memcpy(analyzed_index.fields[0..object.fields.len], object.fields);
1145                             @memcpy(analyzed_index.columns[0..object.columns.len], object.columns);
1146                             indexes.append(allocator, analyzed_index) catch |err| {
1147                                 allocator.free(owned_name);
1148                                 return err;
1149                             };
1150                         }
1151                     },
1152                     .schema => unreachable,
1153                     .stats => unreachable,
1154                 },
1155                 .stats => |stats| {
1156                     if (std.mem.eql(u8, stats.table_name, name)) try old_stats.append(allocator, entry.rowid);
1157                 },
1158             }
1159         }
1160         const current_schema = try state.schemaOrDefault();
1161         const root_page = table_root orelse return error.RelationNotFound;
1162 
1163         const space = try space_mod.Space.open(self.database, .{
1164             .meta_page = self.meta_page,
1165             .roots = roots[0..root_count],
1166         });
1167         var schema = try space.rowidTable(self.root_page);
1168         var relation_table = try space.rowidTable(root_page);
1169         const table_summary = try relation_table.summarize();
1170         var index_summaries: [relation_mod.max_indexes]tree.Summary = undefined;
1171         for (indexes.items, 0..) |index_object, index_offset| {
1172             var relation_index = try space.index(index_object.root_page, index_object.indexColumns());
1173             index_summaries[index_offset] = try relation_index.summarize();
1174             indexes.items[index_offset].distribution_blob = try analyzeIndexDistribution(allocator, &relation_index);
1175         }
1176 
1177         var write = try space.beginWrite();
1178         defer write.deinit();
1179         for (old_stats.items) |rowid| try schema.deleteIn(&write, rowid);
1180 
1181         var summary_buffer: [summary_blob_bytes]u8 = undefined;
1182         var next_rowid = @max(state.max_rowid + 1, first_object_rowid);
1183         try putCatalogRow(
1184             &schema,
1185             &write,
1186             next_rowid,
1187             stats_kind,
1188             name,
1189             name,
1190             root_page,
1191             0,
1192             try encodeSummary(&summary_buffer, table_summary),
1193             "",
1194         );
1195         next_rowid += 1;
1196 
1197         for (indexes.items, 0..) |index_object, index_offset| {
1198             try putCatalogRow(
1199                 &schema,
1200                 &write,
1201                 next_rowid,
1202                 stats_kind,
1203                 index_object.name,
1204                 name,
1205                 index_object.root_page,
1206                 0,
1207                 try encodeSummary(&summary_buffer, index_summaries[index_offset]),
1208                 index_object.distribution_blob,
1209             );
1210             next_rowid += 1;
1211         }
1212 
1213         return .{
1214             .storage = try write.commit(options),
1215             .schema = current_schema,
1216         };
1217     }
1218 
1219     pub fn clearRelationStats(self: *const Catalog, allocator: Allocator, name: []const u8, options: file.CommitOptions) Error!?Commit {
1220         const phase = trace.scope("catalog.clear_relation_stats");
1221         defer phase.end();
1222 
1223         var roots: [space_mod.max_roots]space_mod.RootSpec = undefined;
1224         var root_count: usize = 0;
1225         try addRoot(&roots, &root_count, .{ .root_page = self.root_page });
1226 
1227         var old_stats: std.ArrayList(i64) = .empty;
1228         defer old_stats.deinit(allocator);
1229         var found_relation = false;
1230         var state = CatalogState{};
1231 
1232         var scan: table.Scan = undefined;
1233         try self.schema.scan(&scan, allocator, null, null);
1234         defer scan.deinit();
1235         while (try scan.next()) |entry| {
1236             var scratch = CatalogScratch{};
1237             switch (try state.record(entry, &scratch)) {
1238                 .schema => {},
1239                 .object => |object| switch (object.kind) {
1240                     .relation => {
1241                         try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page });
1242                         if (std.mem.eql(u8, object.name, name)) {
1243                             if (found_relation) return error.CatalogCorrupt;
1244                             found_relation = true;
1245                         }
1246                     },
1247                     .index => try addRoot(&roots, &root_count, .{ .root_page = object.root_page, .identity_page = object.identity_page }),
1248                     .schema => unreachable,
1249                     .stats => unreachable,
1250                 },
1251                 .stats => |stats| {
1252                     if (std.mem.eql(u8, stats.table_name, name)) try old_stats.append(allocator, entry.rowid);
1253                 },
1254             }
1255         }
1256         const current_schema = try state.schemaOrDefault();
1257         if (!found_relation) return error.RelationNotFound;
1258         if (old_stats.items.len == 0) return null;
1259 
1260         const space = try space_mod.Space.open(self.database, .{
1261             .meta_page = self.meta_page,
1262             .roots = roots[0..root_count],
1263         });
1264         var schema = try space.rowidTable(self.root_page);
1265         var write = try space.beginWrite();
1266         defer write.deinit();
1267 
1268         for (old_stats.items) |rowid| try schema.deleteIn(&write, rowid);
1269 
1270         return .{
1271             .storage = try write.commit(options),
1272             .schema = current_schema,
1273         };
1274     }
1275 
1276     pub fn relationStats(self: *const Catalog, allocator: Allocator, name: []const u8) Error!?RelationStats {
1277         return try readRelationStats(&self.schema, allocator, name);
1278     }
1279 
1280     /// Returns what `schemaState`, `openRelation` and `relationStats` return
1281     /// for `name`, read in one catalog pass over one snapshot.
1282     pub fn readRelation(
1283         self: *const Catalog,
1284         allocator: Allocator,
1285         name: []const u8,
1286     ) Error!RelationState {
1287         return try readRelationFrom(
1288             RelationHandle,
1289             space_mod.Space,
1290             relation_mod.Relation,
1291             self.database,
1292             &self.schema,
1293             self.meta_page,
1294             self.root_page,
1295             allocator,
1296             name,
1297         );
1298     }
1299 };
1300 
1301 fn RelationStateType(comptime Handle: type) type {
1302     return struct {
1303         /// The schema state of the catalog pass that read the relation.
1304         schema: Schema,
1305         handle: Handle,
1306         stats: ?RelationStats,
1307 
1308         pub fn relationStats(self: *const @This()) ?*const RelationStats {
1309             if (self.stats) |*stats| return stats;
1310             return null;
1311         }
1312 
1313         pub fn deinit(self: *@This()) void {
1314             if (self.stats) |*stats| stats.deinit();
1315             self.handle.deinit();
1316             self.* = undefined;
1317         }
1318     };
1319 }
1320 
1321 /// The schema state, handle and statistics of one relation.
1322 pub const RelationState = RelationStateType(RelationHandle);
1323 /// The schema state, read handle and statistics of one relation.
1324 pub const ReadRelationState = RelationStateType(ReadRelationHandle);
1325 
1326 fn validateOptions(options: Options) Error!void {
1327     if (options.meta_page == 0) return error.InvalidPageId;
1328     if (options.root_page == 0) return error.InvalidPageId;
1329     if (options.meta_page == options.root_page) return error.InvalidPageId;
1330 }
1331 
1332 fn catalogTreeOptions(options: Options) tree.Options {
1333     return .{
1334         .meta_page = options.meta_page,
1335         .root_page = options.root_page,
1336         .reserved_page_max = options.root_page,
1337     };
1338 }
1339 
1340 fn readSchemaState(schema: anytype, allocator: Allocator) Error!Schema {
1341     const phase = trace.scope("catalog.schema_state");
1342     defer phase.end();
1343 
1344     return try scanCatalog(schema, allocator, SchemaRows{});
1345 }
1346 
1347 /// Reads every catalog row once and returns the schema state. Each relation
1348 /// object, index object and statistics row goes to `rows`, which copies what
1349 /// it keeps before the pass reads the next row.
1350 fn scanCatalog(schema: anytype, allocator: Allocator, rows: anytype) Error!Schema {
1351     var state = CatalogState{};
1352     var scan: table.Scan = undefined;
1353     try schema.scan(&scan, allocator, null, null);
1354     defer scan.deinit();
1355     while (try scan.next()) |entry| {
1356         var scratch = CatalogScratch{};
1357         switch (try state.record(entry, &scratch)) {
1358             .schema => {},
1359             .object => |object| try rows.visitObject(object),
1360             .stats => |stats| try rows.visitStats(stats),
1361         }
1362     }
1363     return try state.schemaOrDefault();
1364 }
1365 
1366 /// Keeps no rows, for a pass that only reads the schema state.
1367 const SchemaRows = struct {
1368     fn visitObject(_: SchemaRows, _: CatalogObject) Error!void {}
1369     fn visitStats(_: SchemaRows, _: CatalogStats) Error!void {}
1370 };
1371 
1372 const RelationMetadata = struct {
1373     allocator: Allocator,
1374     specs: []relation_mod.IndexSpec,
1375     index_definitions: []IndexDefinition,
1376     fields: []usize,
1377     columns: []row.Column,
1378     definitions: []ColumnDefinition,
1379     index_names: []u8,
1380     names: []u8,
1381     defaults: []u8,
1382 
1383     fn deinit(self: *RelationMetadata) void {
1384         self.allocator.free(self.defaults);
1385         self.allocator.free(self.names);
1386         self.allocator.free(self.index_names);
1387         self.allocator.free(self.definitions);
1388         self.allocator.free(self.columns);
1389         self.allocator.free(self.fields);
1390         self.allocator.free(self.index_definitions);
1391         self.allocator.free(self.specs);
1392         self.* = undefined;
1393     }
1394 };
1395 
1396 const RelationOpenPlan = struct {
1397     name: []const u8,
1398     roots: [space_mod.max_roots]space_mod.RootSpec = undefined,
1399     root_count: usize = 0,
1400     table_root: ?u32 = null,
1401     index_roots: [relation_mod.max_indexes]u32 = undefined,
1402     index_name_stack: [relation_mod.max_indexes][max_column_name_bytes]u8 = undefined,
1403     index_name_lengths: [relation_mod.max_indexes]usize = undefined,
1404     fields_stack: [relation_mod.max_indexes][relation_mod.max_index_fields]usize = undefined,
1405     columns_stack: [relation_mod.max_indexes][relation_mod.max_index_fields]row.Column = undefined,
1406     field_counts: [relation_mod.max_indexes]usize = undefined,
1407     definition_stack: [max_columns]ColumnDefinition = undefined,
1408     name_stack: [max_column_name_bytes]u8 = undefined,
1409     default_stack: [max_column_default_bytes]u8 = undefined,
1410     definition_count: usize = 0,
1411     name_count: usize = 0,
1412     default_count: usize = 0,
1413     index_count: usize = 0,
1414     total_index_name_bytes: usize = 0,
1415     total_fields: usize = 0,
1416 
1417     fn init(root_page: u32, name: []const u8) Error!RelationOpenPlan {
1418         var plan = RelationOpenPlan{ .name = name };
1419         try addRoot(&plan.roots, &plan.root_count, .{ .root_page = root_page });
1420         return plan;
1421     }
1422 
1423     fn visitObject(self: *RelationOpenPlan, object: CatalogObject) Error!void {
1424         switch (object.kind) {
1425             .relation => if (std.mem.eql(u8, object.name, self.name)) {
1426                 self.table_root = object.root_page;
1427                 self.definition_count = object.definitions.len;
1428                 const copied = try copyDefinitions(
1429                     &self.definition_stack,
1430                     &self.name_stack,
1431                     &self.default_stack,
1432                     object.definitions,
1433                 );
1434                 self.name_count = copied.names;
1435                 self.default_count = copied.defaults;
1436                 try self.addObjectRoot(object);
1437             },
1438             .index => if (std.mem.eql(u8, object.table_name, self.name)) {
1439                 try self.recordIndex(object);
1440             },
1441             .schema, .stats => unreachable,
1442         }
1443     }
1444 
1445     fn visitStats(_: *RelationOpenPlan, _: CatalogStats) Error!void {}
1446 
1447     fn recordIndex(self: *RelationOpenPlan, object: CatalogObject) Error!void {
1448         if (self.index_count >= relation_mod.max_indexes) return error.TooManyIndexes;
1449         const offset = self.index_count;
1450         if (object.name.len > self.index_name_stack[offset].len) return error.CatalogCorrupt;
1451         self.index_roots[offset] = object.root_page;
1452         @memcpy(self.index_name_stack[offset][0..object.name.len], object.name);
1453         self.index_name_lengths[offset] = object.name.len;
1454         self.field_counts[offset] = object.fields.len;
1455         @memcpy(self.fields_stack[offset][0..object.fields.len], object.fields);
1456         @memcpy(self.columns_stack[offset][0..object.columns.len], object.columns);
1457         self.total_index_name_bytes += object.name.len;
1458         self.total_fields += object.fields.len;
1459         try self.addObjectRoot(object);
1460         self.index_count += 1;
1461     }
1462 
1463     fn addObjectRoot(self: *RelationOpenPlan, object: CatalogObject) Error!void {
1464         try addRoot(&self.roots, &self.root_count, .{
1465             .root_page = object.root_page,
1466             .identity_page = object.identity_page,
1467         });
1468     }
1469 
1470     fn allocate(self: *const RelationOpenPlan, allocator: Allocator) Error!RelationMetadata {
1471         const specs = try allocator.alloc(relation_mod.IndexSpec, self.index_count);
1472         errdefer allocator.free(specs);
1473         const indexes = try allocator.alloc(IndexDefinition, self.index_count);
1474         errdefer allocator.free(indexes);
1475         const fields = try allocator.alloc(usize, self.total_fields);
1476         errdefer allocator.free(fields);
1477         const columns = try allocator.alloc(row.Column, self.total_fields);
1478         errdefer allocator.free(columns);
1479         const definitions = try allocator.alloc(ColumnDefinition, self.definition_count);
1480         errdefer allocator.free(definitions);
1481         const index_names = try allocator.alloc(u8, self.total_index_name_bytes);
1482         errdefer allocator.free(index_names);
1483         const names = try allocator.alloc(u8, self.name_count);
1484         errdefer allocator.free(names);
1485         const defaults = try allocator.alloc(u8, self.default_count);
1486         errdefer allocator.free(defaults);
1487         self.copyIndexes(specs, indexes, fields, columns, index_names);
1488         copyOpenedDefinitions(
1489             definitions,
1490             names,
1491             defaults,
1492             self.definition_stack[0..self.definition_count],
1493         );
1494         return .{
1495             .allocator = allocator,
1496             .specs = specs,
1497             .index_definitions = indexes,
1498             .fields = fields,
1499             .columns = columns,
1500             .definitions = definitions,
1501             .index_names = index_names,
1502             .names = names,
1503             .defaults = defaults,
1504         };
1505     }
1506 
1507     fn copyIndexes(
1508         self: *const RelationOpenPlan,
1509         specs: []relation_mod.IndexSpec,
1510         definitions: []IndexDefinition,
1511         fields: []usize,
1512         columns: []row.Column,
1513         names: []u8,
1514     ) void {
1515         var field_offset: usize = 0;
1516         var name_offset: usize = 0;
1517         var offset: usize = 0;
1518         while (offset < self.index_count) : (offset += 1) {
1519             const count = self.field_counts[offset];
1520             @memcpy(fields[field_offset..][0..count], self.fields_stack[offset][0..count]);
1521             @memcpy(columns[field_offset..][0..count], self.columns_stack[offset][0..count]);
1522             const name = names[name_offset..][0..self.index_name_lengths[offset]];
1523             @memcpy(name, self.index_name_stack[offset][0..name.len]);
1524             specs[offset] = .{
1525                 .root_page = self.index_roots[offset],
1526                 .fields = fields[field_offset..][0..count],
1527                 .columns = columns[field_offset..][0..count],
1528             };
1529             definitions[offset] = .{
1530                 .name = name,
1531                 .fields = fields[field_offset..][0..count],
1532                 .columns = columns[field_offset..][0..count],
1533             };
1534             field_offset += count;
1535             name_offset += name.len;
1536         }
1537     }
1538 };
1539 
1540 fn openRelationFrom(
1541     comptime Handle: type,
1542     comptime Space: type,
1543     comptime Relation: type,
1544     source: anytype,
1545     schema: anytype,
1546     meta_page: u32,
1547     catalog_root: u32,
1548     allocator: Allocator,
1549     name: []const u8,
1550 ) Error!Handle {
1551     const phase = trace.scope("catalog.open_relation");
1552     defer phase.end();
1553 
1554     var plan = try RelationOpenPlan.init(catalog_root, name);
1555     _ = try scanCatalog(schema, allocator, &plan);
1556     return try openPlannedRelation(Handle, Space, Relation, source, meta_page, &plan, allocator);
1557 }
1558 
1559 fn readRelationFrom(
1560     comptime Handle: type,
1561     comptime Space: type,
1562     comptime Relation: type,
1563     source: anytype,
1564     schema: anytype,
1565     meta_page: u32,
1566     catalog_root: u32,
1567     allocator: Allocator,
1568     name: []const u8,
1569 ) Error!RelationStateType(Handle) {
1570     const phase = trace.scope("catalog.read_relation");
1571     defer phase.end();
1572 
1573     var plan = try RelationOpenPlan.init(catalog_root, name);
1574     var stats_rows = RelationStatsRows{ .allocator = allocator, .name = name };
1575     defer stats_rows.deinit();
1576     const schema_state = try scanCatalog(schema, allocator, RelationRows{
1577         .plan = &plan,
1578         .stats = &stats_rows,
1579     });
1580     var handle = try openPlannedRelation(
1581         Handle,
1582         Space,
1583         Relation,
1584         source,
1585         meta_page,
1586         &plan,
1587         allocator,
1588     );
1589     errdefer handle.deinit();
1590     return .{ .schema = schema_state, .handle = handle, .stats = try stats_rows.finish() };
1591 }
1592 
1593 fn openPlannedRelation(
1594     comptime Handle: type,
1595     comptime Space: type,
1596     comptime Relation: type,
1597     source: anytype,
1598     meta_page: u32,
1599     plan: *const RelationOpenPlan,
1600     allocator: Allocator,
1601 ) Error!Handle {
1602     const root_page = plan.table_root orelse return error.RelationNotFound;
1603     var metadata = try plan.allocate(allocator);
1604     errdefer metadata.deinit();
1605     const space = try Space.open(source, .{
1606         .meta_page = meta_page,
1607         .roots = plan.roots[0..plan.root_count],
1608     });
1609     const opened = try Relation.open(&space, .{
1610         .table_root = root_page,
1611         .indexes = metadata.specs,
1612     });
1613     return .{
1614         .allocator = allocator,
1615         .relation = opened,
1616         .specs = metadata.specs,
1617         .index_definitions = metadata.index_definitions,
1618         .fields = metadata.fields,
1619         .columns = metadata.columns,
1620         .definitions = metadata.definitions,
1621         .index_names = metadata.index_names,
1622         .names = metadata.names,
1623         .defaults = metadata.defaults,
1624     };
1625 }
1626 
1627 fn readRelationNames(schema: anytype, allocator: Allocator) Error!RelationNames {
1628     const phase = trace.scope("catalog.relation_names");
1629     defer phase.end();
1630 
1631     var rows = RelationNameRows{ .allocator = allocator };
1632     errdefer rows.deinit();
1633     _ = try scanCatalog(schema, allocator, &rows);
1634     return .{
1635         .allocator = allocator,
1636         .names = try rows.names.toOwnedSlice(allocator),
1637     };
1638 }
1639 
1640 /// Collects the name of every relation during a catalog pass.
1641 const RelationNameRows = struct {
1642     allocator: Allocator,
1643     names: std.ArrayList([]u8) = .empty,
1644 
1645     fn deinit(self: *RelationNameRows) void {
1646         for (self.names.items) |name| self.allocator.free(name);
1647         self.names.deinit(self.allocator);
1648         self.* = undefined;
1649     }
1650 
1651     fn visitObject(self: *RelationNameRows, object: CatalogObject) Error!void {
1652         switch (object.kind) {
1653             .relation => {
1654                 const owned = try self.allocator.dupe(u8, object.name);
1655                 self.names.append(self.allocator, owned) catch |err| {
1656                     self.allocator.free(owned);
1657                     return err;
1658                 };
1659             },
1660             .index => {},
1661             .schema, .stats => unreachable,
1662         }
1663     }
1664 
1665     fn visitStats(_: *RelationNameRows, _: CatalogStats) Error!void {}
1666 };
1667 
1668 fn readRelationStats(
1669     schema: anytype,
1670     allocator: Allocator,
1671     name: []const u8,
1672 ) Error!?RelationStats {
1673     const phase = trace.scope("catalog.relation_stats");
1674     defer phase.end();
1675 
1676     var rows = RelationStatsRows{ .allocator = allocator, .name = name };
1677     defer rows.deinit();
1678     _ = try scanCatalog(schema, allocator, &rows);
1679     return try rows.finish();
1680 }
1681 
1682 /// Collects the statistics rows of one relation during a catalog pass.
1683 const RelationStatsRows = struct {
1684     allocator: Allocator,
1685     name: []const u8,
1686     table_root: ?u32 = null,
1687     table_stats_root: ?u32 = null,
1688     table_summary: ?tree.Summary = null,
1689     matching_stats_seen: bool = false,
1690     index_stats: std.ArrayList(IndexStats) = .empty,
1691 
1692     fn deinit(self: *RelationStatsRows) void {
1693         freeIndexStats(self.allocator, self.index_stats.items);
1694         self.index_stats.deinit(self.allocator);
1695         self.* = undefined;
1696     }
1697 
1698     fn visitObject(self: *RelationStatsRows, object: CatalogObject) Error!void {
1699         switch (object.kind) {
1700             .relation => if (std.mem.eql(u8, object.name, self.name)) {
1701                 self.table_root = object.root_page;
1702             },
1703             .index => {},
1704             .schema, .stats => unreachable,
1705         }
1706     }
1707 
1708     fn visitStats(self: *RelationStatsRows, stats: CatalogStats) Error!void {
1709         if (!std.mem.eql(u8, stats.table_name, self.name)) return;
1710         self.matching_stats_seen = true;
1711         if (std.mem.eql(u8, stats.name, self.name)) {
1712             if (self.table_summary != null) return error.CatalogCorrupt;
1713             if (stats.distribution_blob.len != 0) return error.CatalogCorrupt;
1714             self.table_summary = stats.summary;
1715             self.table_stats_root = stats.root_page;
1716         } else try appendIndexStats(self.allocator, &self.index_stats, stats);
1717     }
1718 
1719     /// Returns the statistics of the relation once the pass is over. The
1720     /// result owns the index statistics the rows collected.
1721     fn finish(self: *RelationStatsRows) Error!?RelationStats {
1722         const root_page = self.table_root orelse return error.RelationNotFound;
1723         if (self.table_summary == null) {
1724             if (self.matching_stats_seen) return error.CatalogCorrupt;
1725             return null;
1726         }
1727         if (self.table_stats_root.? != root_page) return error.CatalogCorrupt;
1728         return .{
1729             .allocator = self.allocator,
1730             .table_root_page = root_page,
1731             .table = self.table_summary.?,
1732             .indexes = try self.index_stats.toOwnedSlice(self.allocator),
1733         };
1734     }
1735 };
1736 
1737 /// Hands each catalog row to the open plan and the statistics of one
1738 /// relation, so one pass reads both.
1739 const RelationRows = struct {
1740     plan: *RelationOpenPlan,
1741     stats: *RelationStatsRows,
1742 
1743     fn visitObject(self: RelationRows, object: CatalogObject) Error!void {
1744         try self.plan.visitObject(object);
1745         try self.stats.visitObject(object);
1746     }
1747 
1748     fn visitStats(self: RelationRows, stats: CatalogStats) Error!void {
1749         try self.stats.visitStats(stats);
1750     }
1751 };
1752 
1753 fn appendIndexStats(
1754     allocator: Allocator,
1755     index_stats: *std.ArrayList(IndexStats),
1756     stats: CatalogStats,
1757 ) Error!void {
1758     if (containsIndexStats(index_stats.items, stats.name)) return error.CatalogCorrupt;
1759     const owned_name = try allocator.dupe(u8, stats.name);
1760     errdefer allocator.free(owned_name);
1761     const distribution = if (stats.distribution_blob.len == 0)
1762         IndexDistribution{}
1763     else
1764         try decodeIndexDistribution(allocator, stats.distribution_blob);
1765     errdefer freeIndexDistribution(allocator, distribution);
1766     try index_stats.append(allocator, .{
1767         .name = owned_name,
1768         .root_page = stats.root_page,
1769         .summary = stats.summary,
1770         .distribution = distribution,
1771     });
1772 }
1773 
1774 const CatalogKind = enum {
1775     schema,
1776     relation,
1777     index,
1778     stats,
1779 };
1780 
1781 const CatalogEntry = union(enum) {
1782     schema: Schema,
1783     object: CatalogObject,
1784     stats: CatalogStats,
1785 };
1786 
1787 const CatalogObject = struct {
1788     kind: CatalogKind,
1789     name: []const u8,
1790     table_name: []const u8,
1791     root_page: u32,
1792     identity_page: u32,
1793     fields: []const usize,
1794     columns: []const row.Column,
1795     definitions: []const ColumnDefinition,
1796 };
1797 
1798 const CatalogStats = struct {
1799     name: []const u8,
1800     table_name: []const u8,
1801     root_page: u32,
1802     summary: tree.Summary,
1803     distribution_blob: []const u8,
1804 };
1805 
1806 const AnalyzeIndex = struct {
1807     name: []u8,
1808     root_page: u32,
1809     fields: [relation_mod.max_index_fields]usize = undefined,
1810     columns: [relation_mod.max_index_fields]row.Column = undefined,
1811     field_count: usize = 0,
1812     distribution_blob: []u8 = &.{},
1813 
1814     fn indexColumns(self: *const AnalyzeIndex) []const row.Column {
1815         return self.columns[0..self.field_count];
1816     }
1817 };
1818 
1819 const CatalogScratch = struct {
1820     fields: [relation_mod.max_index_fields]usize = undefined,
1821     columns: [relation_mod.max_index_fields]row.Column = undefined,
1822     definitions: [max_columns]ColumnDefinition = undefined,
1823     names: [max_column_name_bytes]u8 = undefined,
1824     defaults: [max_column_default_bytes]u8 = undefined,
1825 };
1826 
1827 const CatalogState = struct {
1828     schema: ?Schema = null,
1829     data_seen: bool = false,
1830     max_rowid: i64 = 0,
1831 
1832     fn record(self: *CatalogState, entry: table.Entry, scratch: *CatalogScratch) Error!CatalogEntry {
1833         const parsed = try catalogEntry(entry.rowid, entry.bytes, scratch);
1834         switch (parsed) {
1835             .schema => |schema| {
1836                 if (self.schema != null) return error.CatalogCorrupt;
1837                 if (schema.format != format_version) return error.UnsupportedCatalogFormat;
1838                 self.schema = schema;
1839             },
1840             .object => {
1841                 self.data_seen = true;
1842                 self.max_rowid = @max(self.max_rowid, entry.rowid);
1843             },
1844             .stats => {
1845                 self.data_seen = true;
1846                 self.max_rowid = @max(self.max_rowid, entry.rowid);
1847             },
1848         }
1849         return parsed;
1850     }
1851 
1852     fn schemaOrDefault(self: CatalogState) Error!Schema {
1853         const schema = self.schema orelse {
1854             if (self.data_seen) return error.CatalogCorrupt;
1855             return .{};
1856         };
1857         return schema;
1858     }
1859 };
1860 
1861 pub fn validateDefinition(definition: RelationDefinition) Error!void {
1862     if (definition.name.len == 0) return error.CatalogCorrupt;
1863     if (definition.columns.len > max_columns) return error.TooManyColumns;
1864     for (definition.columns, 0..) |column, column_offset| {
1865         if (column.name.len == 0) return error.CatalogCorrupt;
1866         if (column.name.len > std.math.maxInt(u16)) return error.CatalogCorrupt;
1867         if ((try row.encodedSize(&.{column.default})) > std.math.maxInt(u16)) return error.CatalogCorrupt;
1868         for (definition.columns[0..column_offset]) |previous_column| {
1869             if (std.ascii.eqlIgnoreCase(column.name, previous_column.name)) return error.CatalogCorrupt;
1870         }
1871     }
1872     if (definition.indexes.len > relation_mod.max_indexes) return error.TooManyIndexes;
1873     for (definition.indexes, 0..) |index_definition, index_offset| {
1874         if (index_definition.name.len == 0) return error.CatalogCorrupt;
1875         if (std.mem.eql(u8, index_definition.name, definition.name)) return error.ObjectExists;
1876         for (definition.indexes[0..index_offset]) |previous_index| {
1877             if (std.mem.eql(u8, index_definition.name, previous_index.name)) return error.ObjectExists;
1878         }
1879         if (index_definition.fields.len > relation_mod.max_index_fields) return error.TooManyIndexFields;
1880         if (index_definition.columns.len > index_definition.fields.len) return error.CatalogCorrupt;
1881         if (definition.columns.len > 0) {
1882             for (index_definition.fields) |field| {
1883                 if (field >= definition.columns.len) return error.ColumnOutOfBounds;
1884             }
1885         }
1886     }
1887 }
1888 
1889 fn validatePreparedStats(
1890     definition: RelationDefinition,
1891     prepared: *const PreparedRelationStats,
1892 ) Error!void {
1893     if (prepared.stats.indexes.len != definition.indexes.len) return error.CatalogCorrupt;
1894     if (prepared.distribution_blobs.len != definition.indexes.len) return error.CatalogCorrupt;
1895     for (definition.indexes, prepared.stats.indexes) |index_definition, index_stats| {
1896         if (!std.mem.eql(u8, index_definition.name, index_stats.name)) return error.CatalogCorrupt;
1897     }
1898 }
1899 
1900 fn sameLogicalSummary(left: tree.Summary, right: tree.Summary) bool {
1901     return left.entries == right.entries and
1902         left.key_bytes == right.key_bytes and
1903         left.value_bytes == right.value_bytes;
1904 }
1905 
1906 fn bumpSchemaVersion(schema: Schema) Error!Schema {
1907     if (schema.version == std.math.maxInt(u64)) return error.SchemaVersionOverflow;
1908     return .{
1909         .format = schema.format,
1910         .version = schema.version + 1,
1911     };
1912 }
1913 
1914 fn putSchemaRow(schema: *table.Table, write: *tree.Write, catalog_schema: Schema) Error!void {
1915     var schema_buffer: [schema_blob_bytes]u8 = undefined;
1916     try schema.putIn(write, schema_rowid, &.{
1917         .{ .integer = schema_kind },
1918         .{ .text = schema_name },
1919         .{ .text = "" },
1920         .{ .integer = 0 },
1921         .{ .blob = encodeSchema(&schema_buffer, catalog_schema) },
1922         .{ .blob = "" },
1923     });
1924 }
1925 
1926 fn putCatalogRow(schema: *table.Table, write: *tree.Write, rowid: i64, kind: i64, name: []const u8, table_name: []const u8, root_page: u32, identity_page: u32, fields: []const u8, collations: []const u8) Error!void {
1927     try schema.putIn(write, rowid, &.{
1928         .{ .integer = kind },
1929         .{ .text = name },
1930         .{ .text = table_name },
1931         .{ .integer = rootAsInteger(root_page) },
1932         .{ .blob = fields },
1933         .{ .blob = collations },
1934         .{ .integer = identityAsInteger(identity_page) },
1935     });
1936 }
1937 
1938 fn catalogEntry(rowid: i64, bytes: []const u8, scratch: *CatalogScratch) Error!CatalogEntry {
1939     const view = try row.View.init(bytes);
1940     const kind = try catalogKind(try view.column(0));
1941     return switch (kind) {
1942         .schema => .{ .schema = try catalogSchema(rowid, view) },
1943         .relation, .index => .{ .object = try catalogObject(rowid, kind, view, scratch) },
1944         .stats => .{ .stats = try catalogStats(rowid, view) },
1945     };
1946 }
1947 
1948 fn catalogSchema(rowid: i64, view: row.View) Error!Schema {
1949     var cursor = view.cursor();
1950     if (view.columnCount() != schema_row_columns) return error.UnsupportedCatalogFormat;
1951     if (rowid != schema_rowid) return error.CatalogCorrupt;
1952     if (!std.mem.eql(u8, try textValue(try cursor.column(1)), schema_name)) return error.CatalogCorrupt;
1953     if ((try textValue(try cursor.column(2))).len != 0) return error.CatalogCorrupt;
1954     if (try integerValue(try cursor.column(3)) != 0) return error.CatalogCorrupt;
1955     const schema_blob = try blobValue(try cursor.column(4));
1956     if ((try blobValue(try cursor.column(5))).len != 0) return error.CatalogCorrupt;
1957     return try decodeSchema(schema_blob);
1958 }
1959 
1960 fn catalogObject(rowid: i64, kind: CatalogKind, view: row.View, scratch: *CatalogScratch) Error!CatalogObject {
1961     var cursor = view.cursor();
1962     if (view.columnCount() != catalog_row_columns) return error.UnsupportedCatalogFormat;
1963     if (rowid < first_object_rowid) return error.CatalogCorrupt;
1964     const name = try textValue(try cursor.column(1));
1965     const table_name = try textValue(try cursor.column(2));
1966     const root_page = try rootValue(try cursor.column(3));
1967     const first_blob = try blobValue(try cursor.column(4));
1968     const second_blob = try blobValue(try cursor.column(5));
1969     const identity_page = try rootValue(try cursor.column(6));
1970     const fields = if (kind == .index) try decodeFields(first_blob, &scratch.fields) else scratch.fields[0..0];
1971     const columns = if (kind == .index) try decodeCollations(second_blob, fields.len, &scratch.columns) else scratch.columns[0..0];
1972     const definitions = if (kind == .relation) try decodeDefinitions(first_blob, &scratch.definitions, &scratch.names, &scratch.defaults) else scratch.definitions[0..0];
1973     if (kind == .relation and second_blob.len != 0) return error.CatalogCorrupt;
1974     return .{
1975         .kind = kind,
1976         .name = name,
1977         .table_name = table_name,
1978         .root_page = root_page,
1979         .identity_page = identity_page,
1980         .fields = fields,
1981         .columns = columns,
1982         .definitions = definitions,
1983     };
1984 }
1985 
1986 fn catalogStats(rowid: i64, view: row.View) Error!CatalogStats {
1987     var cursor = view.cursor();
1988     if (view.columnCount() != catalog_row_columns) return error.UnsupportedCatalogFormat;
1989     if (rowid < first_object_rowid) return error.CatalogCorrupt;
1990     const name = try textValue(try cursor.column(1));
1991     const table_name = try textValue(try cursor.column(2));
1992     const root_page = try rootValue(try cursor.column(3));
1993     const summary = try decodeSummary(try blobValue(try cursor.column(4)));
1994     const distribution_blob = try blobValue(try cursor.column(5));
1995     if (name.len == 0 or table_name.len == 0) return error.CatalogCorrupt;
1996     return .{
1997         .name = name,
1998         .table_name = table_name,
1999         .root_page = root_page,
2000         .summary = summary,
2001         .distribution_blob = distribution_blob,
2002     };
2003 }
2004 
2005 fn catalogKind(value: row.Value) Error!CatalogKind {
2006     return switch (try integerValue(value)) {
2007         schema_kind => .schema,
2008         relation_kind => .relation,
2009         index_kind => .index,
2010         stats_kind => .stats,
2011         else => error.CatalogCorrupt,
2012     };
2013 }
2014 
2015 fn integerValue(value: row.Value) Error!i64 {
2016     return switch (value) {
2017         .integer => |integer| integer,
2018         else => error.CatalogCorrupt,
2019     };
2020 }
2021 
2022 fn textValue(value: row.Value) Error![]const u8 {
2023     return switch (value) {
2024         .text => |text| text,
2025         else => error.CatalogCorrupt,
2026     };
2027 }
2028 
2029 fn blobValue(value: row.Value) Error![]const u8 {
2030     return switch (value) {
2031         .blob => |blob| blob,
2032         else => error.CatalogCorrupt,
2033     };
2034 }
2035 
2036 fn rootValue(value: row.Value) Error!u32 {
2037     const integer = try integerValue(value);
2038     if (integer <= 0) return error.InvalidPageId;
2039     if (integer > std.math.maxInt(u32)) return error.InvalidPageId;
2040     return @intCast(integer);
2041 }
2042 
2043 fn rootAsInteger(root_page: u32) i64 {
2044     return @intCast(root_page);
2045 }
2046 
2047 fn identityAsInteger(identity_page: u32) i64 {
2048     return @intCast(identity_page);
2049 }
2050 
2051 fn encodeSchema(target: *[schema_blob_bytes]u8, schema: Schema) []const u8 {
2052     std.mem.writeInt(u32, target[0..4], schema.format, .big);
2053     std.mem.writeInt(u64, target[4..12], schema.version, .big);
2054     return target[0..];
2055 }
2056 
2057 fn decodeSchema(bytes: []const u8) Error!Schema {
2058     if (bytes.len != schema_blob_bytes) return error.CatalogCorrupt;
2059     return .{
2060         .format = std.mem.readInt(u32, bytes[0..4], .big),
2061         .version = std.mem.readInt(u64, bytes[4..12], .big),
2062     };
2063 }
2064 
2065 fn encodeSummary(target: *[summary_blob_bytes]u8, summary: tree.Summary) Error![]const u8 {
2066     try writeSummaryField(target, 0, summary.branch_pages);
2067     try writeSummaryField(target, 1, summary.leaf_pages);
2068     try writeSummaryField(target, 2, summary.overflow_pages);
2069     try writeSummaryField(target, 3, summary.entries);
2070     try writeSummaryField(target, 4, summary.inline_records);
2071     try writeSummaryField(target, 5, summary.overflow_records);
2072     try writeSummaryField(target, 6, summary.max_depth);
2073     try writeSummaryField(target, 7, summary.key_bytes);
2074     try writeSummaryField(target, 8, summary.record_bytes);
2075     try writeSummaryField(target, 9, summary.value_bytes);
2076     return target[0..];
2077 }
2078 
2079 fn decodeSummary(bytes: []const u8) Error!tree.Summary {
2080     if (bytes.len != summary_blob_bytes) return error.CatalogCorrupt;
2081     return .{
2082         .branch_pages = try readSummaryField(bytes, 0),
2083         .leaf_pages = try readSummaryField(bytes, 1),
2084         .overflow_pages = try readSummaryField(bytes, 2),
2085         .entries = try readSummaryField(bytes, 3),
2086         .inline_records = try readSummaryField(bytes, 4),
2087         .overflow_records = try readSummaryField(bytes, 5),
2088         .max_depth = try readSummaryField(bytes, 6),
2089         .key_bytes = try readSummaryField(bytes, 7),
2090         .record_bytes = try readSummaryField(bytes, 8),
2091         .value_bytes = try readSummaryField(bytes, 9),
2092     };
2093 }
2094 
2095 fn writeSummaryField(target: *[summary_blob_bytes]u8, field: usize, value: usize) Error!void {
2096     if (value > std.math.maxInt(u64)) return error.CatalogCorrupt;
2097     std.mem.writeInt(u64, target[field * 8 ..][0..8], @intCast(value), .big);
2098 }
2099 
2100 fn readSummaryField(bytes: []const u8, field: usize) Error!usize {
2101     const value = std.mem.readInt(u64, bytes[field * 8 ..][0..8], .big);
2102     if (value > std.math.maxInt(usize)) return error.CatalogCorrupt;
2103     return @intCast(value);
2104 }
2105 
2106 const DistributionMeasure = struct {
2107     entries: usize = 0,
2108     distinct_values: usize = 0,
2109     max_equal: usize = 0,
2110 };
2111 
2112 fn analyzeIndexDistribution(allocator: Allocator, relation_index: *const index_mod.Index) Error![]u8 {
2113     var source = LiveDistributionSource{ .index = relation_index };
2114     return try analyzeDistribution(allocator, &source);
2115 }
2116 
2117 const LiveDistributionSource = struct {
2118     index: *const index_mod.Index,
2119 
2120     const Scan = LiveDistributionScan;
2121 
2122     fn columns(self: *const LiveDistributionSource) []const row.Column {
2123         return self.index.columns;
2124     }
2125 
2126     fn scan(
2127         self: *const LiveDistributionSource,
2128         target: *LiveDistributionScan,
2129         allocator: Allocator,
2130     ) Error!void {
2131         try self.index.scan(&target.scan_value, allocator, null, null);
2132     }
2133 };
2134 
2135 const LiveDistributionScan = struct {
2136     scan_value: index_mod.Scan,
2137 
2138     fn deinit(self: *LiveDistributionScan) void {
2139         self.scan_value.deinit();
2140     }
2141 
2142     fn next(self: *LiveDistributionScan) Error!?[]const u8 {
2143         const entry = (try self.scan_value.next()) orelse return null;
2144         return entry.key;
2145     }
2146 };
2147 
2148 const PreparedDistributionKeys = struct {
2149     allocator: Allocator,
2150     bytes: []u8,
2151     keys: [][]const u8,
2152 
2153     fn deinit(self: *PreparedDistributionKeys) void {
2154         self.allocator.free(self.keys);
2155         if (self.bytes.len != 0) self.allocator.free(self.bytes);
2156         self.* = undefined;
2157     }
2158 };
2159 
2160 const PreparedDistributionSource = struct {
2161     columns_value: []const row.Column,
2162     keys: []const []const u8,
2163 
2164     const Scan = PreparedDistributionScan;
2165 
2166     fn columns(self: *const PreparedDistributionSource) []const row.Column {
2167         return self.columns_value;
2168     }
2169 
2170     fn scan(
2171         self: *const PreparedDistributionSource,
2172         target: *PreparedDistributionScan,
2173         allocator: Allocator,
2174     ) Error!void {
2175         _ = allocator;
2176         target.* = .{ .keys = self.keys };
2177     }
2178 };
2179 
2180 const PreparedDistributionScan = struct {
2181     keys: []const []const u8,
2182     offset: usize = 0,
2183 
2184     fn deinit(self: *PreparedDistributionScan) void {
2185         self.* = undefined;
2186     }
2187 
2188     fn next(self: *PreparedDistributionScan) Error!?[]const u8 {
2189         if (self.offset == self.keys.len) return null;
2190         const bytes = self.keys[self.offset];
2191         self.offset += 1;
2192         return bytes;
2193     }
2194 };
2195 
2196 fn prepareIndexStats(
2197     allocator: Allocator,
2198     definition: IndexDefinition,
2199     puts: []const relation_mod.Edit.Put,
2200     summary: tree.Summary,
2201     index_offset: usize,
2202 ) Error!PreparedIndexStats {
2203     std.debug.assert(index_offset < relation_mod.max_indexes);
2204     var sorted_keys = try prepareDistributionKeys(allocator, definition, puts);
2205     defer sorted_keys.deinit();
2206     var source = PreparedDistributionSource{
2207         .columns_value = definition.columns,
2208         .keys = sorted_keys.keys,
2209     };
2210     const distribution_blob = try analyzeDistribution(allocator, &source);
2211     errdefer if (distribution_blob.len != 0) allocator.free(distribution_blob);
2212     const distribution = if (distribution_blob.len == 0)
2213         IndexDistribution{}
2214     else
2215         try decodeIndexDistribution(allocator, distribution_blob);
2216     errdefer freeIndexDistribution(allocator, distribution);
2217     return .{
2218         .stats = .{
2219             .name = try allocator.dupe(u8, definition.name),
2220             .root_page = @intCast(index_offset + 1),
2221             .summary = summary,
2222             .distribution = distribution,
2223         },
2224         .distribution_blob = distribution_blob,
2225     };
2226 }
2227 
2228 fn prepareDistributionKeys(
2229     allocator: Allocator,
2230     definition: IndexDefinition,
2231     puts: []const relation_mod.Edit.Put,
2232 ) Error!PreparedDistributionKeys {
2233     var total_bytes: usize = 0;
2234     for (puts) |put| {
2235         const view = try row.View.init(put.bytes);
2236         var projected: [relation_mod.max_index_fields]row.Value = undefined;
2237         const values = try view.project(definition.fields, projected[0..]);
2238         var buffer: [page.size]u8 = undefined;
2239         const encoded = key.encodeIndex(
2240             &buffer,
2241             values,
2242             definition.columns,
2243             put.rowid,
2244         ) catch |err| switch (err) {
2245             error.OutputTooSmall => return error.KeyTooLarge,
2246             else => return err,
2247         };
2248         total_bytes = std.math.add(usize, total_bytes, encoded.len) catch return error.KeyTooLarge;
2249     }
2250 
2251     const bytes = if (total_bytes == 0) @as([]u8, &.{}) else try allocator.alloc(u8, total_bytes);
2252     errdefer if (bytes.len != 0) allocator.free(bytes);
2253     const keys = try allocator.alloc([]const u8, puts.len);
2254     errdefer allocator.free(keys);
2255 
2256     var cursor: usize = 0;
2257     for (puts, keys) |put, *encoded_key| {
2258         const view = try row.View.init(put.bytes);
2259         var projected: [relation_mod.max_index_fields]row.Value = undefined;
2260         const values = try view.project(definition.fields, projected[0..]);
2261         encoded_key.* = key.encodeIndex(
2262             bytes[cursor..],
2263             values,
2264             definition.columns,
2265             put.rowid,
2266         ) catch |err| switch (err) {
2267             error.OutputTooSmall => return error.KeyTooLarge,
2268             else => return err,
2269         };
2270         cursor += encoded_key.*.len;
2271     }
2272     std.debug.assert(cursor == bytes.len);
2273     std.mem.sort([]const u8, keys, {}, encodedKeyLessThan);
2274     return .{
2275         .allocator = allocator,
2276         .bytes = bytes,
2277         .keys = keys,
2278     };
2279 }
2280 
2281 fn encodedKeyLessThan(_: void, left: []const u8, right: []const u8) bool {
2282     return simd.order(Bytes, left, right) == .lt;
2283 }
2284 
2285 fn analyzeDistribution(allocator: Allocator, source: anytype) Error![]u8 {
2286     const prefix_total = source.columns().len;
2287     if (prefix_total == 0) return try allocator.alloc(u8, 0);
2288 
2289     const leading_measure = try measureIndexDistribution(allocator, source, 1);
2290     if (leading_measure.entries == 0) return try allocator.alloc(u8, 0);
2291 
2292     var encoded: std.ArrayList(u8) = .empty;
2293     errdefer encoded.deinit(allocator);
2294     try appendInt(u32, &encoded, allocator, distribution_format);
2295     try appendUsizeAsU16(&encoded, allocator, prefix_total);
2296 
2297     try appendPrefixDistribution(&encoded, allocator, source, 1, leading_measure);
2298     var prefix_count: usize = 2;
2299     while (prefix_count <= prefix_total) : (prefix_count += 1) {
2300         const measure = try measureIndexDistribution(allocator, source, prefix_count);
2301         try appendPrefixDistribution(&encoded, allocator, source, prefix_count, measure);
2302     }
2303     return try encoded.toOwnedSlice(allocator);
2304 }
2305 
2306 fn appendPrefixDistribution(
2307     encoded: *std.ArrayList(u8),
2308     allocator: Allocator,
2309     source: anytype,
2310     prefix_count: usize,
2311     measure: DistributionMeasure,
2312 ) Error!void {
2313     if (measure.entries == 0) return error.CatalogCorrupt;
2314 
2315     var samples: std.ArrayList(u8) = .empty;
2316     defer samples.deinit(allocator);
2317 
2318     const sample_target = @min(max_index_distribution_samples, measure.entries);
2319     var target_index: usize = 0;
2320     var sample_count: usize = 0;
2321     var scan: @TypeOf(source.*).Scan = undefined;
2322     try source.scan(&scan, allocator);
2323     defer scan.deinit();
2324 
2325     var current_key: [page.size]u8 = undefined;
2326     var current_len: usize = 0;
2327     var current_valid = false;
2328     var run_start: usize = 0;
2329     var run_count: usize = 0;
2330     var ordinal: usize = 0;
2331     var distinct_ordinal: usize = 0;
2332 
2333     while (try scan.next()) |bytes| {
2334         var prefix_buffer: [page.size]u8 = undefined;
2335         const prefix = try indexPrefixKey(&prefix_buffer, source.columns(), bytes, prefix_count);
2336         if (!current_valid) {
2337             current_valid = true;
2338             current_len = prefix.len;
2339             @memcpy(current_key[0..prefix.len], prefix);
2340             run_start = ordinal;
2341             run_count = 1;
2342         } else if (std.mem.eql(u8, current_key[0..current_len], prefix)) {
2343             run_count += 1;
2344         } else {
2345             try appendDistributionSamples(&samples, allocator, measure.entries, sample_target, &target_index, &sample_count, current_key[0..current_len], run_start, run_count, distinct_ordinal);
2346             distinct_ordinal += 1;
2347             current_len = prefix.len;
2348             @memcpy(current_key[0..prefix.len], prefix);
2349             run_start = ordinal;
2350             run_count = 1;
2351         }
2352         ordinal += 1;
2353     }
2354     if (current_valid) try appendDistributionSamples(&samples, allocator, measure.entries, sample_target, &target_index, &sample_count, current_key[0..current_len], run_start, run_count, distinct_ordinal);
2355 
2356     try appendUsizeAsU16(encoded, allocator, prefix_count);
2357     try appendUsizeAsU64(encoded, allocator, measure.distinct_values);
2358     try appendUsizeAsU64(encoded, allocator, measure.max_equal);
2359     try appendUsizeAsU16(encoded, allocator, sample_count);
2360     try encoded.appendSlice(allocator, samples.items);
2361 }
2362 
2363 fn measureIndexDistribution(
2364     allocator: Allocator,
2365     source: anytype,
2366     prefix_count: usize,
2367 ) Error!DistributionMeasure {
2368     var measure = DistributionMeasure{};
2369     var scan: @TypeOf(source.*).Scan = undefined;
2370     try source.scan(&scan, allocator);
2371     defer scan.deinit();
2372 
2373     var current_key: [page.size]u8 = undefined;
2374     var current_len: usize = 0;
2375     var current_valid = false;
2376     var run_count: usize = 0;
2377 
2378     while (try scan.next()) |bytes| {
2379         var prefix_buffer: [page.size]u8 = undefined;
2380         const prefix = try indexPrefixKey(&prefix_buffer, source.columns(), bytes, prefix_count);
2381         measure.entries += 1;
2382         if (!current_valid) {
2383             current_valid = true;
2384             current_len = prefix.len;
2385             @memcpy(current_key[0..prefix.len], prefix);
2386             run_count = 1;
2387         } else if (std.mem.eql(u8, current_key[0..current_len], prefix)) {
2388             run_count += 1;
2389         } else {
2390             measure.distinct_values += 1;
2391             measure.max_equal = @max(measure.max_equal, run_count);
2392             current_len = prefix.len;
2393             @memcpy(current_key[0..prefix.len], prefix);
2394             run_count = 1;
2395         }
2396     }
2397     if (current_valid) {
2398         measure.distinct_values += 1;
2399         measure.max_equal = @max(measure.max_equal, run_count);
2400     }
2401     return measure;
2402 }
2403 
2404 fn indexPrefixKey(target: *[page.size]u8, columns: []const row.Column, bytes: []const u8, prefix_count: usize) Error![]const u8 {
2405     var values: [relation_mod.max_index_fields]row.Value = undefined;
2406     var scratch: [page.size]u8 = undefined;
2407     const decoded = try key.decodeIndex(&values, &scratch, bytes);
2408     if (prefix_count == 0 or prefix_count > decoded.values.len) return error.CatalogCorrupt;
2409     return try key.encodeIndexPrefix(target, decoded.values[0..prefix_count], columns);
2410 }
2411 
2412 fn appendDistributionSamples(
2413     bytes: *std.ArrayList(u8),
2414     allocator: Allocator,
2415     entries: usize,
2416     sample_target: usize,
2417     target_index: *usize,
2418     sample_count: *usize,
2419     sample_key: []const u8,
2420     less_than: usize,
2421     equal_count: usize,
2422     less_distinct: usize,
2423 ) Error!void {
2424     const end = less_than + equal_count;
2425     var sampled = false;
2426     while (target_index.* < sample_target) {
2427         const target = sampleOrdinal(target_index.*, entries, sample_target);
2428         if (target < less_than) {
2429             target_index.* += 1;
2430             continue;
2431         }
2432         if (target >= end) break;
2433         if (!sampled) {
2434             try appendUsizeAsU64(bytes, allocator, less_than);
2435             try appendUsizeAsU64(bytes, allocator, equal_count);
2436             try appendUsizeAsU64(bytes, allocator, less_distinct);
2437             try appendUsizeAsU16(bytes, allocator, sample_key.len);
2438             try bytes.appendSlice(allocator, sample_key);
2439             sample_count.* += 1;
2440             sampled = true;
2441         }
2442         target_index.* += 1;
2443     }
2444 }
2445 
2446 fn sampleOrdinal(sample_index: usize, entries: usize, sample_count: usize) usize {
2447     const base = entries / sample_count;
2448     const remainder = entries % sample_count;
2449     const start = sample_index * base + @min(sample_index, remainder);
2450     const width = base + if (sample_index < remainder) @as(usize, 1) else 0;
2451     return @min(entries - 1, start + width / 2);
2452 }
2453 
2454 fn decodeIndexDistribution(allocator: Allocator, bytes: []const u8) Error!IndexDistribution {
2455     if (bytes.len == 0) return .{};
2456     if (bytes.len < distribution_header_bytes) return error.CatalogCorrupt;
2457     if (std.mem.readInt(u32, bytes[0..4], .big) != distribution_format) return error.CatalogCorrupt;
2458     const prefix_total = std.mem.readInt(u16, bytes[4..6], .big);
2459     if (prefix_total == 0 or prefix_total > relation_mod.max_index_fields) return error.CatalogCorrupt;
2460 
2461     var cursor: usize = distribution_header_bytes;
2462     const prefixes = try allocator.alloc(IndexPrefixDistribution, prefix_total);
2463     for (prefixes) |*prefix| prefix.* = .{};
2464     errdefer freeIndexPrefixDistributions(allocator, prefixes);
2465 
2466     var prefix_index: usize = 0;
2467     while (prefix_index < prefix_total) : (prefix_index += 1) {
2468         prefixes[prefix_index] = try decodeIndexPrefixDistribution(allocator, bytes, &cursor);
2469     }
2470     if (cursor != bytes.len) return error.CatalogCorrupt;
2471 
2472     const leading = prefixes[0];
2473     return .{
2474         .distinct_values = leading.distinct_values,
2475         .max_equal = leading.max_equal,
2476         .samples = leading.samples,
2477         .sample_keys = leading.sample_keys,
2478         .prefixes = prefixes,
2479     };
2480 }
2481 
2482 fn decodeIndexPrefixDistribution(allocator: Allocator, bytes: []const u8, cursor: *usize) Error!IndexPrefixDistribution {
2483     if (distribution_prefix_header_bytes > bytes.len - cursor.*) return error.CatalogCorrupt;
2484     const field_count = std.mem.readInt(u16, bytes[cursor.*..][0..2], .big);
2485     cursor.* += 2;
2486     if (field_count == 0 or field_count > relation_mod.max_index_fields) return error.CatalogCorrupt;
2487     const distinct_values = try readUsizeField(bytes[cursor.*..][0..8]);
2488     cursor.* += 8;
2489     const max_equal = try readUsizeField(bytes[cursor.*..][0..8]);
2490     cursor.* += 8;
2491     const sample_count = std.mem.readInt(u16, bytes[cursor.*..][0..2], .big);
2492     cursor.* += 2;
2493 
2494     var scan_cursor = cursor.*;
2495     var key_bytes: usize = 0;
2496     var sample_index: usize = 0;
2497     while (sample_index < sample_count) : (sample_index += 1) {
2498         if (distribution_sample_header_bytes > bytes.len - scan_cursor) return error.CatalogCorrupt;
2499         scan_cursor += 24;
2500         const key_len = std.mem.readInt(u16, bytes[scan_cursor..][0..2], .big);
2501         scan_cursor += 2;
2502         if (key_len > bytes.len - scan_cursor) return error.CatalogCorrupt;
2503         key_bytes += key_len;
2504         scan_cursor += key_len;
2505     }
2506 
2507     const samples: []IndexSample = if (sample_count == 0)
2508         &.{}
2509     else
2510         try allocator.alloc(IndexSample, sample_count);
2511     errdefer if (sample_count != 0) allocator.free(samples);
2512     const keys: []u8 = if (key_bytes == 0)
2513         &.{}
2514     else
2515         try allocator.alloc(u8, key_bytes);
2516     errdefer if (key_bytes != 0) allocator.free(keys);
2517 
2518     var key_cursor: usize = 0;
2519     sample_index = 0;
2520     while (sample_index < sample_count) : (sample_index += 1) {
2521         const less_than = try readUsizeField(bytes[cursor.*..][0..8]);
2522         cursor.* += 8;
2523         const equal_count = try readUsizeField(bytes[cursor.*..][0..8]);
2524         cursor.* += 8;
2525         const less_distinct = try readUsizeField(bytes[cursor.*..][0..8]);
2526         cursor.* += 8;
2527         const key_len = std.mem.readInt(u16, bytes[cursor.*..][0..2], .big);
2528         cursor.* += 2;
2529         @memcpy(keys[key_cursor..][0..key_len], bytes[cursor.*..][0..key_len]);
2530         samples[sample_index] = .{
2531             .key = keys[key_cursor..][0..key_len],
2532             .less_than = less_than,
2533             .equal_count = equal_count,
2534             .less_distinct = less_distinct,
2535         };
2536         key_cursor += key_len;
2537         cursor.* += key_len;
2538     }
2539 
2540     return .{
2541         .field_count = field_count,
2542         .distinct_values = distinct_values,
2543         .max_equal = max_equal,
2544         .samples = samples,
2545         .sample_keys = keys,
2546     };
2547 }
2548 
2549 fn appendUsizeAsU64(bytes: *std.ArrayList(u8), allocator: Allocator, value: usize) Error!void {
2550     if (value > std.math.maxInt(u64)) return error.CatalogCorrupt;
2551     try appendInt(u64, bytes, allocator, @intCast(value));
2552 }
2553 
2554 fn appendUsizeAsU16(bytes: *std.ArrayList(u8), allocator: Allocator, value: usize) Error!void {
2555     var buffer: [2]u8 = undefined;
2556     try writeUsizeAsU16(&buffer, value);
2557     try bytes.appendSlice(allocator, &buffer);
2558 }
2559 
2560 fn writeUsizeAsU16(target: []u8, value: usize) Error!void {
2561     if (target.len < 2) return error.CatalogCorrupt;
2562     if (value > std.math.maxInt(u16)) return error.CatalogCorrupt;
2563     std.mem.writeInt(u16, target[0..2], @intCast(value), .big);
2564 }
2565 
2566 fn appendInt(comptime T: type, bytes: *std.ArrayList(u8), allocator: Allocator, value: T) std.mem.Allocator.Error!void {
2567     var buffer: [@sizeOf(T)]u8 = undefined;
2568     std.mem.writeInt(T, &buffer, value, .big);
2569     try bytes.appendSlice(allocator, &buffer);
2570 }
2571 
2572 fn readUsizeField(bytes: []const u8) Error!usize {
2573     if (bytes.len < 8) return error.CatalogCorrupt;
2574     const value = std.mem.readInt(u64, bytes[0..8], .big);
2575     if (value > std.math.maxInt(usize)) return error.CatalogCorrupt;
2576     return @intCast(value);
2577 }
2578 
2579 fn encodeDefinitions(target: *[page.size]u8, definitions: []const ColumnDefinition) Error![]const u8 {
2580     if (definitions.len > max_columns) return error.TooManyColumns;
2581     var cursor: usize = 0;
2582     for (definitions) |definition| {
2583         if (definition.name.len == 0) return error.CatalogCorrupt;
2584         if (definition.name.len > std.math.maxInt(u16)) return error.CatalogCorrupt;
2585         var default_buffer: [page.size]u8 = undefined;
2586         const encoded_default = try row.encode(&default_buffer, &.{definition.default});
2587         if (encoded_default.len > std.math.maxInt(u16)) return error.CatalogCorrupt;
2588         const required = column_name_len_bytes + definition.name.len + 1 + default_len_bytes + encoded_default.len;
2589         if (required > target.len - cursor) return error.CatalogCorrupt;
2590         std.mem.writeInt(u16, target[cursor..][0..column_name_len_bytes], @intCast(definition.name.len), .big);
2591         cursor += column_name_len_bytes;
2592         @memcpy(target[cursor..][0..definition.name.len], definition.name);
2593         cursor += definition.name.len;
2594         target[cursor] = collationByte(definition.column.collation);
2595         cursor += 1;
2596         std.mem.writeInt(u16, target[cursor..][0..default_len_bytes], @intCast(encoded_default.len), .big);
2597         cursor += default_len_bytes;
2598         @memcpy(target[cursor..][0..encoded_default.len], encoded_default);
2599         cursor += encoded_default.len;
2600     }
2601     return target[0..cursor];
2602 }
2603 
2604 fn decodeDefinitions(bytes: []const u8, target: *[max_columns]ColumnDefinition, names_target: *[max_column_name_bytes]u8, defaults_target: *[max_column_default_bytes]u8) Error![]ColumnDefinition {
2605     var cursor: usize = 0;
2606     var name_cursor: usize = 0;
2607     var default_cursor: usize = 0;
2608     var count: usize = 0;
2609     while (cursor < bytes.len) {
2610         if (count >= target.len) return error.TooManyColumns;
2611         if (column_name_len_bytes > bytes.len - cursor) return error.CatalogCorrupt;
2612         const name_len = std.mem.readInt(u16, bytes[cursor..][0..column_name_len_bytes], .big);
2613         cursor += column_name_len_bytes;
2614         if (name_len == 0) return error.CatalogCorrupt;
2615         const name_required = @as(usize, name_len) + 1 + default_len_bytes;
2616         if (name_required > bytes.len - cursor) return error.CatalogCorrupt;
2617         if (name_len > names_target.len - name_cursor) return error.CatalogCorrupt;
2618         @memcpy(names_target[name_cursor..][0..name_len], bytes[cursor..][0..name_len]);
2619         const name = names_target[name_cursor..][0..name_len];
2620         cursor += name_len;
2621         name_cursor += name_len;
2622         const collation = try collationFromByte(bytes[cursor]);
2623         cursor += 1;
2624         const default_len = std.mem.readInt(u16, bytes[cursor..][0..default_len_bytes], .big);
2625         cursor += default_len_bytes;
2626         if (default_len > bytes.len - cursor) return error.CatalogCorrupt;
2627         if (default_len > defaults_target.len - default_cursor) return error.CatalogCorrupt;
2628         @memcpy(defaults_target[default_cursor..][0..default_len], bytes[cursor..][0..default_len]);
2629         const default_bytes = defaults_target[default_cursor..][0..default_len];
2630         const default_value = try defaultValue(default_bytes);
2631         cursor += default_len;
2632         default_cursor += default_len;
2633         for (target[0..count]) |previous| {
2634             if (std.ascii.eqlIgnoreCase(previous.name, name)) return error.CatalogCorrupt;
2635         }
2636         target[count] = .{
2637             .name = name,
2638             .column = .{ .collation = collation },
2639             .default = default_value,
2640         };
2641         count += 1;
2642     }
2643     return target[0..count];
2644 }
2645 
2646 const DefinitionCopy = struct {
2647     names: usize,
2648     defaults: usize,
2649 };
2650 
2651 fn copyDefinitions(target: *[max_columns]ColumnDefinition, names_target: *[max_column_name_bytes]u8, defaults_target: *[max_column_default_bytes]u8, definitions: []const ColumnDefinition) Error!DefinitionCopy {
2652     if (definitions.len > target.len) return error.TooManyColumns;
2653     var name_cursor: usize = 0;
2654     var default_cursor: usize = 0;
2655     for (definitions, 0..) |definition, offset| {
2656         if (definition.name.len > names_target.len - name_cursor) return error.CatalogCorrupt;
2657         @memcpy(names_target[name_cursor..][0..definition.name.len], definition.name);
2658         var default_buffer: [page.size]u8 = undefined;
2659         const encoded_default = try row.encode(&default_buffer, &.{definition.default});
2660         if (encoded_default.len > defaults_target.len - default_cursor) return error.CatalogCorrupt;
2661         @memcpy(defaults_target[default_cursor..][0..encoded_default.len], encoded_default);
2662         const default_value = try defaultValue(defaults_target[default_cursor..][0..encoded_default.len]);
2663         target[offset] = .{
2664             .name = names_target[name_cursor..][0..definition.name.len],
2665             .column = definition.column,
2666             .default = default_value,
2667         };
2668         name_cursor += definition.name.len;
2669         default_cursor += encoded_default.len;
2670     }
2671     return .{
2672         .names = name_cursor,
2673         .defaults = default_cursor,
2674     };
2675 }
2676 
2677 fn copyOpenedDefinitions(target: []ColumnDefinition, names_target: []u8, defaults_target: []u8, definitions: []const ColumnDefinition) void {
2678     var name_cursor: usize = 0;
2679     var default_cursor: usize = 0;
2680     for (definitions, 0..) |definition, offset| {
2681         @memcpy(names_target[name_cursor..][0..definition.name.len], definition.name);
2682         var default_buffer: [page.size]u8 = undefined;
2683         const encoded_default = row.encode(&default_buffer, &.{definition.default}) catch unreachable;
2684         @memcpy(defaults_target[default_cursor..][0..encoded_default.len], encoded_default);
2685         const default_value = defaultValue(defaults_target[default_cursor..][0..encoded_default.len]) catch unreachable;
2686         target[offset] = .{
2687             .name = names_target[name_cursor..][0..definition.name.len],
2688             .column = definition.column,
2689             .default = default_value,
2690         };
2691         name_cursor += definition.name.len;
2692         default_cursor += encoded_default.len;
2693     }
2694 }
2695 
2696 fn defaultValue(bytes: []const u8) Error!row.Value {
2697     const view = try row.View.init(bytes);
2698     if (view.columnCount() != 1) return error.CatalogCorrupt;
2699     return try view.column(0);
2700 }
2701 
2702 fn encodeFields(target: *[relation_mod.max_index_fields * field_bytes]u8, fields: []const usize) Error![]const u8 {
2703     if (fields.len > relation_mod.max_index_fields) return error.TooManyIndexFields;
2704     for (fields, 0..) |field, offset| {
2705         if (field > std.math.maxInt(u32)) return error.CatalogCorrupt;
2706         std.mem.writeInt(u32, target[offset * field_bytes ..][0..field_bytes], @intCast(field), .big);
2707     }
2708     return target[0 .. fields.len * field_bytes];
2709 }
2710 
2711 fn decodeFields(bytes: []const u8, target: *[relation_mod.max_index_fields]usize) Error![]usize {
2712     if (bytes.len % field_bytes != 0) return error.CatalogCorrupt;
2713     const count = bytes.len / field_bytes;
2714     if (count > target.len) return error.TooManyIndexFields;
2715     var index: usize = 0;
2716     while (index < count) : (index += 1) {
2717         target[index] = std.mem.readInt(u32, bytes[index * field_bytes ..][0..field_bytes], .big);
2718     }
2719     return target[0..count];
2720 }
2721 
2722 fn encodeCollations(target: *[relation_mod.max_index_fields]u8, field_count: usize, columns: []const row.Column) Error![]const u8 {
2723     if (field_count > target.len) return error.TooManyIndexFields;
2724     if (columns.len > field_count) return error.CatalogCorrupt;
2725     var index: usize = 0;
2726     while (index < field_count) : (index += 1) {
2727         const column = if (index < columns.len) columns[index] else row.Column{};
2728         target[index] = collationByte(column.collation);
2729     }
2730     return target[0..field_count];
2731 }
2732 
2733 fn decodeCollations(bytes: []const u8, field_count: usize, target: *[relation_mod.max_index_fields]row.Column) Error![]row.Column {
2734     if (bytes.len != field_count) return error.CatalogCorrupt;
2735     if (field_count > target.len) return error.TooManyIndexFields;
2736     var index: usize = 0;
2737     while (index < field_count) : (index += 1) {
2738         target[index] = .{ .collation = try collationFromByte(bytes[index]) };
2739     }
2740     return target[0..field_count];
2741 }
2742 
2743 fn collationByte(collation: row.Collation) u8 {
2744     return switch (collation) {
2745         .binary => 0,
2746         .nocase => 1,
2747         .rtrim => 2,
2748     };
2749 }
2750 
2751 fn collationFromByte(byte: u8) Error!row.Collation {
2752     return switch (byte) {
2753         0 => .binary,
2754         1 => .nocase,
2755         2 => .rtrim,
2756         else => error.CatalogCorrupt,
2757     };
2758 }
2759 
2760 fn freeAnalyzeIndexes(allocator: Allocator, indexes: []const AnalyzeIndex) void {
2761     for (indexes) |index_object| {
2762         allocator.free(index_object.name);
2763         if (index_object.distribution_blob.len != 0) allocator.free(index_object.distribution_blob);
2764     }
2765 }
2766 
2767 fn freeIndexStats(allocator: Allocator, indexes: []const IndexStats) void {
2768     for (indexes) |index_stats| {
2769         allocator.free(index_stats.name);
2770         freeIndexDistribution(allocator, index_stats.distribution);
2771     }
2772 }
2773 
2774 fn freeIndexDistribution(allocator: Allocator, distribution: IndexDistribution) void {
2775     if (distribution.prefixes.len != 0) {
2776         freeIndexPrefixDistributions(allocator, distribution.prefixes);
2777         return;
2778     }
2779     if (distribution.samples.len != 0) allocator.free(distribution.samples);
2780     if (distribution.sample_keys.len != 0) allocator.free(distribution.sample_keys);
2781 }
2782 
2783 fn freeIndexPrefixDistributions(allocator: Allocator, prefixes: []IndexPrefixDistribution) void {
2784     for (prefixes) |prefix| {
2785         if (prefix.samples.len != 0) allocator.free(prefix.samples);
2786         if (prefix.sample_keys.len != 0) allocator.free(prefix.sample_keys);
2787     }
2788     allocator.free(prefixes);
2789 }
2790 
2791 fn containsIndexStats(indexes: []const IndexStats, name: []const u8) bool {
2792     for (indexes) |index_stats| {
2793         if (std.mem.eql(u8, index_stats.name, name)) return true;
2794     }
2795     return false;
2796 }
2797 
2798 fn addRoot(roots: *[space_mod.max_roots]space_mod.RootSpec, root_count: *usize, spec: space_mod.RootSpec) Error!void {
2799     if (spec.root_page == 0) return error.InvalidPageId;
2800     var index: usize = 0;
2801     while (index < root_count.*) : (index += 1) {
2802         if (roots[index].root_page == spec.root_page) return error.InvalidPageId;
2803     }
2804     if (root_count.* >= roots.len) return error.TooManyRoots;
2805     roots[root_count.*] = spec;
2806     root_count.* += 1;
2807 }
2808 
2809 fn allocateCatalogRoot(write: *tree.Write, roots: *[space_mod.max_roots]space_mod.RootSpec, root_count: *usize) Error!space_mod.RootSpec {
2810     const root_page = try write.allocateRoot();
2811     const identity_page = try write.allocateRoot();
2812     const spec = space_mod.RootSpec{ .root_page = root_page, .identity_page = identity_page };
2813     try addRoot(roots, root_count, spec);
2814     return spec;
2815 }
2816 
2817 test "catalog reader exposes one snapshot through query-only methods" {
2818     var tmp = std.testing.tmpDir(.{});
2819     defer tmp.cleanup();
2820 
2821     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
2822         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
2823         .header = testingHeader(),
2824     });
2825     defer database.deinit();
2826     try database.reserve(.{ .wal_frames = 160 });
2827 
2828     var catalog = try Catalog.open(&database, .{});
2829     const indexes = [_]IndexDefinition{.{
2830         .name = "items_value",
2831         .fields = &.{0},
2832     }};
2833     const created = try catalog.createRelation(std.testing.allocator, .{
2834         .name = "items",
2835         .indexes = &indexes,
2836     }, .{ .durability = .buffered });
2837     {
2838         var writable = try catalog.openRelation(std.testing.allocator, "items");
2839         defer writable.deinit();
2840         _ = try writable.relation.put(
2841             std.testing.allocator,
2842             7,
2843             &.{.{ .integer = 42 }},
2844             .{ .durability = .buffered },
2845         );
2846     }
2847 
2848     var read = try database.beginRead();
2849     defer read.deinit();
2850     const reader = try Reader.open(read.snapshot(), .{});
2851     try std.testing.expectEqual(created.schema, try reader.schemaState(std.testing.allocator));
2852     var names = try reader.relationNames(std.testing.allocator);
2853     defer names.deinit();
2854     try std.testing.expectEqual(@as(usize, 1), names.names.len);
2855     try std.testing.expectEqualSlices(u8, "items", names.names[0]);
2856     var opened = try reader.openRelation(std.testing.allocator, "items");
2857     defer opened.deinit();
2858     const bytes = (try opened.relation.get(std.testing.allocator, 7)).?;
2859     defer std.testing.allocator.free(bytes);
2860     const view = try row.View.init(bytes);
2861     try std.testing.expectEqual(@as(i64, 42), (try view.column(0)).integer);
2862     var lookup: index_mod.Scan = undefined;
2863     try opened.relation.lookup(&lookup, std.testing.allocator, 0, &.{.{ .integer = 42 }});
2864     defer lookup.deinit();
2865     try std.testing.expectEqual(@as(i64, 7), (try lookup.next()).?.rowid);
2866     try std.testing.expect(try lookup.next() == null);
2867     try std.testing.expect((try reader.relationStats(std.testing.allocator, "items")) == null);
2868     try std.testing.expect(!@hasDecl(Reader, "createRelation"));
2869     try std.testing.expect(!@hasDecl(Reader, "analyzeRelation"));
2870     try std.testing.expect(!@hasDecl(Reader, "dropRelation"));
2871 }
2872 
2873 test "catalog reads the schema, handle and stats of one relation in one pass" {
2874     var tmp = std.testing.tmpDir(.{});
2875     defer tmp.cleanup();
2876 
2877     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
2878         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
2879         .header = testingHeader(),
2880     });
2881     defer database.deinit();
2882     try database.reserve(.{ .wal_frames = 640 });
2883 
2884     var catalog = try Catalog.open(&database, .{});
2885     const buffered: file.CommitOptions = .{ .durability = .buffered };
2886     _ = try catalog.createRelation(std.testing.allocator, .{
2887         .name = "items",
2888         .columns = &.{.{ .name = "value" }},
2889     }, buffered);
2890     _ = try catalog.createRelation(std.testing.allocator, .{
2891         .name = "other",
2892         .indexes = &.{.{ .name = "other_value", .fields = &.{0} }},
2893     }, buffered);
2894     _ = try catalog.createIndex(std.testing.allocator, "items", .{
2895         .name = "items_value",
2896         .fields = &.{0},
2897     }, buffered);
2898     {
2899         var writable = try catalog.openRelation(std.testing.allocator, "items");
2900         defer writable.deinit();
2901         _ = try writable.relation.put(std.testing.allocator, 1, &.{.{ .integer = 3 }}, buffered);
2902         _ = try writable.relation.put(std.testing.allocator, 2, &.{.{ .integer = 5 }}, buffered);
2903     }
2904     _ = try catalog.analyzeRelation(std.testing.allocator, "items", buffered);
2905 
2906     var items = try catalog.readRelation(std.testing.allocator, "items");
2907     defer items.deinit();
2908     try expectRelationState(&catalog, "items", &items);
2909     try std.testing.expectEqual(@as(usize, 1), items.handle.index_definitions.len);
2910     try std.testing.expectEqual(@as(usize, 2), items.relationStats().?.table.entries);
2911 
2912     var other = try catalog.readRelation(std.testing.allocator, "other");
2913     defer other.deinit();
2914     try expectRelationState(&catalog, "other", &other);
2915     try std.testing.expectEqualStrings("other_value", other.handle.index_definitions[0].name);
2916     try std.testing.expect(other.relationStats() == null);
2917     try std.testing.expectError(
2918         error.RelationNotFound,
2919         catalog.readRelation(std.testing.allocator, "missing"),
2920     );
2921 
2922     var read = try database.beginRead();
2923     defer read.deinit();
2924     const reader = try Reader.open(read.snapshot(), .{});
2925     var read_items = try reader.readRelation(std.testing.allocator, "items");
2926     defer read_items.deinit();
2927     try expectRelationState(&reader, "items", &read_items);
2928     try std.testing.expectError(
2929         error.RelationNotFound,
2930         reader.readRelation(std.testing.allocator, "missing"),
2931     );
2932 }
2933 
2934 /// Checks that `state` holds what the separate readers of `catalog` return
2935 /// for `name`.
2936 fn expectRelationState(catalog: anytype, name: []const u8, state: anytype) !void {
2937     try std.testing.expectEqual(try catalog.schemaState(std.testing.allocator), state.schema);
2938     var handle = try catalog.openRelation(std.testing.allocator, name);
2939     defer handle.deinit();
2940     try std.testing.expectEqual(
2941         handle.relation.table.rows.root_page,
2942         state.handle.relation.table.rows.root_page,
2943     );
2944     try std.testing.expectEqual(handle.definitions.len, state.handle.definitions.len);
2945     try std.testing.expectEqual(handle.specs.len, state.handle.specs.len);
2946     for (handle.specs, state.handle.specs) |expected, actual| {
2947         try std.testing.expectEqual(expected.root_page, actual.root_page);
2948         try std.testing.expectEqualSlices(usize, expected.fields, actual.fields);
2949     }
2950     try std.testing.expectEqual(
2951         handle.index_definitions.len,
2952         state.handle.index_definitions.len,
2953     );
2954     for (handle.index_definitions, state.handle.index_definitions) |expected, actual| {
2955         try std.testing.expectEqualStrings(expected.name, actual.name);
2956     }
2957 
2958     var stats = try catalog.relationStats(std.testing.allocator, name);
2959     defer if (stats) |*relation_stats| relation_stats.deinit();
2960     const actual_stats = state.relationStats() orelse {
2961         try std.testing.expect(stats == null);
2962         return;
2963     };
2964     const expected_stats = stats orelse return error.TestUnexpectedResult;
2965     try std.testing.expectEqual(expected_stats.table_root_page, actual_stats.table_root_page);
2966     try std.testing.expectEqual(expected_stats.table, actual_stats.table);
2967     try std.testing.expectEqual(expected_stats.indexes.len, actual_stats.indexes.len);
2968     for (expected_stats.indexes, actual_stats.indexes) |expected, actual| {
2969         try std.testing.expectEqualStrings(expected.name, actual.name);
2970         try std.testing.expectEqual(expected.root_page, actual.root_page);
2971         try std.testing.expectEqual(expected.summary, actual.summary);
2972         try std.testing.expectEqual(
2973             expected.distribution.distinct_values,
2974             actual.distribution.distinct_values,
2975         );
2976     }
2977 }
2978 
2979 test "catalog rejects stats that disagree with their relation and rows without a schema" {
2980     const damages = [_]CatalogDamage{
2981         .moved_table_stats,
2982         .orphaned_index_stats,
2983         .missing_schema_row,
2984     };
2985     for (damages) |damage| {
2986         var tmp = std.testing.tmpDir(.{});
2987         defer tmp.cleanup();
2988 
2989         var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
2990             .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
2991             .header = testingHeader(),
2992         });
2993         defer database.deinit();
2994         try database.reserve(.{ .wal_frames = 640 });
2995 
2996         var catalog = try Catalog.open(&database, .{});
2997         const buffered: file.CommitOptions = .{ .durability = .buffered };
2998         _ = try catalog.createRelation(std.testing.allocator, .{
2999             .name = "items",
3000             .indexes = &.{.{ .name = "items_value", .fields = &.{0} }},
3001         }, buffered);
3002         {
3003             var writable = try catalog.openRelation(std.testing.allocator, "items");
3004             defer writable.deinit();
3005             _ = try writable.relation.put(
3006                 std.testing.allocator,
3007                 1,
3008                 &.{.{ .integer = 3 }},
3009                 buffered,
3010             );
3011         }
3012         _ = try catalog.analyzeRelation(std.testing.allocator, "items", buffered);
3013         try damageCatalog(&database, damage);
3014 
3015         try std.testing.expectError(
3016             error.CatalogCorrupt,
3017             catalog.readRelation(std.testing.allocator, "items"),
3018         );
3019         switch (damage) {
3020             .moved_table_stats, .orphaned_index_stats => try std.testing.expectError(
3021                 error.CatalogCorrupt,
3022                 catalog.relationStats(std.testing.allocator, "items"),
3023             ),
3024             .missing_schema_row => try std.testing.expectError(
3025                 error.CatalogCorrupt,
3026                 catalog.schemaState(std.testing.allocator),
3027             ),
3028         }
3029     }
3030 }
3031 
3032 const CatalogDamage = enum {
3033     /// The table stats row names a root other than the relation's.
3034     moved_table_stats,
3035     /// Index stats rows remain without the table stats row.
3036     orphaned_index_stats,
3037     /// Object rows remain without the schema row.
3038     missing_schema_row,
3039 };
3040 
3041 /// Rewrites the catalog rows of an analyzed relation so that `damage` holds.
3042 fn damageCatalog(database: *file.Database, damage: CatalogDamage) !void {
3043     const space = try space_mod.Space.open(database, .{
3044         .meta_page = default_meta_page,
3045         .roots = &.{.{ .root_page = default_root_page }},
3046     });
3047     var schema = try space.rowidTable(default_root_page);
3048     var write = try space.beginWrite();
3049     defer write.deinit();
3050     switch (damage) {
3051         .missing_schema_row => try schema.deleteIn(&write, schema_rowid),
3052         .moved_table_stats, .orphaned_index_stats => {
3053             var scan: table.Scan = undefined;
3054             try schema.scan(&scan, std.testing.allocator, null, null);
3055             defer scan.deinit();
3056             while (try scan.next()) |entry| {
3057                 var scratch = CatalogScratch{};
3058                 const table_stats = switch (try catalogEntry(entry.rowid, entry.bytes, &scratch)) {
3059                     .stats => |found| found,
3060                     .schema, .object => continue,
3061                 };
3062                 if (!std.mem.eql(u8, table_stats.name, table_stats.table_name)) continue;
3063                 if (damage == .orphaned_index_stats) {
3064                     try schema.deleteIn(&write, entry.rowid);
3065                     continue;
3066                 }
3067                 var summary_buffer: [summary_blob_bytes]u8 = undefined;
3068                 try putCatalogRow(
3069                     &schema,
3070                     &write,
3071                     entry.rowid,
3072                     stats_kind,
3073                     table_stats.name,
3074                     table_stats.table_name,
3075                     table_stats.root_page + 1,
3076                     0,
3077                     try encodeSummary(&summary_buffer, table_stats.summary),
3078                     "",
3079                 );
3080             }
3081         },
3082     }
3083     _ = try write.commit(.{ .durability = .buffered });
3084 }
3085 
3086 test "catalog persists relation metadata and reopens without caller specs" {
3087     var tmp = std.testing.tmpDir(.{});
3088     defer tmp.cleanup();
3089 
3090     {
3091         var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3092             .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3093             .header = testingHeader(),
3094         });
3095         defer database.deinit();
3096         try database.reserve(.{ .wal_frames = 220 });
3097 
3098         var catalog = try Catalog.open(&database, .{});
3099         try std.testing.expectEqual(Schema{}, try catalog.schemaState(std.testing.allocator));
3100         const indexes = [_]IndexDefinition{.{
3101             .name = "items_by_name",
3102             .fields = &.{1},
3103             .columns = &.{.{ .collation = .nocase }},
3104         }};
3105         const columns = [_]ColumnDefinition{
3106             .{ .name = "id" },
3107             .{ .name = "name", .column = .{ .collation = .nocase }, .default = .{ .text = "unknown" } },
3108         };
3109         const commit = try catalog.createRelation(std.testing.allocator, .{
3110             .name = "items",
3111             .columns = &columns,
3112             .indexes = &indexes,
3113         }, .{ .durability = .buffered });
3114         try std.testing.expectEqual(@as(u64, 1), commit.schema.version);
3115         try std.testing.expectEqual(commit.schema, try catalog.schemaState(std.testing.allocator));
3116         var handle = try catalog.openRelation(std.testing.allocator, "items");
3117         defer handle.deinit();
3118         try std.testing.expectEqual(@as(u32, 3), handle.relation.table.rows.root_page);
3119         try std.testing.expectEqual(@as(usize, 2), handle.definitions.len);
3120         try std.testing.expectEqualStrings("id", handle.definitions[0].name);
3121         try std.testing.expectEqualStrings("name", handle.definitions[1].name);
3122         try std.testing.expectEqual(row.Collation.nocase, handle.definitions[1].column.collation);
3123         try std.testing.expectEqualStrings("unknown", handle.definitions[1].default.text);
3124         try std.testing.expectEqual(@as(usize, 1), handle.index_definitions.len);
3125         try std.testing.expectEqualStrings("items_by_name", handle.index_definitions[0].name);
3126         try std.testing.expectEqual(@as(usize, 1), handle.index_definitions[0].fields.len);
3127         try std.testing.expectEqual(@as(usize, 1), handle.index_definitions[0].fields[0]);
3128         try std.testing.expectEqual(row.Collation.nocase, handle.index_definitions[0].columns[0].collation);
3129         _ = try handle.relation.put(std.testing.allocator, 8, &.{ .{ .integer = 8 }, .{ .text = "Alpha" } }, .{ .durability = .buffered });
3130         try database.syncWal();
3131     }
3132 
3133     var reopened = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3134         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3135         .header = recoveredHeader(),
3136     });
3137     defer reopened.deinit();
3138 
3139     var catalog = try Catalog.open(&reopened, .{});
3140     try std.testing.expectEqual(@as(u64, 1), (try catalog.schemaState(std.testing.allocator)).version);
3141     var handle = try catalog.openRelation(std.testing.allocator, "items");
3142     defer handle.deinit();
3143     try std.testing.expectEqual(@as(usize, 2), handle.definitions.len);
3144     try std.testing.expectEqualStrings("id", handle.definitions[0].name);
3145     try std.testing.expectEqualStrings("name", handle.definitions[1].name);
3146     try std.testing.expectEqual(row.Collation.nocase, handle.definitions[1].column.collation);
3147     try std.testing.expectEqualStrings("unknown", handle.definitions[1].default.text);
3148     try std.testing.expectEqual(@as(usize, 1), handle.index_definitions.len);
3149     try std.testing.expectEqualStrings("items_by_name", handle.index_definitions[0].name);
3150 
3151     var lookup: index_mod.Scan = undefined;
3152     try handle.relation.lookup(&lookup, std.testing.allocator, 0, &.{.{ .text = "alpha" }});
3153     defer lookup.deinit();
3154     const entry = (try lookup.next()).?;
3155     try std.testing.expectEqual(@as(i64, 8), entry.rowid);
3156     try std.testing.expect(try lookup.next() == null);
3157 
3158     const bytes = (try handle.relation.get(std.testing.allocator, 8)).?;
3159     defer std.testing.allocator.free(bytes);
3160     const view = try row.View.init(bytes);
3161     try std.testing.expectEqual(@as(i64, 8), (try view.column(0)).integer);
3162     try std.testing.expectEqualStrings("Alpha", (try view.column(1)).text);
3163 }
3164 
3165 test "catalog analyzes relation stats and reopens them without schema bump" {
3166     var tmp = std.testing.tmpDir(.{});
3167     defer tmp.cleanup();
3168 
3169     {
3170         var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3171             .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3172             .header = testingHeader(),
3173         });
3174         defer database.deinit();
3175         try database.reserve(.{ .wal_frames = 420 });
3176 
3177         var catalog = try Catalog.open(&database, .{});
3178         const indexes = [_]IndexDefinition{.{
3179             .name = "items_value",
3180             .fields = &.{0},
3181         }};
3182         const created = try catalog.createRelation(std.testing.allocator, .{
3183             .name = "items",
3184             .indexes = &indexes,
3185         }, .{ .durability = .buffered });
3186         try std.testing.expect((try catalog.relationStats(std.testing.allocator, "items")) == null);
3187 
3188         var handle = try catalog.openRelation(std.testing.allocator, "items");
3189         defer handle.deinit();
3190         _ = try handle.relation.put(std.testing.allocator, 1, &.{ .{ .integer = 3 }, .{ .text = "three" } }, .{ .durability = .buffered });
3191         _ = try handle.relation.put(std.testing.allocator, 2, &.{ .{ .integer = 5 }, .{ .text = "five" } }, .{ .durability = .buffered });
3192         _ = try handle.relation.put(std.testing.allocator, 3, &.{ .{ .integer = 5 }, .{ .text = "cinco" } }, .{ .durability = .buffered });
3193 
3194         const analyzed = try catalog.analyzeRelation(std.testing.allocator, "items", .{ .durability = .buffered });
3195         try std.testing.expectEqual(created.schema, analyzed.schema);
3196         try std.testing.expectEqual(created.schema, try catalog.schemaState(std.testing.allocator));
3197 
3198         var stats = (try catalog.relationStats(std.testing.allocator, "items")).?;
3199         defer stats.deinit();
3200         try std.testing.expectEqual(handle.relation.table.rows.root_page, stats.table_root_page);
3201         try std.testing.expectEqual(@as(usize, 3), stats.table.entries);
3202         try std.testing.expectEqual(@as(usize, 3), stats.table.inline_records);
3203         const index_stats = stats.index("items_value").?;
3204         try std.testing.expectEqual(@as(usize, 3), index_stats.summary.entries);
3205         try std.testing.expect(index_stats.summary.key_bytes > stats.table.key_bytes);
3206         try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.distinct_values);
3207         try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.max_equal);
3208         try std.testing.expect(index_stats.distribution.samples.len > 0);
3209         try std.testing.expectEqual(@as(usize, 1), index_stats.distribution.prefixes.len);
3210         try std.testing.expectEqual(@as(usize, 1), index_stats.distribution.prefixes[0].field_count);
3211         try database.syncWal();
3212     }
3213 
3214     var reopened = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3215         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3216         .header = recoveredHeader(),
3217     });
3218     defer reopened.deinit();
3219 
3220     var catalog = try Catalog.open(&reopened, .{});
3221     try std.testing.expectEqual(@as(u64, 1), (try catalog.schemaState(std.testing.allocator)).version);
3222     var stats = (try catalog.relationStats(std.testing.allocator, "items")).?;
3223     defer stats.deinit();
3224     try std.testing.expectEqual(@as(usize, 3), stats.table.entries);
3225     try std.testing.expectEqual(@as(usize, 1), stats.indexes.len);
3226     const index_stats = stats.index("items_value").?;
3227     try std.testing.expectEqual(@as(usize, 3), index_stats.summary.entries);
3228     try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.distinct_values);
3229     try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.max_equal);
3230     try std.testing.expect(index_stats.distribution.samples.len > 0);
3231     try std.testing.expectEqual(@as(usize, 1), index_stats.distribution.prefixes.len);
3232 }
3233 
3234 test "catalog clears relation stats without schema bump" {
3235     var tmp = std.testing.tmpDir(.{});
3236     defer tmp.cleanup();
3237 
3238     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3239         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3240         .header = testingHeader(),
3241     });
3242     defer database.deinit();
3243     try database.reserve(.{ .wal_frames = 420 });
3244 
3245     var catalog = try Catalog.open(&database, .{});
3246     const indexes = [_]IndexDefinition{.{
3247         .name = "items_value",
3248         .fields = &.{0},
3249     }};
3250     const created = try catalog.createRelation(std.testing.allocator, .{
3251         .name = "items",
3252         .indexes = &indexes,
3253     }, .{ .durability = .buffered });
3254 
3255     var handle = try catalog.openRelation(std.testing.allocator, "items");
3256     defer handle.deinit();
3257     _ = try handle.relation.put(std.testing.allocator, 1, &.{.{ .integer = 3 }}, .{ .durability = .buffered });
3258     _ = try catalog.analyzeRelation(std.testing.allocator, "items", .{ .durability = .buffered });
3259 
3260     var stats = (try catalog.relationStats(std.testing.allocator, "items")).?;
3261     stats.deinit();
3262 
3263     const cleared = (try catalog.clearRelationStats(std.testing.allocator, "items", .{ .durability = .buffered })).?;
3264     try std.testing.expectEqual(created.schema, cleared.schema);
3265     try std.testing.expectEqual(created.schema, try catalog.schemaState(std.testing.allocator));
3266     try std.testing.expect((try catalog.relationStats(std.testing.allocator, "items")) == null);
3267     try std.testing.expect((try catalog.clearRelationStats(std.testing.allocator, "items", .{ .durability = .buffered })) == null);
3268     try std.testing.expectError(error.RelationNotFound, catalog.clearRelationStats(std.testing.allocator, "missing", .{ .durability = .buffered }));
3269 }
3270 
3271 test "catalog drops relation index and stats metadata" {
3272     var tmp = std.testing.tmpDir(.{});
3273     defer tmp.cleanup();
3274 
3275     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3276         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3277         .header = testingHeader(),
3278     });
3279     defer database.deinit();
3280     try database.reserve(.{ .wal_frames = 420 });
3281 
3282     var catalog = try Catalog.open(&database, .{});
3283     const indexes = [_]IndexDefinition{.{
3284         .name = "items_value",
3285         .fields = &.{0},
3286     }};
3287     const created = try catalog.createRelation(std.testing.allocator, .{
3288         .name = "items",
3289         .indexes = &indexes,
3290     }, .{ .durability = .buffered });
3291     try std.testing.expectEqual(@as(u64, 1), created.schema.version);
3292 
3293     var handle = try catalog.openRelation(std.testing.allocator, "items");
3294     defer handle.deinit();
3295     _ = try handle.relation.put(std.testing.allocator, 1, &.{.{ .integer = 3 }}, .{ .durability = .buffered });
3296     _ = try catalog.analyzeRelation(std.testing.allocator, "items", .{ .durability = .buffered });
3297 
3298     var stats = (try catalog.relationStats(std.testing.allocator, "items")).?;
3299     stats.deinit();
3300 
3301     const dropped = try catalog.dropRelation(std.testing.allocator, "items", .{ .durability = .buffered });
3302     try std.testing.expectEqual(@as(u64, 2), dropped.schema.version);
3303     try std.testing.expectEqual(dropped.schema, try catalog.schemaState(std.testing.allocator));
3304     try std.testing.expectError(error.RelationNotFound, catalog.openRelation(std.testing.allocator, "items"));
3305     try std.testing.expectError(error.RelationNotFound, catalog.relationStats(std.testing.allocator, "items"));
3306 
3307     var names = try catalog.relationNames(std.testing.allocator);
3308     defer names.deinit();
3309     try std.testing.expectEqual(@as(usize, 0), names.names.len);
3310 
3311     const recreated = try catalog.createRelation(std.testing.allocator, .{
3312         .name = "items",
3313         .indexes = &indexes,
3314     }, .{ .durability = .buffered });
3315     try std.testing.expectEqual(@as(u64, 3), recreated.schema.version);
3316     try std.testing.expectError(error.RelationNotFound, catalog.dropRelation(std.testing.allocator, "missing", .{ .durability = .buffered }));
3317 }
3318 
3319 test "catalog analyzes composite index prefix distributions" {
3320     var tmp = std.testing.tmpDir(.{});
3321     defer tmp.cleanup();
3322 
3323     {
3324         var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3325             .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3326             .header = testingHeader(),
3327         });
3328         defer database.deinit();
3329         try database.reserve(.{ .wal_frames = 520 });
3330 
3331         var catalog = try Catalog.open(&database, .{});
3332         const columns = [_]ColumnDefinition{
3333             .{ .name = "region" },
3334             .{ .name = "payload" },
3335             .{ .name = "name" },
3336         };
3337         const indexes = [_]IndexDefinition{.{
3338             .name = "items_region_payload",
3339             .fields = &.{ 0, 1 },
3340         }};
3341         _ = try catalog.createRelation(std.testing.allocator, .{
3342             .name = "items",
3343             .columns = &columns,
3344             .indexes = &indexes,
3345         }, .{ .durability = .buffered });
3346 
3347         var handle = try catalog.openRelation(std.testing.allocator, "items");
3348         defer handle.deinit();
3349         _ = try handle.relation.put(std.testing.allocator, 1, &.{ .{ .integer = 0 }, .{ .integer = 1 }, .{ .text = "a" } }, .{ .durability = .buffered });
3350         _ = try handle.relation.put(std.testing.allocator, 2, &.{ .{ .integer = 0 }, .{ .integer = 1 }, .{ .text = "b" } }, .{ .durability = .buffered });
3351         _ = try handle.relation.put(std.testing.allocator, 3, &.{ .{ .integer = 0 }, .{ .integer = 2 }, .{ .text = "c" } }, .{ .durability = .buffered });
3352         _ = try handle.relation.put(std.testing.allocator, 4, &.{ .{ .integer = 1 }, .{ .integer = 1 }, .{ .text = "d" } }, .{ .durability = .buffered });
3353         _ = try handle.relation.put(std.testing.allocator, 5, &.{ .{ .integer = 1 }, .{ .integer = 2 }, .{ .text = "e" } }, .{ .durability = .buffered });
3354         _ = try handle.relation.put(std.testing.allocator, 6, &.{ .{ .integer = 1 }, .{ .integer = 2 }, .{ .text = "f" } }, .{ .durability = .buffered });
3355         _ = try handle.relation.put(std.testing.allocator, 7, &.{ .{ .integer = 1 }, .{ .integer = 3 }, .{ .text = "g" } }, .{ .durability = .buffered });
3356         _ = try handle.relation.put(std.testing.allocator, 8, &.{ .{ .integer = 2 }, .{ .integer = 5 }, .{ .text = "h" } }, .{ .durability = .buffered });
3357         _ = try handle.relation.put(std.testing.allocator, 9, &.{ .{ .integer = 2 }, .{ .integer = 5 }, .{ .text = "i" } }, .{ .durability = .buffered });
3358 
3359         _ = try catalog.analyzeRelation(std.testing.allocator, "items", .{ .durability = .buffered });
3360 
3361         var stats = (try catalog.relationStats(std.testing.allocator, "items")).?;
3362         defer stats.deinit();
3363         const index_stats = stats.index("items_region_payload").?;
3364         try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.prefixes.len);
3365         try std.testing.expectEqual(@as(usize, 1), index_stats.distribution.prefixes[0].field_count);
3366         try std.testing.expectEqual(@as(usize, 3), index_stats.distribution.prefixes[0].distinct_values);
3367         try std.testing.expectEqual(@as(usize, 4), index_stats.distribution.prefixes[0].max_equal);
3368         try std.testing.expect(index_stats.distribution.prefixes[0].samples.len > 0);
3369         for (index_stats.distribution.prefixes[0].samples, 0..) |sample, offset| {
3370             try std.testing.expectEqual(offset, sample.less_distinct);
3371         }
3372         try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.prefixes[1].field_count);
3373         try std.testing.expectEqual(@as(usize, 6), index_stats.distribution.prefixes[1].distinct_values);
3374         try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.prefixes[1].max_equal);
3375         try std.testing.expect(index_stats.distribution.prefixes[1].samples.len > 0);
3376         for (index_stats.distribution.prefixes[1].samples, 0..) |sample, offset| {
3377             try std.testing.expectEqual(offset, sample.less_distinct);
3378         }
3379         try std.testing.expectEqual(index_stats.distribution.prefixes[0].distinct_values, index_stats.distribution.distinct_values);
3380         try std.testing.expectEqual(index_stats.distribution.prefixes[0].max_equal, index_stats.distribution.max_equal);
3381         try database.syncWal();
3382     }
3383 
3384     var reopened = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3385         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3386         .header = recoveredHeader(),
3387     });
3388     defer reopened.deinit();
3389 
3390     var catalog = try Catalog.open(&reopened, .{});
3391     var stats = (try catalog.relationStats(std.testing.allocator, "items")).?;
3392     defer stats.deinit();
3393     const index_stats = stats.index("items_region_payload").?;
3394     try std.testing.expectEqual(@as(usize, 2), index_stats.distribution.prefixes.len);
3395     try std.testing.expectEqual(@as(usize, 3), index_stats.distribution.prefixes[0].distinct_values);
3396     try std.testing.expectEqual(@as(usize, 6), index_stats.distribution.prefixes[1].distinct_values);
3397     for (index_stats.distribution.prefixes[1].samples, 0..) |sample, offset| {
3398         try std.testing.expectEqual(offset, sample.less_distinct);
3399     }
3400 }
3401 
3402 test "catalog rejects duplicate object names" {
3403     var tmp = std.testing.tmpDir(.{});
3404     defer tmp.cleanup();
3405 
3406     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3407         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3408         .header = testingHeader(),
3409     });
3410     defer database.deinit();
3411     try database.reserve(.{ .wal_frames = 96 });
3412 
3413     var catalog = try Catalog.open(&database, .{});
3414     _ = try catalog.createRelation(std.testing.allocator, .{
3415         .name = "items",
3416     }, .{ .durability = .buffered });
3417     try std.testing.expectError(error.ObjectExists, catalog.createRelation(std.testing.allocator, .{
3418         .name = "items",
3419     }, .{ .durability = .buffered }));
3420     try std.testing.expectError(error.RelationNotFound, catalog.openRelation(std.testing.allocator, "missing"));
3421 }
3422 
3423 test "catalog allocates distinct roots for multiple relations" {
3424     var tmp = std.testing.tmpDir(.{});
3425     defer tmp.cleanup();
3426 
3427     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3428         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3429         .header = testingHeader(),
3430     });
3431     defer database.deinit();
3432     try database.reserve(.{ .wal_frames = 160 });
3433 
3434     var catalog = try Catalog.open(&database, .{});
3435     const indexes = [_]IndexDefinition{.{
3436         .name = "items_value",
3437         .fields = &.{0},
3438     }};
3439     const first = try catalog.createRelation(std.testing.allocator, .{
3440         .name = "items",
3441         .indexes = &indexes,
3442     }, .{ .durability = .buffered });
3443     try std.testing.expectEqual(@as(u64, 1), first.schema.version);
3444     const second = try catalog.createRelation(std.testing.allocator, .{
3445         .name = "users",
3446     }, .{ .durability = .buffered });
3447     try std.testing.expectEqual(@as(u64, 2), second.schema.version);
3448     try std.testing.expectEqual(@as(u64, 2), (try catalog.schemaState(std.testing.allocator)).version);
3449 
3450     var items = try catalog.openRelation(std.testing.allocator, "items");
3451     defer items.deinit();
3452     var users = try catalog.openRelation(std.testing.allocator, "users");
3453     defer users.deinit();
3454 
3455     try std.testing.expect(items.relation.table.rows.root_page > default_root_page);
3456     try std.testing.expect(users.relation.table.rows.root_page > items.relation.table.rows.root_page);
3457 
3458     _ = try items.relation.put(std.testing.allocator, 1, &.{.{ .integer = 7 }}, .{ .durability = .buffered });
3459     _ = try users.relation.put(std.testing.allocator, 1, &.{.{ .text = "Ada" }}, .{ .durability = .buffered });
3460 
3461     var lookup: index_mod.Scan = undefined;
3462     try items.relation.lookup(&lookup, std.testing.allocator, 0, &.{.{ .integer = 7 }});
3463     defer lookup.deinit();
3464     try std.testing.expectEqual(@as(i64, 1), (try lookup.next()).?.rowid);
3465     try std.testing.expect(try lookup.next() == null);
3466 
3467     const bytes = (try users.relation.get(std.testing.allocator, 1)).?;
3468     defer std.testing.allocator.free(bytes);
3469     const view = try row.View.init(bytes);
3470     try std.testing.expectEqualStrings("Ada", (try view.column(0)).text);
3471 }
3472 
3473 test "catalog rejects duplicate names inside one relation definition" {
3474     var tmp = std.testing.tmpDir(.{});
3475     defer tmp.cleanup();
3476 
3477     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3478         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3479         .header = testingHeader(),
3480     });
3481     defer database.deinit();
3482 
3483     var catalog = try Catalog.open(&database, .{});
3484     const table_named_index = [_]IndexDefinition{.{
3485         .name = "items",
3486         .fields = &.{0},
3487     }};
3488     try std.testing.expectError(error.ObjectExists, catalog.createRelation(std.testing.allocator, .{
3489         .name = "items",
3490         .indexes = &table_named_index,
3491     }, .{ .durability = .buffered }));
3492 
3493     const duplicate_indexes = [_]IndexDefinition{
3494         .{
3495             .name = "items_value",
3496             .fields = &.{0},
3497         },
3498         .{
3499             .name = "items_value",
3500             .fields = &.{1},
3501         },
3502     };
3503     try std.testing.expectError(error.ObjectExists, catalog.createRelation(std.testing.allocator, .{
3504         .name = "items",
3505         .indexes = &duplicate_indexes,
3506     }, .{ .durability = .buffered }));
3507 
3508     const duplicate_columns = [_]ColumnDefinition{
3509         .{ .name = "name" },
3510         .{ .name = "Name" },
3511     };
3512     try std.testing.expectError(error.CatalogCorrupt, catalog.createRelation(std.testing.allocator, .{
3513         .name = "items",
3514         .columns = &duplicate_columns,
3515     }, .{ .durability = .buffered }));
3516 }
3517 
3518 test "catalog diagnoses pre identity object rows as unsupported format" {
3519     var tmp = std.testing.tmpDir(.{});
3520     defer tmp.cleanup();
3521 
3522     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3523         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3524         .header = testingHeader(),
3525     });
3526     defer database.deinit();
3527     try database.reserve(.{ .wal_frames = 96 });
3528 
3529     try forgeCatalog(&database, .{ .format = format_version, .version = 1 }, &.{.{ .rowid = first_object_rowid, .values = &.{
3530         .{ .integer = relation_kind },
3531         .{ .text = "items" },
3532         .{ .text = "items" },
3533         .{ .integer = 3 },
3534         .{ .blob = "" },
3535         .{ .blob = "" },
3536     } }});
3537 
3538     var catalog = try Catalog.open(&database, .{});
3539     try std.testing.expectError(error.UnsupportedCatalogFormat, catalog.schemaState(std.testing.allocator));
3540     try std.testing.expectError(error.UnsupportedCatalogFormat, catalog.openRelation(std.testing.allocator, "items"));
3541 }
3542 
3543 test "catalog rejects newer schema format before object rows decode" {
3544     var tmp = std.testing.tmpDir(.{});
3545     defer tmp.cleanup();
3546 
3547     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3548         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3549         .header = testingHeader(),
3550     });
3551     defer database.deinit();
3552     try database.reserve(.{ .wal_frames = 96 });
3553 
3554     try forgeCatalog(&database, .{ .format = format_version + 1, .version = 1 }, &.{.{ .rowid = first_object_rowid, .values = &.{
3555         .{ .integer = 99 },
3556         .{ .text = "items" },
3557         .{ .text = "items" },
3558         .{ .integer = 3 },
3559         .{ .blob = "" },
3560         .{ .blob = "" },
3561         .{ .integer = 0 },
3562     } }});
3563 
3564     var catalog = try Catalog.open(&database, .{});
3565     try std.testing.expectError(error.UnsupportedCatalogFormat, catalog.schemaState(std.testing.allocator));
3566 }
3567 
3568 test "catalog diagnoses pre identity stats rows as unsupported format" {
3569     var tmp = std.testing.tmpDir(.{});
3570     defer tmp.cleanup();
3571 
3572     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3573         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3574         .header = testingHeader(),
3575     });
3576     defer database.deinit();
3577     try database.reserve(.{ .wal_frames = 96 });
3578 
3579     try forgeCatalog(&database, .{ .format = format_version, .version = 1 }, &.{.{ .rowid = first_object_rowid, .values = &.{
3580         .{ .integer = stats_kind },
3581         .{ .text = "items" },
3582         .{ .text = "items" },
3583         .{ .integer = 3 },
3584         .{ .blob = "" },
3585         .{ .blob = "" },
3586     } }});
3587 
3588     var catalog = try Catalog.open(&database, .{});
3589     try std.testing.expectError(error.UnsupportedCatalogFormat, catalog.schemaState(std.testing.allocator));
3590 }
3591 
3592 test "catalog diagnoses schema row arity drift as unsupported format" {
3593     var tmp = std.testing.tmpDir(.{});
3594     defer tmp.cleanup();
3595 
3596     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
3597         .paths = .{ .database = "catalog.db", .wal = "catalog.wal" },
3598         .header = testingHeader(),
3599     });
3600     defer database.deinit();
3601     try database.reserve(.{ .wal_frames = 96 });
3602 
3603     const schema_blob = @as([schema_blob_bytes]u8, @splat(0));
3604     try forgeCatalog(&database, null, &.{.{ .rowid = schema_rowid, .values = &.{
3605         .{ .integer = schema_kind },
3606         .{ .text = schema_name },
3607         .{ .text = "" },
3608         .{ .integer = 0 },
3609         .{ .blob = &schema_blob },
3610         .{ .blob = "" },
3611         .{ .integer = 0 },
3612     } }});
3613 
3614     var catalog = try Catalog.open(&database, .{});
3615     try std.testing.expectError(error.UnsupportedCatalogFormat, catalog.schemaState(std.testing.allocator));
3616 }
3617 
3618 const RawCatalogRow = struct {
3619     rowid: i64,
3620     values: []const row.Value,
3621 };
3622 
3623 fn forgeCatalog(database: *file.Database, catalog_schema: ?Schema, rows: []const RawCatalogRow) !void {
3624     const space = try space_mod.Space.open(database, .{
3625         .meta_page = default_meta_page,
3626         .roots = &.{.{ .root_page = default_root_page }},
3627     });
3628     var schema = try space.rowidTable(default_root_page);
3629     var write = try space.beginWrite();
3630     defer write.deinit();
3631     if (catalog_schema) |value| try putSchemaRow(&schema, &write, value);
3632     for (rows) |raw| try schema.putIn(&write, raw.rowid, raw.values);
3633     _ = try write.commit(.{ .durability = .buffered });
3634 }
3635 
3636 fn testingHeader() wal.Header {
3637     return .{
3638         .sequence = 1101,
3639         .salt = .{ .first = 0x9191_c3c3, .second = 0x6363_d4d4 },
3640     };
3641 }
3642 
3643 fn recoveredHeader() wal.Header {
3644     return .{
3645         .sequence = 1102,
3646         .salt = .{ .first = 0xaaaa_5555, .second = 0xbbbb_6666 },
3647     };
3648 }