lib/sql/src/table.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const alloc_phase = @import("alloc_phase");
  3 const file = @import("file.zig");
  4 const key = @import("key.zig");
  5 const page = @import("page.zig");
  6 const row = @import("row.zig");
  7 const trace = @import("trace.zig");
  8 const tree = @import("tree.zig");
  9 const wal = @import("wal.zig");
 10 
 11 const Allocator = std.mem.Allocator;
 12 const reader_get_instruction_page_alignment = 4096;
 13 
 14 pub const Error = tree.Error || row.Error || key.Error;
 15 
 16 pub const ValueLimits = struct {
 17     max_value_bytes: usize = 0,
 18 
 19     pub fn inspect(table: *const Table, rowid: i64) Error!?ValueLimits {
 20         const value_bytes = (try table.valueLength(rowid)) orelse return null;
 21         return .{ .max_value_bytes = value_bytes };
 22     }
 23 
 24     pub fn include(self: *ValueLimits, limits: ValueLimits) void {
 25         self.max_value_bytes = @max(self.max_value_bytes, limits.max_value_bytes);
 26     }
 27 };
 28 
 29 pub const ValueCapacity = struct {
 30     max_value_bytes: usize,
 31     storage_bytes: usize,
 32 
 33     pub fn derive(limits: ValueLimits) ValueCapacity {
 34         return .{
 35             .max_value_bytes = limits.max_value_bytes,
 36             .storage_bytes = limits.max_value_bytes,
 37         };
 38     }
 39 };
 40 
 41 pub const ValueStorage = struct {
 42     phase: alloc_phase.capacity.Phase,
 43     capacity: ValueCapacity,
 44     bytes: []u8,
 45 
 46     pub const Limits: type = ValueLimits;
 47     pub const Capacity: type = ValueCapacity;
 48     pub const Exhaustion = error{ValueCapacityExceeded};
 49     pub const InitError: type = Allocator.Error;
 50 
 51     pub const claim: alloc_phase.capacity.Declaration = .{
 52         .source = .{
 53             .id = "sql.table_value_storage",
 54             .kind = .phase_static,
 55             .limit_source = .caller,
 56             .storage = .{
 57                 .covered = &.{
 58                     .{
 59                         .id = "one_reusable_encoded_row_value_region",
 60                         .lifetime = .steady,
 61                         .detail = "one reusable encoded row value region",
 62                     },
 63                 },
 64                 .excluded = &.{
 65                     "tree traversal, database/WAL bytes, file handles, and operating-system page cache",
 66                     "file read cache, pager history, search hit storage, and decoded result fields",
 67                     "caller-owned keys and trace instrumentation",
 68                 },
 69             },
 70             .capacity = .{
 71                 .inputs = &.{
 72                     alloc_phase.capacity.bindInput(Limits, "max_value_bytes", "max_value_bytes"),
 73                 },
 74                 .type_selectors = &.{},
 75                 .nodes = &.{
 76                     .{ .input = 0 },
 77                 },
 78                 .assertions = &.{.{
 79                     .scope = .closure_total,
 80                     .measure = .retained,
 81                     .relation = .exact,
 82                     .expression = 0,
 83                 }},
 84             },
 85             .overload = .{
 86                 .kind = .reject_before_mutation,
 87                 .detail = "oversize values return ValueCapacityExceeded before reusable bytes mutate",
 88             },
 89             .risks = .{
 90                 .transitive = .{
 91                     .status = .open,
 92                     .detail = "tree reads use separate file, pager, cache, and trace owners",
 93                 },
 94                 .foreign = .{
 95                     .status = .open,
 96                     .detail = "base-page reads may enter the host filesystem and OS cache",
 97                 },
 98             },
 99             .obligations = &.{
100                 .{ .key = "sql_table_value_capacity", .role = .capacity_model },
101                 .{ .key = "sql_table_value_oom_retry", .role = .custom },
102                 .{ .key = "sql_table_value_sealed", .role = .overload },
103                 .{ .key = "sql_table_value_semantics", .role = .custom },
104             },
105         },
106         .bindings = .{
107             .owner = @This(),
108             .seal = .{
109                 .family = alloc_phase.capacity.selector(@This().activate),
110                 .premise = .{
111                     .class = .checked_semantic_fact,
112                     .authority = .checker,
113                 },
114             },
115             .teardown = .{
116                 .family = alloc_phase.capacity.selector(@This().deinit),
117                 .premise = .{
118                     .class = .checked_semantic_fact,
119                     .authority = .checker,
120                 },
121             },
122         },
123     };
124 
125     pub fn init(allocator: Allocator, limits: ValueLimits) InitError!ValueStorage {
126         const capacity = ValueCapacity.derive(limits);
127         const bytes = if (capacity.storage_bytes == 0)
128             @as([]u8, &.{})
129         else
130             try allocator.alloc(u8, capacity.storage_bytes);
131         return .{
132             .phase = .initialization,
133             .capacity = capacity,
134             .bytes = bytes,
135         };
136     }
137 
138     pub fn activate(self: *ValueStorage) void {
139         std.debug.assert(self.phase == .initialization);
140         std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
141         self.phase = .steady;
142     }
143 
144     pub fn get(
145         self: *ValueStorage,
146         table: *const Table,
147         rowid: i64,
148     ) (Error || Exhaustion)!?[]const u8 {
149         std.debug.assert(self.phase == .steady);
150         const value = table.getInto(rowid, self.bytes) catch |err| switch (err) {
151             error.OutputTooSmall => return error.ValueCapacityExceeded,
152             else => return err,
153         };
154         return value;
155     }
156 
157     pub fn deinit(self: *ValueStorage, allocator: Allocator) void {
158         std.debug.assert(self.phase != .teardown);
159         std.debug.assert(self.bytes.len == self.capacity.storage_bytes);
160         self.phase = .teardown;
161         if (self.bytes.len != 0) allocator.free(self.bytes);
162         self.bytes = &.{};
163     }
164 };
165 
166 comptime {
167     alloc_phase.capacity.requireAllocatorRejectingOwnerShape(ValueStorage);
168 }
169 
170 pub const Options = struct {
171     tree: tree.Options = .{},
172 };
173 
174 /// Chooses what a table scan carries back for each row, so a scan then pays
175 /// only for the part of each row it will look at. `key` gives the row's key and
176 /// an empty byte slice, `record` gives the record bytes as they are stored,
177 /// which for a large row is the pointer to its overflow pages, and `value`
178 /// gives the decoded row value and reads the overflow pages when the record
179 /// points at them. The projection is chosen once for a whole scan, where `scan`
180 /// picks `value` for callers that do not say, and the type is re-exported from
181 /// the B-tree layer so a table scan and a tree scan take the same values.
182 pub const Projection = tree.Projection;
183 
184 pub const Entry = struct {
185     rowid: i64,
186     bytes: []const u8,
187 
188     pub fn view(self: Entry) row.Error!row.View {
189         return try row.View.init(self.bytes);
190     }
191 };
192 
193 pub const Reader = struct {
194     rows: tree.Reader,
195 
196     pub fn open(snapshot: file.Snapshot, options: Options) Error!Reader {
197         return .{ .rows = try tree.Reader.open(snapshot, options.tree) };
198     }
199 
200     pub fn lastRowId(self: *const Reader) Error!?i64 {
201         var buffer: [key.rowid_size]u8 = undefined;
202         const last = (try self.rows.lastKey(&buffer)) orelse return null;
203         return try key.decodeRowId(last);
204     }
205 
206     pub fn get(self: *const Reader, allocator: Allocator, rowid: i64) align(reader_get_instruction_page_alignment) Error!?[]u8 {
207         const phase = trace.scope("table.get");
208         defer phase.end();
209 
210         var key_bytes: [key.rowid_size]u8 = undefined;
211         return try self.rows.get(allocator, try key.encodeRowId(&key_bytes, rowid));
212     }
213 
214     pub fn valueLength(self: *const Reader, rowid: i64) Error!?usize {
215         var key_bytes: [key.rowid_size]u8 = undefined;
216         return try self.rows.valueLength(try key.encodeRowId(&key_bytes, rowid));
217     }
218 
219     pub fn getInto(self: *const Reader, rowid: i64, target: []u8) Error!?[]u8 {
220         var key_bytes: [key.rowid_size]u8 = undefined;
221         return try self.rows.getInto(try key.encodeRowId(&key_bytes, rowid), target);
222     }
223 
224     /// Starts a scan of the rows from `start` up to `end` in `target`.
225     pub fn scan(
226         self: *const Reader,
227         target: *Scan,
228         allocator: Allocator,
229         start: ?i64,
230         end: ?i64,
231     ) Error!void {
232         try self.scanProjected(target, allocator, start, end, .value);
233     }
234 
235     pub fn scanProjected(
236         self: *const Reader,
237         target: *Scan,
238         allocator: Allocator,
239         start: ?i64,
240         end: ?i64,
241         projection: Projection,
242     ) Error!void {
243         const phase = trace.scope("table.scan");
244         defer phase.end();
245 
246         var start_bytes: [key.rowid_size]u8 = undefined;
247         var end_bytes: [key.rowid_size]u8 = undefined;
248         const start_key = if (start) |rowid| try key.encodeRowId(&start_bytes, rowid) else null;
249         const end_key = if (end) |rowid| try key.encodeRowId(&end_bytes, rowid) else null;
250         try self.rows.scan(&target.rows, allocator, start_key, end_key, projection);
251     }
252 
253     pub fn summarize(self: *const Reader) Error!tree.Summary {
254         const phase = trace.scope("table.summarize");
255         defer phase.end();
256 
257         return try self.rows.summarize();
258     }
259 
260     /// Returns how many rows the table holds in this reader's snapshot. A
261     /// caller sizes a scan's output by it without walking the table.
262     pub fn count(self: *const Reader) Error!usize {
263         return try self.rows.count();
264     }
265 };
266 
267 pub const Table = struct {
268     rows: tree.Tree,
269 
270     pub fn open(database: *file.Database, options: Options) Error!Table {
271         return .{ .rows = try tree.Tree.open(database, options.tree) };
272     }
273 
274     pub fn reader(self: *const Table, snapshot: file.Snapshot) Error!Reader {
275         return .{ .rows = try self.rows.reader(snapshot) };
276     }
277 
278     pub fn lastRowId(self: *const Table) Error!?i64 {
279         var read = try self.rows.database.beginRead();
280         defer read.deinit();
281         const opened = try self.reader(read.snapshot());
282         return try opened.lastRowId();
283     }
284 
285     pub fn put(self: *Table, rowid: i64, values: []const row.Value, options: file.CommitOptions) Error!file.Commit {
286         const phase = trace.scope("table.put");
287         defer phase.end();
288 
289         var write = try tree.Write.beginTree(&self.rows);
290         defer write.deinit();
291         try self.putIn(&write, rowid, values);
292         return try write.commit(options);
293     }
294 
295     pub fn putIn(self: *Table, write: *tree.Write, rowid: i64, values: []const row.Value) Error!void {
296         var key_bytes: [key.rowid_size]u8 = undefined;
297         var row_bytes: [page.size]u8 = undefined;
298         const encoded_key = try key.encodeRowId(&key_bytes, rowid);
299         const encoded_row = try row.encode(&row_bytes, values);
300         try write.put(&self.rows, encoded_key, encoded_row);
301     }
302 
303     pub fn putEncoded(self: *Table, rowid: i64, bytes: []const u8, options: file.CommitOptions) Error!file.Commit {
304         const phase = trace.scope("table.put_encoded");
305         defer phase.end();
306 
307         var write = try tree.Write.beginTree(&self.rows);
308         defer write.deinit();
309         try self.putEncodedIn(&write, rowid, bytes);
310         return try write.commit(options);
311     }
312 
313     pub fn putEncodedIn(self: *Table, write: *tree.Write, rowid: i64, bytes: []const u8) Error!void {
314         _ = try row.View.init(bytes);
315         var key_bytes: [key.rowid_size]u8 = undefined;
316         try write.put(&self.rows, try key.encodeRowId(&key_bytes, rowid), bytes);
317     }
318 
319     pub fn get(self: *const Table, allocator: Allocator, rowid: i64) Error!?[]u8 {
320         var read = try self.rows.database.beginRead();
321         defer read.deinit();
322         const opened = try self.reader(read.snapshot());
323         return try opened.get(allocator, rowid);
324     }
325 
326     pub fn valueLength(self: *const Table, rowid: i64) Error!?usize {
327         var read = try self.rows.database.beginRead();
328         defer read.deinit();
329         const opened = try self.reader(read.snapshot());
330         return try opened.valueLength(rowid);
331     }
332 
333     pub fn getInto(self: *const Table, rowid: i64, target: []u8) Error!?[]u8 {
334         var read = try self.rows.database.beginRead();
335         defer read.deinit();
336         const opened = try self.reader(read.snapshot());
337         return try opened.getInto(rowid, target);
338     }
339 
340     pub fn delete(self: *Table, rowid: i64, options: file.CommitOptions) Error!file.Commit {
341         const phase = trace.scope("table.delete");
342         defer phase.end();
343 
344         var write = try tree.Write.beginTree(&self.rows);
345         defer write.deinit();
346         try self.deleteIn(&write, rowid);
347         return try write.commit(options);
348     }
349 
350     pub fn deleteIn(self: *Table, write: *tree.Write, rowid: i64) Error!void {
351         var key_bytes: [key.rowid_size]u8 = undefined;
352         try write.delete(&self.rows, try key.encodeRowId(&key_bytes, rowid));
353     }
354 
355     /// Starts a scan of the rows from `start` up to `end` in `target`.
356     pub fn scan(
357         self: *const Table,
358         target: *Scan,
359         allocator: Allocator,
360         start: ?i64,
361         end: ?i64,
362     ) Error!void {
363         try self.scanProjected(target, allocator, start, end, .value);
364     }
365 
366     pub fn scanProjected(
367         self: *const Table,
368         target: *Scan,
369         allocator: Allocator,
370         start: ?i64,
371         end: ?i64,
372         projection: Projection,
373     ) Error!void {
374         var read = try self.rows.database.beginRead();
375         defer read.deinit();
376         const opened = try self.reader(read.snapshot());
377         try opened.scanProjected(target, allocator, start, end, projection);
378     }
379 
380     pub fn summarize(self: *const Table) Error!tree.Summary {
381         var read = try self.rows.database.beginRead();
382         defer read.deinit();
383         const opened = try self.reader(read.snapshot());
384         return try opened.summarize();
385     }
386 
387     pub fn count(self: *const Table) Error!usize {
388         var read = try self.rows.database.beginRead();
389         defer read.deinit();
390         const opened = try self.reader(read.snapshot());
391         return try opened.count();
392     }
393 
394     pub fn summarizeIn(self: *const Table, write: *const tree.Write) Error!tree.Summary {
395         const phase = trace.scope("table.summarize_in");
396         defer phase.end();
397 
398         return try self.rows.summarizeIn(write);
399     }
400 };
401 
402 pub const Scan = struct {
403     rows: tree.Scan,
404 
405     pub fn deinit(self: *Scan) void {
406         self.rows.deinit();
407         self.* = undefined;
408     }
409 
410     pub fn next(self: *Scan) Error!?Entry {
411         if (try self.rows.next()) |entry| {
412             return .{
413                 .rowid = try key.decodeRowId(entry.key),
414                 .bytes = entry.bytes,
415             };
416         }
417         return null;
418     }
419 };
420 
421 test "table value capacity matches the independent maximum model" {
422     comptime {
423         @stardustClaim(
424             @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_capacity"),
425             null,
426             null,
427             null,
428             null,
429             null,
430             null,
431         );
432     }
433 
434     for (0..4097) |value_bytes| {
435         const expected = ValueCapacity{
436             .max_value_bytes = value_bytes,
437             .storage_bytes = value_bytes,
438         };
439         try std.testing.expectEqual(
440             expected,
441             ValueCapacity.derive(.{ .max_value_bytes = value_bytes }),
442         );
443     }
444     try std.testing.expectEqual(
445         ValueCapacity{
446             .max_value_bytes = std.math.maxInt(usize),
447             .storage_bytes = std.math.maxInt(usize),
448         },
449         ValueCapacity.derive(.{ .max_value_bytes = std.math.maxInt(usize) }),
450     );
451 }
452 
453 fn checkValueStorageInitFailures(allocator: Allocator) !void {
454     var storage = try ValueStorage.init(allocator, .{ .max_value_bytes = 4096 });
455     storage.deinit(allocator);
456 }
457 
458 test "table value storage initialization cleans allocation failure and retries" {
459     comptime {
460         @stardustClaim(
461             @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_oom_retry"),
462             null,
463             null,
464             null,
465             null,
466             null,
467             null,
468         );
469     }
470 
471     try std.testing.checkAllAllocationFailures(
472         std.testing.allocator,
473         checkValueStorageInitFailures,
474         .{},
475     );
476 
477     var storage = try ValueStorage.init(std.testing.allocator, .{ .max_value_bytes = 4096 });
478     defer storage.deinit(std.testing.allocator);
479     storage.activate();
480     try std.testing.expectEqual(alloc_phase.capacity.Phase.steady, storage.phase);
481 }
482 
483 fn checkSealedValueReads(
484     table: *const Table,
485     storage: *ValueStorage,
486     rejecting: *ValueStorage,
487     phase_allocator: *alloc_phase.SealedPhaseAllocator,
488     large: []const u8,
489 ) !void {
490     const storage_pointer = storage.bytes.ptr;
491     const storage_capacity = storage.capacity;
492     phase_allocator.seal();
493     storage.activate();
494     rejecting.activate();
495 
496     const inline_bytes = (try storage.get(table, 1)).?;
497     try std.testing.expect(storage.bytes.ptr == storage_pointer);
498     try std.testing.expectEqual(storage_capacity, storage.capacity);
499     try std.testing.expectEqualStrings(
500         "inline",
501         (try (try row.View.init(inline_bytes)).column(0)).text,
502     );
503 
504     const overflow_bytes = (try storage.get(table, 2)).?;
505     try std.testing.expect(storage.bytes.ptr == storage_pointer);
506     try std.testing.expectEqual(storage_capacity, storage.capacity);
507     try std.testing.expectEqualSlices(
508         u8,
509         large,
510         (try (try row.View.init(overflow_bytes)).column(0)).blob,
511     );
512     try std.testing.expect((try storage.get(table, 3)) == null);
513 
514     @memset(rejecting.bytes, 0xa5);
515     try std.testing.expectError(error.ValueCapacityExceeded, rejecting.get(table, 2));
516     for (rejecting.bytes) |byte| try std.testing.expectEqual(@as(u8, 0xa5), byte);
517 }
518 
519 test "table value storage is sealed before inline overflow and rejection reads" {
520     comptime {
521         @stardustClaim(
522             @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_sealed"),
523             null,
524             null,
525             null,
526             null,
527             null,
528             null,
529         );
530     }
531 
532     var tmp = std.testing.tmpDir(.{});
533     defer tmp.cleanup();
534 
535     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
536         .paths = .{ .database = "table-value-sealed.db", .wal = "table-value-sealed.wal" },
537         .header = testingHeader(),
538     });
539     defer database.deinit();
540     try database.reserve(.{ .wal_frames = 64 });
541 
542     var table = try Table.open(&database, .{});
543     _ = try table.put(1, &.{.{ .text = "inline" }}, .{ .durability = .buffered });
544     var large: [page.overflow_capacity + 37]u8 = undefined;
545     for (&large, 0..) |*byte, index| byte.* = @intCast(index % 251);
546     var encoded_large: [large.len + 32]u8 = undefined;
547     _ = try table.putEncoded(
548         2,
549         try row.encode(&encoded_large, &.{.{ .blob = &large }}),
550         .{ .durability = .buffered },
551     );
552 
553     const inline_limits = (try ValueLimits.inspect(&table, 1)).?;
554     var combined_limits = inline_limits;
555     combined_limits.include((try ValueLimits.inspect(&table, 2)).?);
556     try std.testing.expect((try ValueLimits.inspect(&table, 3)) == null);
557 
558     var phase_allocator = try alloc_phase.SealedPhaseAllocator.init(std.testing.allocator);
559     var storage = ValueStorage.init(
560         phase_allocator.initializationAllocator(),
561         combined_limits,
562     ) catch |err| {
563         phase_allocator.abortInitialization();
564         phase_allocator.deinit();
565         return err;
566     };
567     var rejecting = ValueStorage.init(
568         phase_allocator.initializationAllocator(),
569         inline_limits,
570     ) catch |err| {
571         storage.deinit(phase_allocator.initializationAllocator());
572         phase_allocator.abortInitialization();
573         phase_allocator.deinit();
574         return err;
575     };
576     defer {
577         if (phase_allocator.phase() == .initialization) phase_allocator.abortInitialization();
578         if (phase_allocator.phase() == .steady) phase_allocator.beginTeardown();
579         if (rejecting.phase != .teardown) rejecting.deinit(phase_allocator.teardownAllocator());
580         if (storage.phase != .teardown) storage.deinit(phase_allocator.teardownAllocator());
581         phase_allocator.deinit();
582     }
583 
584     try checkSealedValueReads(&table, &storage, &rejecting, &phase_allocator, &large);
585 }
586 
587 test "table value storage preserves typed row semantics" {
588     comptime {
589         @stardustClaim(
590             @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_semantics"),
591             null,
592             null,
593             null,
594             null,
595             null,
596             null,
597         );
598     }
599 
600     var tmp = std.testing.tmpDir(.{});
601     defer tmp.cleanup();
602 
603     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
604         .paths = .{ .database = "table-value-semantics.db", .wal = "table-value-semantics.wal" },
605         .header = testingHeader(),
606     });
607     defer database.deinit();
608     try database.reserve(.{ .wal_frames = 64 });
609 
610     var table = try Table.open(&database, .{});
611     _ = try table.put(
612         7,
613         &.{ .{ .integer = -42 }, .{ .text = "placed" }, .nil },
614         .{ .durability = .buffered },
615     );
616     const limits = (try ValueLimits.inspect(&table, 7)).?;
617     var storage = try ValueStorage.init(std.testing.allocator, limits);
618     defer storage.deinit(std.testing.allocator);
619     storage.activate();
620     const view = try row.View.init((try storage.get(&table, 7)).?);
621     try std.testing.expectEqual(@as(i64, -42), (try view.column(0)).integer);
622     try std.testing.expectEqualStrings("placed", (try view.column(1)).text);
623     try std.testing.expectEqual(row.Storage.nil, std.meta.activeTag(try view.column(2)));
624 }
625 
626 test "rowid table stores typed rows and scans in rowid order" {
627     var tmp = std.testing.tmpDir(.{});
628     defer tmp.cleanup();
629 
630     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
631         .paths = .{ .database = "table.db", .wal = "table.wal" },
632         .header = testingHeader(),
633     });
634     defer database.deinit();
635     try database.reserve(.{ .wal_frames = 64 });
636 
637     var table = try Table.open(&database, .{});
638     _ = try table.put(7, &.{ .{ .integer = 7 }, .{ .text = "seven" } }, .{ .durability = .buffered });
639     _ = try table.put(-2, &.{ .{ .integer = -2 }, .{ .text = "minus-two" } }, .{ .durability = .buffered });
640     _ = try table.put(0, &.{ .{ .integer = 0 }, .{ .text = "zero" } }, .{ .durability = .buffered });
641 
642     const found = (try table.get(std.testing.allocator, -2)).?;
643     defer std.testing.allocator.free(found);
644     const view = try row.View.init(found);
645     try std.testing.expectEqual(@as(i64, -2), (try view.column(0)).integer);
646     try std.testing.expectEqualStrings("minus-two", (try view.column(1)).text);
647 
648     var scan: Scan = undefined;
649     try table.scan(&scan, std.testing.allocator, null, null);
650     defer scan.deinit();
651     const first = (try scan.next()).?;
652     const second = (try scan.next()).?;
653     const third = (try scan.next()).?;
654     try std.testing.expectEqual(@as(i64, -2), first.rowid);
655     try std.testing.expectEqual(@as(i64, 0), second.rowid);
656     try std.testing.expectEqual(@as(i64, 7), third.rowid);
657     try std.testing.expect(try scan.next() == null);
658 }
659 
660 test "rowid table key projection avoids overflow materialization" {
661     var tmp = std.testing.tmpDir(.{});
662     defer tmp.cleanup();
663 
664     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
665         .paths = .{ .database = "table-projection.db", .wal = "table-projection.wal" },
666         .header = testingHeader(),
667     });
668     defer database.deinit();
669     try database.reserve(.{ .wal_frames = 64 });
670 
671     var table = try Table.open(&database, .{});
672     const large: [2048]u8 = @splat(0xa5);
673     _ = try table.put(
674         7,
675         &.{.{ .blob = &large }},
676         .{ .durability = .buffered },
677     );
678 
679     var key_failing = std.testing.FailingAllocator.init(
680         std.testing.allocator,
681         .{ .fail_index = 0 },
682     );
683     var key_scan: Scan = undefined;
684     try table.scanProjected(
685         &key_scan,
686         key_failing.allocator(),
687         null,
688         null,
689         .key,
690     );
691     defer key_scan.deinit();
692     const entry = (try key_scan.next()).?;
693     try std.testing.expectEqual(@as(i64, 7), entry.rowid);
694     try std.testing.expectEqual(@as(usize, 0), entry.bytes.len);
695     try std.testing.expect(try key_scan.next() == null);
696 
697     var value_failing = std.testing.FailingAllocator.init(
698         std.testing.allocator,
699         .{ .fail_index = 0 },
700     );
701     var value_scan: Scan = undefined;
702     try table.scanProjected(
703         &value_scan,
704         value_failing.allocator(),
705         null,
706         null,
707         .value,
708     );
709     defer value_scan.deinit();
710     try std.testing.expectError(error.OutOfMemory, value_scan.next());
711 }
712 
713 test "rowid table reports the last assigned rowid" {
714     var tmp = std.testing.tmpDir(.{});
715     defer tmp.cleanup();
716 
717     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
718         .paths = .{ .database = "table.db", .wal = "table.wal" },
719         .header = testingHeader(),
720     });
721     defer database.deinit();
722     try database.reserve(.{ .wal_frames = 256 });
723 
724     var table = try Table.open(&database, .{});
725     try std.testing.expectEqual(@as(?i64, null), try table.lastRowId());
726     _ = try table.put(7, &.{ .{ .integer = 7 }, .{ .text = "seven" } }, .{ .durability = .buffered });
727     try std.testing.expectEqual(@as(?i64, 7), try table.lastRowId());
728     _ = try table.put(3, &.{ .{ .integer = 3 }, .{ .text = "three" } }, .{ .durability = .buffered });
729     try std.testing.expectEqual(@as(?i64, 7), try table.lastRowId());
730     var rowid: i64 = 8;
731     while (rowid <= 600) : (rowid += 1) {
732         _ = try table.put(rowid, &.{ .{ .integer = rowid }, .{ .text = "filler" } }, .{ .durability = .buffered });
733     }
734     try std.testing.expectEqual(@as(?i64, 600), try table.lastRowId());
735 }
736 
737 test "rowid table deletes and recovers after reopen" {
738     var tmp = std.testing.tmpDir(.{});
739     defer tmp.cleanup();
740 
741     {
742         var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
743             .paths = .{ .database = "table.db", .wal = "table.wal" },
744             .header = testingHeader(),
745         });
746         defer database.deinit();
747         try database.reserve(.{ .wal_frames = 64 });
748 
749         var table = try Table.open(&database, .{});
750         _ = try table.put(1, &.{.{ .text = "one" }}, .{ .durability = .buffered });
751         _ = try table.put(2, &.{.{ .text = "two" }}, .{ .durability = .buffered });
752         _ = try table.delete(1, .{ .durability = .buffered });
753         try database.syncWal();
754     }
755 
756     var reopened = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
757         .paths = .{ .database = "table.db", .wal = "table.wal" },
758         .header = recoveredHeader(),
759     });
760     defer reopened.deinit();
761 
762     var table = try Table.open(&reopened, .{});
763     const missing = try table.get(std.testing.allocator, 1);
764     if (missing) |bytes| std.testing.allocator.free(bytes);
765     try std.testing.expect(missing == null);
766 
767     const found = (try table.get(std.testing.allocator, 2)).?;
768     defer std.testing.allocator.free(found);
769     const view = try row.View.init(found);
770     try std.testing.expectEqualStrings("two", (try view.column(0)).text);
771 }
772 
773 fn testingHeader() wal.Header {
774     return .{
775         .sequence = 601,
776         .salt = .{ .first = 0x7171_6262, .second = 0x5353_4444 },
777     };
778 }
779 
780 fn recoveredHeader() wal.Header {
781     return .{
782         .sequence = 602,
783         .salt = .{ .first = 0x8888_9999, .second = 0xaaaa_bbbb },
784     };
785 }