lib/sql/src/session/test.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const alloc_phase = @import("alloc_phase");
  3 const sql = @import("../root.zig");
  4 const session = @import("root.zig");
  5 
  6 const branch = sql.branch;
  7 const catalog_mod = sql.catalog;
  8 const file = sql.file;
  9 const history_mod = sql.history;
 10 const relation_mod = sql.relation;
 11 const row = sql.row;
 12 const version = sql.version;
 13 const wal = sql.wal;
 14 
 15 const Allocator = std.mem.Allocator;
 16 const Error = session.Error;
 17 const DatabaseSession = session.DatabaseSession;
 18 const DatabaseWrite = session.DatabaseWrite;
 19 const RelationSession = session.RelationSession;
 20 
 21 const testing_write_limits = DatabaseWrite.Limits{
 22     .relations = 4,
 23     .edits = 8,
 24     .payload_bytes = 512,
 25     .assignments = 8,
 26 };
 27 
 28 fn testingDatabaseRoot(
 29     allocator: Allocator,
 30     catalog: *const catalog_mod.Catalog,
 31 ) Error!version.DatabaseRoot {
 32     return try version.databaseRootMaintained(
 33         allocator,
 34         catalog,
 35         version.ConflictRoot.empty().hash,
 36     );
 37 }
 38 
 39 test "relation sessions stage edits through database flush" {
 40     var tmp = std.testing.tmpDir(.{});
 41     defer tmp.cleanup();
 42 
 43     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
 44         .paths = .{ .database = "session.db", .wal = "session.wal" },
 45         .header = testingHeader(),
 46     });
 47     defer database.deinit();
 48     try database.reserve(.{ .wal_frames = 512 });
 49 
 50     var catalog = try catalog_mod.Catalog.open(&database, .{});
 51     _ = try catalog.createRelation(std.testing.allocator, .{
 52         .name = "items",
 53         .columns = &.{.{ .name = "value" }},
 54     }, .{ .durability = .buffered });
 55     _ = try catalog.createRelation(std.testing.allocator, .{
 56         .name = "users",
 57         .columns = &.{.{ .name = "name" }},
 58     }, .{ .durability = .buffered });
 59 
 60     var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
 61     const before_relation = relation_session.root.hash;
 62     var relation_session_live = true;
 63     errdefer if (relation_session_live) relation_session.deinit();
 64 
 65     var root = try testingDatabaseRoot(std.testing.allocator, &catalog);
 66     const commit = version.Commit.init(root.hash, &.{});
 67     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, .{
 68         .name = "main",
 69         .head = commit.hash,
 70         .working = version.WorkingSet.init(root.hash),
 71     }, &root);
 72     defer database_session.deinit();
 73 
 74     try relation_session.put(1, &.{.{ .text = "one" }});
 75     try std.testing.expectEqual(@as(usize, 1), relation_session.pendingEdits());
 76     try std.testing.expect(version.same(before_relation, relation_session.root.hash));
 77 
 78     var workspace = try DatabaseWrite.Workspace.allocate(
 79         std.testing.allocator,
 80         testing_write_limits,
 81     );
 82     defer workspace.deallocate(std.testing.allocator);
 83     var write = try database_session.beginWrite(
 84         &workspace,
 85         std.testing.allocator,
 86         testing_write_limits,
 87         .{ .durability = .buffered },
 88     );
 89     defer write.deinit();
 90     try write.stageRelation(&relation_session);
 91     relation_session_live = false;
 92     var database_flush = try write.flush();
 93     defer database_flush.deinit();
 94     const first_flush = database_flush.onlyRelation();
 95 
 96     var relation_after = try RelationSession.open(std.testing.allocator, &catalog, "items");
 97     defer relation_after.deinit();
 98     try std.testing.expect(!version.same(before_relation, relation_after.root.hash));
 99     try std.testing.expect(version.same(relation_after.root.hash, first_flush.relation));
100     try std.testing.expect(version.same(database_session.checkout.working.working, first_flush.database));
101     try std.testing.expectEqual(@as(usize, 1), relation_after.root.table.summary.entries);
102     try std.testing.expectEqual(relation_after.schema, try catalog.schemaState(std.testing.allocator));
103 
104     const found = (try relation_after.handle.relation.get(std.testing.allocator, 1)).?;
105     defer std.testing.allocator.free(found);
106     const view = try row.View.init(found);
107     try std.testing.expectEqualStrings("one", (try view.column(0)).text);
108 }
109 
110 test "database write appends staged edits to staged relations" {
111     var tmp = std.testing.tmpDir(.{});
112     defer tmp.cleanup();
113 
114     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
115         .paths = .{ .database = "database-write-staged-edits.db", .wal = "database-write-staged-edits.wal" },
116         .header = testingHeader(),
117     });
118     defer database.deinit();
119     try database.reserve(.{ .wal_frames = 512 });
120 
121     var catalog = try catalog_mod.Catalog.open(&database, .{});
122     _ = try catalog.createRelation(std.testing.allocator, .{
123         .name = "items",
124         .columns = &.{ .{ .name = "name" }, .{ .name = "score" } },
125     }, .{ .durability = .buffered });
126 
127     var root = try testingDatabaseRoot(std.testing.allocator, &catalog);
128     const commit = version.Commit.init(root.hash, &.{});
129     const root_hash = root.hash;
130     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, branch.checkout(.{
131         .name = "main",
132         .target = commit.hash,
133     }, root_hash), &root);
134     defer database_session.deinit();
135 
136     var workspace = try DatabaseWrite.Workspace.allocate(
137         std.testing.allocator,
138         testing_write_limits,
139     );
140     defer workspace.deallocate(std.testing.allocator);
141     var write = try database_session.beginWrite(
142         &workspace,
143         std.testing.allocator,
144         testing_write_limits,
145         .{ .durability = .buffered },
146     );
147     defer write.deinit();
148     try std.testing.expect(write.stagedRelationRoot("items") == null);
149     try std.testing.expectError(error.NoStagedRelation, write.stagePut("items", 1, &.{ .{ .text = "ada" }, .{ .integer = 1 } }));
150 
151     var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
152     const base_root = relation_session.root.hash;
153     var relation_session_live = true;
154     errdefer if (relation_session_live) relation_session.deinit();
155     try relation_session.put(1, &.{ .{ .text = "ada" }, .{ .integer = 1 } });
156     try write.stageRelation(&relation_session);
157     relation_session_live = false;
158 
159     try std.testing.expect(version.same(base_root, write.stagedRelationRoot("items").?.hash));
160     try write.stagePut("items", 2, &.{ .{ .text = "grace" }, .{ .integer = 2 } });
161     try write.stagePut("items", 3, &.{ .{ .text = "ida" }, .{ .integer = 3 } });
162     try write.stageUpdate("items", 2, &.{.{ .column = 1, .value = .{ .integer = 20 } }});
163     try write.stageDelete("items", 3);
164     try std.testing.expectEqual(@as(usize, 1), write.pendingRelations());
165 
166     var flush = try write.flush();
167     defer flush.deinit();
168 
169     var handle = try catalog.openRelation(std.testing.allocator, "items");
170     defer handle.deinit();
171     const first = (try handle.relation.get(std.testing.allocator, 1)).?;
172     defer std.testing.allocator.free(first);
173     const first_view = try row.View.init(first);
174     try std.testing.expectEqualStrings("ada", (try first_view.column(0)).text);
175     const second = (try handle.relation.get(std.testing.allocator, 2)).?;
176     defer std.testing.allocator.free(second);
177     const second_view = try row.View.init(second);
178     try std.testing.expectEqualStrings("grace", (try second_view.column(0)).text);
179     try std.testing.expectEqual(@as(i64, 20), (try second_view.column(1)).integer);
180     const third = try handle.relation.get(std.testing.allocator, 3);
181     if (third) |bytes| std.testing.allocator.free(bytes);
182     try std.testing.expect(third == null);
183 }
184 
185 test "database write limits bound staged edit storage" {
186     comptime {
187         @stardustClaim(
188             @import("alloc_phase").capacity.witness(@import("./staging/root.zig").Storage, "sql_database_write_staging_semantics_overload"),
189             null,
190             null,
191             null,
192             null,
193             null,
194             null,
195         );
196     }
197     comptime {
198         @stardustClaim(
199             @import("alloc_phase").capacity.witness(@import("./staging/root.zig").Storage, "sql_database_write_staging_semantics_transitive_risk"),
200             null,
201             null,
202             null,
203             null,
204             null,
205             null,
206         );
207     }
208     comptime {
209         @stardustClaim(
210             @import("alloc_phase").capacity.witness(@import("./staging/root.zig").Storage, "sql_database_write_staging_semantics_foreign_risk"),
211             null,
212             null,
213             null,
214             null,
215             null,
216             null,
217         );
218     }
219 
220     var tmp = std.testing.tmpDir(.{});
221     defer tmp.cleanup();
222 
223     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
224         .paths = .{ .database = "database-write-bounds.db", .wal = "database-write-bounds.wal" },
225         .header = testingHeader(),
226     });
227     defer database.deinit();
228     try database.reserve(.{ .wal_frames = 512 });
229 
230     var catalog = try catalog_mod.Catalog.open(&database, .{});
231     _ = try catalog.createRelation(std.testing.allocator, .{
232         .name = "items",
233         .columns = &.{ .{ .name = "name" }, .{ .name = "score" } },
234     }, .{ .durability = .buffered });
235     _ = try catalog.createRelation(std.testing.allocator, .{
236         .name = "other",
237         .columns = &.{.{ .name = "name" }},
238     }, .{ .durability = .buffered });
239 
240     var root = try testingDatabaseRoot(std.testing.allocator, &catalog);
241     const commit = version.Commit.init(root.hash, &.{});
242     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, branch.checkout(.{
243         .name = "main",
244         .target = commit.hash,
245     }, root.hash), &root);
246     defer database_session.deinit();
247 
248     var items = try RelationSession.open(std.testing.allocator, &catalog, "items");
249     var items_live = true;
250     errdefer if (items_live) items.deinit();
251     var other = try RelationSession.open(std.testing.allocator, &catalog, "other");
252     defer other.deinit();
253 
254     const put_values = [_]row.Value{ .{ .text = "bounded" }, .{ .integer = 7 } };
255     const update_text = "updated";
256     const put_bytes = try row.encodedSize(&put_values);
257     const limits = DatabaseWrite.Limits{
258         .relations = 1,
259         .edits = 4,
260         .payload_bytes = put_bytes + update_text.len,
261         .assignments = 1,
262     };
263     var phase_allocator = try alloc_phase.SealedPhaseAllocator.init(std.testing.allocator);
264     var maybe_write: ?DatabaseWrite = null;
265     const phase_memory = phase_allocator.initializationAllocator();
266     var workspace = try DatabaseWrite.Workspace.allocate(phase_memory, limits);
267     var workspace_live = true;
268     errdefer {
269         if (phase_allocator.phase() == .initialization) phase_allocator.abortInitialization();
270         if (phase_allocator.phase() == .steady) phase_allocator.beginTeardown();
271         if (maybe_write) |*write| write.deinit();
272         if (workspace_live) {
273             workspace.deallocate(phase_allocator.teardownAllocator());
274         }
275         phase_allocator.deinit();
276     }
277     phase_allocator.seal();
278     maybe_write = try database_session.beginWrite(
279         &workspace,
280         phase_memory,
281         limits,
282         .{ .durability = .buffered },
283     );
284     const write = &maybe_write.?;
285 
286     try write.stageRelation(&items);
287     items_live = false;
288     try std.testing.expectError(error.TooManyStagedRelations, write.stageRelation(&other));
289     try write.stagePut("items", 1, &put_values);
290     try write.stageUpdate("items", 1, &.{.{ .column = 0, .value = .{ .text = update_text } }});
291     try std.testing.expectError(
292         error.TooManyStagedAssignments,
293         write.stageUpdate("items", 1, &.{.{ .column = 1, .value = .nil }}),
294     );
295     try std.testing.expectError(
296         error.StagedPayloadTooLarge,
297         write.stagePut("items", 2, &.{ .{ .text = "overflow" }, .{ .integer = 8 } }),
298     );
299     try write.stageDelete("items", 2);
300     try write.stageDelete("items", 3);
301     try std.testing.expectError(error.TooManyStagedEdits, write.stageDelete("items", 4));
302 
303     try std.testing.expectEqual(@as(usize, 1), write.pendingRelations());
304     var edits = write.stagedRelationEdits("items").?;
305     const expected_tags = [_]std.meta.Tag(relation_mod.Edit){ .put, .update, .delete, .delete };
306     for (expected_tags) |expected| {
307         try std.testing.expectEqual(expected, std.meta.activeTag(edits.next().?));
308     }
309     try std.testing.expect(edits.next() == null);
310     try std.testing.expectEqual(alloc_phase.PhaseViolations{}, phase_allocator.violations());
311 
312     write.deinit();
313     maybe_write = null;
314     maybe_write = try database_session.beginWrite(
315         &workspace,
316         phase_memory,
317         limits,
318         .{ .durability = .buffered },
319     );
320     maybe_write.?.deinit();
321     maybe_write = null;
322     try std.testing.expectEqual(alloc_phase.PhaseViolations{}, phase_allocator.violations());
323 
324     phase_allocator.beginTeardown();
325     workspace.deallocate(phase_allocator.teardownAllocator());
326     workspace_live = false;
327     try std.testing.expectEqual(alloc_phase.PhaseViolations{}, phase_allocator.violations());
328     phase_allocator.deinit();
329 }
330 
331 test "database write can trust staged relation indexes" {
332     var tmp = std.testing.tmpDir(.{});
333     defer tmp.cleanup();
334 
335     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
336         .paths = .{ .database = "database-write-trusted-indexes.db", .wal = "database-write-trusted-indexes.wal" },
337         .header = testingHeader(),
338     });
339     defer database.deinit();
340     try database.reserve(.{ .wal_frames = 512 });
341 
342     var catalog = try catalog_mod.Catalog.open(&database, .{});
343     const columns = [_]catalog_mod.ColumnDefinition{
344         .{ .name = "name" },
345         .{ .name = "score" },
346     };
347     const indexes = [_]catalog_mod.IndexDefinition{.{
348         .name = "items_score",
349         .fields = &.{1},
350     }};
351     _ = try catalog.createRelation(std.testing.allocator, .{
352         .name = "items",
353         .columns = &columns,
354         .indexes = &indexes,
355     }, .{ .durability = .buffered });
356 
357     {
358         var handle = try catalog.openRelation(std.testing.allocator, "items");
359         defer handle.deinit();
360         _ = try handle.relation.put(std.testing.allocator, 1, &.{ .{ .text = "ada" }, .{ .integer = 7 } }, .{ .durability = .buffered });
361         _ = try handle.relation.indexes[0].index.delete(1, &.{.{ .integer = 7 }}, .{ .durability = .buffered });
362         try std.testing.expectError(error.SecondaryIndexCorrupt, handle.relation.validateIndexes(std.testing.allocator));
363     }
364 
365     var root = try testingDatabaseRoot(std.testing.allocator, &catalog);
366     const commit = version.Commit.init(root.hash, &.{});
367     const root_hash = root.hash;
368     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, branch.checkout(.{
369         .name = "main",
370         .target = commit.hash,
371     }, root_hash), &root);
372     defer database_session.deinit();
373 
374     var workspace = try DatabaseWrite.Workspace.allocate(
375         std.testing.allocator,
376         testing_write_limits,
377     );
378     defer workspace.deallocate(std.testing.allocator);
379     {
380         var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
381         var relation_session_live = true;
382         errdefer if (relation_session_live) relation_session.deinit();
383         try relation_session.put(2, &.{ .{ .text = "grace" }, .{ .integer = 9 } });
384         var write = try database_session.beginWrite(&workspace, std.testing.allocator, testing_write_limits, .{
385             .durability = .buffered,
386             .validate_indexes = true,
387         });
388         defer write.deinit();
389         try write.stageRelation(&relation_session);
390         relation_session_live = false;
391         try std.testing.expectError(error.SecondaryIndexCorrupt, write.flush());
392     }
393 
394     {
395         var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
396         var relation_session_live = true;
397         errdefer if (relation_session_live) relation_session.deinit();
398         try relation_session.put(2, &.{ .{ .text = "grace" }, .{ .integer = 9 } });
399         var write = try database_session.beginWrite(&workspace, std.testing.allocator, testing_write_limits, .{
400             .durability = .buffered,
401             .validate_indexes = false,
402         });
403         defer write.deinit();
404         try write.stageRelation(&relation_session);
405         relation_session_live = false;
406         var flush = try write.flush();
407         defer flush.deinit();
408     }
409 
410     var handle = try catalog.openRelation(std.testing.allocator, "items");
411     defer handle.deinit();
412     const found = (try handle.relation.get(std.testing.allocator, 2)).?;
413     defer std.testing.allocator.free(found);
414     const view = try row.View.init(found);
415     try std.testing.expectEqualStrings("grace", (try view.column(0)).text);
416 }
417 
418 test "database session rejects staged relations that drift from working values" {
419     var tmp = std.testing.tmpDir(.{});
420     defer tmp.cleanup();
421 
422     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
423         .paths = .{ .database = "database-session-stale-relation.db", .wal = "database-session-stale-relation.wal" },
424         .header = testingHeader(),
425     });
426     defer database.deinit();
427     try database.reserve(.{ .wal_frames = 512 });
428 
429     var catalog = try catalog_mod.Catalog.open(&database, .{});
430     _ = try catalog.createRelation(std.testing.allocator, .{
431         .name = "items",
432         .columns = &.{.{ .name = "value" }},
433     }, .{ .durability = .buffered });
434 
435     var root = try testingDatabaseRoot(std.testing.allocator, &catalog);
436     const commit = version.Commit.init(root.hash, &.{});
437     const root_hash = root.hash;
438     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, branch.checkout(.{
439         .name = "main",
440         .target = commit.hash,
441     }, root_hash), &root);
442     defer database_session.deinit();
443 
444     var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
445     var relation_session_live = true;
446     errdefer if (relation_session_live) relation_session.deinit();
447     try relation_session.put(2, &.{.{ .text = "session" }});
448 
449     var live = try catalog.openRelation(std.testing.allocator, "items");
450     defer live.deinit();
451     _ = try live.relation.put(std.testing.allocator, 99, &.{.{ .text = "external" }}, .{ .durability = .buffered });
452 
453     var workspace = try DatabaseWrite.Workspace.allocate(
454         std.testing.allocator,
455         testing_write_limits,
456     );
457     defer workspace.deallocate(std.testing.allocator);
458     var write = try database_session.beginWrite(
459         &workspace,
460         std.testing.allocator,
461         testing_write_limits,
462         .{ .durability = .buffered },
463     );
464     defer write.deinit();
465     try write.stageRelation(&relation_session);
466     relation_session_live = false;
467     try std.testing.expectError(error.StagedRelationRootMismatch, write.flush());
468 
469     const missing = try live.relation.get(std.testing.allocator, 2);
470     if (missing) |bytes| std.testing.allocator.free(bytes);
471     try std.testing.expect(missing == null);
472     const external = (try live.relation.get(std.testing.allocator, 99)).?;
473     defer std.testing.allocator.free(external);
474     const view = try row.View.init(external);
475     try std.testing.expectEqualStrings("external", (try view.column(0)).text);
476 }
477 
478 test "database session stages flushed database roots before history commits" {
479     var tmp = std.testing.tmpDir(.{});
480     defer tmp.cleanup();
481 
482     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
483         .paths = .{ .database = "database-session.db", .wal = "database-session.wal" },
484         .header = testingHeader(),
485     });
486     defer database.deinit();
487     try database.reserve(.{ .wal_frames = 512 });
488 
489     var catalog = try catalog_mod.Catalog.open(&database, .{});
490     _ = try catalog.createRelation(std.testing.allocator, .{
491         .name = "items",
492         .columns = &.{.{ .name = "value" }},
493     }, .{ .durability = .buffered });
494 
495     var database_root_value = try testingDatabaseRoot(std.testing.allocator, &catalog);
496     const database_root = database_root_value.hash;
497 
498     var history = try history_mod.History.open(std.testing.allocator, tmp.dir, .{ .path = "database-session.history", .recovery = .reject });
499     defer history.deinit();
500     const root_commit = version.Commit.init(database_root, &.{});
501     try history.putCommit(root_commit);
502     _ = try history.createBranch("main", root_commit.hash);
503 
504     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, try history.checkoutBranch("main"), &database_root_value);
505     defer database_session.deinit();
506     try std.testing.expect(version.same(root_commit.hash, database_session.checkout.head));
507     try std.testing.expect(version.same(database_root, database_session.checkout.working.base));
508 
509     var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
510     var relation_session_live = true;
511     errdefer if (relation_session_live) relation_session.deinit();
512 
513     try relation_session.put(1, &.{.{ .text = "one" }});
514     var workspace = try DatabaseWrite.Workspace.allocate(
515         std.testing.allocator,
516         testing_write_limits,
517     );
518     defer workspace.deallocate(std.testing.allocator);
519     var write = try database_session.beginWrite(
520         &workspace,
521         std.testing.allocator,
522         testing_write_limits,
523         .{ .durability = .buffered },
524     );
525     defer write.deinit();
526     try write.stageRelation(&relation_session);
527     relation_session_live = false;
528     try std.testing.expectEqual(@as(usize, 1), write.pendingRelations());
529     var database_flush = try write.flush();
530     defer database_flush.deinit();
531     const flush = database_flush.onlyRelation();
532     try std.testing.expectEqual(@as(usize, 0), database_session.pendingRelations());
533     const working_root = database_session.workingRoot();
534     try std.testing.expect(version.same(flush.database, working_root.hash));
535     try std.testing.expectEqual(@as(usize, 1), working_root.entries.len);
536     try std.testing.expect(version.same(flush.relation, working_root.entries[0].hash));
537     var flushed_items = try RelationSession.open(std.testing.allocator, &catalog, "items");
538     defer flushed_items.deinit();
539     try std.testing.expectEqual(@as(usize, 1), flushed_items.root.table.summary.entries);
540 
541     try std.testing.expect(version.same(root_commit.hash, database_session.checkout.head));
542     try std.testing.expect(version.same(root_commit.hash, (try history.ref("main")).?.target));
543     try std.testing.expect(version.same(root_commit.root, database_session.checkout.working.base));
544     try std.testing.expect(version.same(flush.database, database_session.checkout.working.working));
545     try std.testing.expect(database_session.checkout.working.dirty());
546     try std.testing.expect(!database_session.checkout.working.hasStaged());
547     try std.testing.expectError(error.NoStagedRoot, database_session.commit(&history));
548 
549     database_session.stage();
550     try std.testing.expect(database_session.checkout.working.hasStaged());
551     const commit_hash = try database_session.commit(&history);
552     try std.testing.expect(version.same(commit_hash, (try history.ref("main")).?.target));
553     try std.testing.expect(version.same(commit_hash, database_session.checkout.head));
554     try std.testing.expect(version.same(flush.database, database_session.checkout.working.base));
555     try std.testing.expect(version.same(flush.database, database_session.workingRoot().hash));
556     try std.testing.expect(!database_session.checkout.working.dirty());
557     try std.testing.expect(!database_session.checkout.working.hasStaged());
558 
559     const checkout = try history.checkoutBranch("main");
560     try std.testing.expect(version.same(commit_hash, checkout.head));
561     try std.testing.expect(version.same(flush.database, checkout.working.base));
562 }
563 
564 test "database session flushes queued relation edits into one working root" {
565     var tmp = std.testing.tmpDir(.{});
566     defer tmp.cleanup();
567 
568     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
569         .paths = .{ .database = "database-session-queue.db", .wal = "database-session-queue.wal" },
570         .header = testingHeader(),
571     });
572     defer database.deinit();
573     try database.reserve(.{ .wal_frames = 768 });
574 
575     var catalog = try catalog_mod.Catalog.open(&database, .{});
576     _ = try catalog.createRelation(std.testing.allocator, .{
577         .name = "items",
578         .columns = &.{.{ .name = "value" }},
579     }, .{ .durability = .buffered });
580     _ = try catalog.createRelation(std.testing.allocator, .{
581         .name = "users",
582         .columns = &.{.{ .name = "name" }},
583     }, .{ .durability = .buffered });
584 
585     var database_root_value = try testingDatabaseRoot(std.testing.allocator, &catalog);
586     const database_root = database_root_value.hash;
587 
588     var history = try history_mod.History.open(std.testing.allocator, tmp.dir, .{ .path = "database-session-queue.history", .recovery = .reject });
589     defer history.deinit();
590     const root_commit = version.Commit.init(database_root, &.{});
591     try history.putCommit(root_commit);
592     _ = try history.createBranch("main", root_commit.hash);
593 
594     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, try history.checkoutBranch("main"), &database_root_value);
595     defer database_session.deinit();
596     var items_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
597     var items_session_live = true;
598     errdefer if (items_session_live) items_session.deinit();
599     var users_session = try RelationSession.open(std.testing.allocator, &catalog, "users");
600     var users_session_live = true;
601     errdefer if (users_session_live) users_session.deinit();
602 
603     const before_items = items_session.root.hash;
604     const before_users = users_session.root.hash;
605     const before_database = database_session.checkout.working.working;
606 
607     try items_session.put(1, &.{.{ .text = "bolt" }});
608     try users_session.put(7, &.{.{ .text = "ada" }});
609     var workspace = try DatabaseWrite.Workspace.allocate(
610         std.testing.allocator,
611         testing_write_limits,
612     );
613     defer workspace.deallocate(std.testing.allocator);
614     var write = try database_session.beginWrite(
615         &workspace,
616         std.testing.allocator,
617         testing_write_limits,
618         .{ .durability = .buffered },
619     );
620     errdefer write.deinit();
621     try write.stageRelation(&items_session);
622     items_session_live = false;
623     try write.stageRelation(&users_session);
624     users_session_live = false;
625     try std.testing.expectEqual(@as(usize, 2), write.pendingRelations());
626     try std.testing.expect(version.same(before_database, database_session.checkout.working.working));
627 
628     var flush = try write.flush();
629     defer flush.deinit();
630     var items_after = try RelationSession.open(std.testing.allocator, &catalog, "items");
631     defer items_after.deinit();
632     var users_after = try RelationSession.open(std.testing.allocator, &catalog, "users");
633     defer users_after.deinit();
634 
635     try std.testing.expectEqual(@as(usize, 2), flush.relations.len);
636     try std.testing.expectEqual(@as(usize, 0), database_session.pendingRelations());
637     try std.testing.expect(!version.same(before_items, items_after.root.hash));
638     try std.testing.expect(!version.same(before_users, users_after.root.hash));
639     try std.testing.expect(!version.same(before_database, flush.database));
640     try std.testing.expect(version.same(flush.database, database_session.checkout.working.working));
641     try std.testing.expect(version.same(flush.database, database_session.workingRoot().hash));
642     try std.testing.expect(version.same(flush.database, flush.relations[0].database));
643     try std.testing.expect(version.same(flush.database, flush.relations[1].database));
644     try std.testing.expect(version.same(items_after.root.hash, flush.relations[0].relation));
645     try std.testing.expect(version.same(users_after.root.hash, flush.relations[1].relation));
646 
647     const item = (try items_after.handle.relation.get(std.testing.allocator, 1)).?;
648     defer std.testing.allocator.free(item);
649     const item_view = try row.View.init(item);
650     try std.testing.expectEqualStrings("bolt", (try item_view.column(0)).text);
651     const user = (try users_after.handle.relation.get(std.testing.allocator, 7)).?;
652     defer std.testing.allocator.free(user);
653     const user_view = try row.View.init(user);
654     try std.testing.expectEqualStrings("ada", (try user_view.column(0)).text);
655 }
656 
657 test "database session assembles staged flushes from working database value" {
658     var tmp = std.testing.tmpDir(.{});
659     defer tmp.cleanup();
660 
661     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
662         .paths = .{ .database = "database-session-value-base.db", .wal = "database-session-value-base.wal" },
663         .header = testingHeader(),
664     });
665     defer database.deinit();
666     try database.reserve(.{ .wal_frames = 768 });
667 
668     var catalog = try catalog_mod.Catalog.open(&database, .{});
669     _ = try catalog.createRelation(std.testing.allocator, .{
670         .name = "items",
671         .columns = &.{.{ .name = "value" }},
672     }, .{ .durability = .buffered });
673     _ = try catalog.createRelation(std.testing.allocator, .{
674         .name = "users",
675         .columns = &.{.{ .name = "name" }},
676     }, .{ .durability = .buffered });
677 
678     var database_root_value = try testingDatabaseRoot(std.testing.allocator, &catalog);
679 
680     var history = try history_mod.History.open(std.testing.allocator, tmp.dir, .{ .path = "database-session-value-base.history", .recovery = .reject });
681     defer history.deinit();
682     const root_commit = version.Commit.init(database_root_value.hash, &.{});
683     try history.putCommit(root_commit);
684     _ = try history.createBranch("main", root_commit.hash);
685 
686     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, try history.checkoutBranch("main"), &database_root_value);
687     defer database_session.deinit();
688     var workspace = try DatabaseWrite.Workspace.allocate(
689         std.testing.allocator,
690         testing_write_limits,
691     );
692     defer workspace.deallocate(std.testing.allocator);
693 
694     var first_users = try RelationSession.open(std.testing.allocator, &catalog, "users");
695     var first_users_live = true;
696     errdefer if (first_users_live) first_users.deinit();
697     try first_users.put(1, &.{.{ .text = "ada" }});
698     var first_write = try database_session.beginWrite(
699         &workspace,
700         std.testing.allocator,
701         testing_write_limits,
702         .{ .durability = .buffered },
703     );
704     defer first_write.deinit();
705     try first_write.stageRelation(&first_users);
706     first_users_live = false;
707     var first_flush = try first_write.flush();
708     defer first_flush.deinit();
709 
710     const first_items_hash = testEntryHash(database_session.workingRoot(), "items");
711     var first_items = try RelationSession.open(std.testing.allocator, &catalog, "items");
712     defer first_items.deinit();
713     try std.testing.expectEqual(@as(usize, 0), first_items.root.table.summary.entries);
714 
715     var live_items = try catalog.openRelation(std.testing.allocator, "items");
716     defer live_items.deinit();
717     _ = try live_items.relation.put(std.testing.allocator, 9, &.{.{ .text = "live-only" }}, .{ .durability = .buffered });
718     var live_root = try testingDatabaseRoot(std.testing.allocator, &catalog);
719     defer live_root.deinit();
720     try std.testing.expect(!version.same(first_items_hash, testEntryHash(&live_root, "items")));
721     var live_items_after = try RelationSession.open(std.testing.allocator, &catalog, "items");
722     defer live_items_after.deinit();
723     try std.testing.expectEqual(@as(usize, 1), live_items_after.root.table.summary.entries);
724 
725     var second_users = try RelationSession.open(std.testing.allocator, &catalog, "users");
726     var second_users_live = true;
727     errdefer if (second_users_live) second_users.deinit();
728     try second_users.put(2, &.{.{ .text = "grace" }});
729     var second_write = try database_session.beginWrite(
730         &workspace,
731         std.testing.allocator,
732         testing_write_limits,
733         .{ .durability = .buffered },
734     );
735     defer second_write.deinit();
736     try second_write.stageRelation(&second_users);
737     second_users_live = false;
738     var second_flush = try second_write.flush();
739     defer second_flush.deinit();
740 
741     const second_root = database_session.workingRoot();
742     try std.testing.expect(version.same(second_flush.database, second_root.hash));
743     try std.testing.expect(version.same(second_flush.onlyRelation().relation, testEntryHash(second_root, "users")));
744     try std.testing.expect(version.same(testEntryHash(second_root, "items"), first_items_hash));
745     var second_users_after = try RelationSession.open(std.testing.allocator, &catalog, "users");
746     defer second_users_after.deinit();
747     try std.testing.expectEqual(@as(usize, 2), second_users_after.root.table.summary.entries);
748 }
749 
750 test "database session deinit discards queued relation edits" {
751     var tmp = std.testing.tmpDir(.{});
752     defer tmp.cleanup();
753 
754     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
755         .paths = .{ .database = "database-session-discard.db", .wal = "database-session-discard.wal" },
756         .header = testingHeader(),
757     });
758     defer database.deinit();
759     try database.reserve(.{ .wal_frames = 512 });
760 
761     var catalog = try catalog_mod.Catalog.open(&database, .{});
762     _ = try catalog.createRelation(std.testing.allocator, .{
763         .name = "items",
764         .columns = &.{.{ .name = "value" }},
765     }, .{ .durability = .buffered });
766 
767     var database_root_value = try testingDatabaseRoot(std.testing.allocator, &catalog);
768     const database_root = database_root_value.hash;
769 
770     var history = try history_mod.History.open(std.testing.allocator, tmp.dir, .{ .path = "database-session-discard.history", .recovery = .reject });
771     defer history.deinit();
772     const root_commit = version.Commit.init(database_root, &.{});
773     try history.putCommit(root_commit);
774     _ = try history.createBranch("main", root_commit.hash);
775 
776     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, try history.checkoutBranch("main"), &database_root_value);
777     var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
778     var relation_session_live = true;
779     errdefer if (relation_session_live) relation_session.deinit();
780 
781     try relation_session.put(11, &.{.{ .text = "discarded" }});
782     var workspace = try DatabaseWrite.Workspace.allocate(
783         std.testing.allocator,
784         testing_write_limits,
785     );
786     defer workspace.deallocate(std.testing.allocator);
787     var write = try database_session.beginWrite(
788         &workspace,
789         std.testing.allocator,
790         testing_write_limits,
791         .{ .durability = .buffered },
792     );
793     try write.stageRelation(&relation_session);
794     relation_session_live = false;
795     try std.testing.expectEqual(@as(usize, 1), write.pendingRelations());
796 
797     var handle = try catalog.openRelation(std.testing.allocator, "items");
798     defer handle.deinit();
799     const missing = try handle.relation.get(std.testing.allocator, 11);
800     if (missing) |bytes| std.testing.allocator.free(bytes);
801     try std.testing.expect(missing == null);
802     try std.testing.expect(version.same(database_root, database_session.checkout.working.working));
803     database_session.deinit();
804 }
805 
806 test "database session applies catalog roots before history commits" {
807     var tmp = std.testing.tmpDir(.{});
808     defer tmp.cleanup();
809 
810     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
811         .paths = .{ .database = "catalog-session.db", .wal = "catalog-session.wal" },
812         .header = testingHeader(),
813     });
814     defer database.deinit();
815     try database.reserve(.{ .wal_frames = 512 });
816 
817     var catalog = try catalog_mod.Catalog.open(&database, .{});
818     var database_root_value = try testingDatabaseRoot(std.testing.allocator, &catalog);
819     const initial_root = database_root_value.hash;
820 
821     var history = try history_mod.History.open(std.testing.allocator, tmp.dir, .{ .path = "catalog-session.history", .recovery = .reject });
822     defer history.deinit();
823     const root_commit = version.Commit.init(initial_root, &.{});
824     try history.putCommit(root_commit);
825     _ = try history.createBranch("main", root_commit.hash);
826 
827     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, try history.checkoutBranch("main"), &database_root_value);
828     defer database_session.deinit();
829     const columns = [_]catalog_mod.ColumnDefinition{
830         .{ .name = "name" },
831         .{ .name = "score" },
832     };
833     const indexes = [_]catalog_mod.IndexDefinition{.{
834         .name = "items_score",
835         .fields = &.{1},
836     }};
837     const create_flush = try database_session.createRelation(std.testing.allocator, &catalog, .{
838         .name = "items",
839         .columns = &columns,
840         .indexes = &indexes,
841     }, .{ .durability = .buffered });
842 
843     try std.testing.expectEqual(@as(u64, 1), create_flush.commit.schema.version);
844     try std.testing.expect(version.same(create_flush.database, database_session.checkout.working.working));
845     const create_root = database_session.workingRoot();
846     try std.testing.expect(version.same(create_flush.database, create_root.hash));
847     try std.testing.expectEqual(@as(usize, 1), create_root.entries.len);
848     try std.testing.expectEqualStrings("items", create_root.entries[0].name);
849     var created_items = try RelationSession.open(std.testing.allocator, &catalog, "items");
850     defer created_items.deinit();
851     try std.testing.expectEqual(@as(usize, 0), created_items.root.table.summary.entries);
852     try std.testing.expect(database_session.checkout.working.dirty());
853     try std.testing.expect(!database_session.checkout.working.hasStaged());
854     try std.testing.expect(version.same(root_commit.hash, (try history.ref("main")).?.target));
855 
856     var relation_session = try RelationSession.open(std.testing.allocator, &catalog, "items");
857     var relation_session_live = true;
858     errdefer if (relation_session_live) relation_session.deinit();
859     try relation_session.put(1, &.{ .{ .text = "ada" }, .{ .integer = 7 } });
860     var workspace = try DatabaseWrite.Workspace.allocate(
861         std.testing.allocator,
862         testing_write_limits,
863     );
864     defer workspace.deallocate(std.testing.allocator);
865     var write = try database_session.beginWrite(
866         &workspace,
867         std.testing.allocator,
868         testing_write_limits,
869         .{ .durability = .buffered },
870     );
871     defer write.deinit();
872     try write.stageRelation(&relation_session);
873     relation_session_live = false;
874     var row_database_flush = try write.flush();
875     defer row_database_flush.deinit();
876     const row_flush = row_database_flush.onlyRelation();
877 
878     const analyze_flush = try database_session.analyzeRelation(std.testing.allocator, &catalog, "items", .{ .durability = .buffered });
879     try std.testing.expectEqual(create_flush.commit.schema, analyze_flush.commit.schema);
880     try std.testing.expect(!version.same(row_flush.database, analyze_flush.database));
881     try std.testing.expect(version.same(analyze_flush.database, database_session.checkout.working.working));
882     const analyze_root = database_session.workingRoot();
883     try std.testing.expect(version.same(analyze_flush.database, analyze_root.hash));
884     try std.testing.expectEqual(@as(usize, 1), analyze_root.entries.len);
885     var analyzed_items = try RelationSession.open(std.testing.allocator, &catalog, "items");
886     defer analyzed_items.deinit();
887     try std.testing.expectEqual(@as(usize, 1), analyzed_items.root.table.summary.entries);
888     try std.testing.expect(!version.same(analyzed_items.root.stats.hash, version.emptyHash("sql.stats.none")));
889     var analyzed_stats = try catalog.relationStats(std.testing.allocator, "items");
890     defer if (analyzed_stats) |*relation_stats| relation_stats.deinit();
891     try std.testing.expect(analyzed_stats != null);
892 
893     database_session.stage();
894     const commit_hash = try database_session.commit(&history);
895     try std.testing.expect(version.same(commit_hash, (try history.ref("main")).?.target));
896     try std.testing.expect(version.same(analyze_flush.database, database_session.checkout.working.base));
897     try std.testing.expect(version.same(analyze_flush.database, database_session.workingRoot().hash));
898     try std.testing.expect(!database_session.checkout.working.dirty());
899 
900     const checkout = try history.checkoutBranch("main");
901     try std.testing.expect(version.same(commit_hash, checkout.head));
902     try std.testing.expect(version.same(analyze_flush.database, checkout.working.base));
903 }
904 
905 test "database session clears analyzed stats after relation edits" {
906     var tmp = std.testing.tmpDir(.{});
907     defer tmp.cleanup();
908 
909     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
910         .paths = .{ .database = "catalog-session-stats.db", .wal = "catalog-session-stats.wal" },
911         .header = testingHeader(),
912     });
913     defer database.deinit();
914     try database.reserve(.{ .wal_frames = 512 });
915 
916     var catalog = try catalog_mod.Catalog.open(&database, .{});
917     _ = try catalog.createRelation(std.testing.allocator, .{
918         .name = "items",
919         .columns = &.{.{ .name = "name" }},
920     }, .{ .durability = .buffered });
921 
922     var initial_root = try testingDatabaseRoot(std.testing.allocator, &catalog);
923     const initial_commit = version.Commit.init(initial_root.hash, &.{});
924     const initial_root_hash = initial_root.hash;
925     var database_session = DatabaseSession.initWithRoot(std.testing.allocator, branch.checkout(.{
926         .name = "main",
927         .target = initial_commit.hash,
928     }, initial_root_hash), &initial_root);
929     defer database_session.deinit();
930     var workspace = try DatabaseWrite.Workspace.allocate(
931         std.testing.allocator,
932         testing_write_limits,
933     );
934     defer workspace.deallocate(std.testing.allocator);
935 
936     var first_relation = try RelationSession.open(std.testing.allocator, &catalog, "items");
937     var first_relation_live = true;
938     errdefer if (first_relation_live) first_relation.deinit();
939     try first_relation.put(1, &.{.{ .text = "one" }});
940     var first_write = try database_session.beginWrite(
941         &workspace,
942         std.testing.allocator,
943         testing_write_limits,
944         .{ .durability = .buffered },
945     );
946     defer first_write.deinit();
947     try first_write.stageRelation(&first_relation);
948     first_relation_live = false;
949     var first_flush = try first_write.flush();
950     defer first_flush.deinit();
951 
952     const analyze_flush = try database_session.analyzeRelation(std.testing.allocator, &catalog, "items", .{ .durability = .buffered });
953     var analyzed_relation = try RelationSession.open(std.testing.allocator, &catalog, "items");
954     defer analyzed_relation.deinit();
955     try std.testing.expect(!version.same(analyzed_relation.root.stats.hash, version.emptyHash("sql.stats.none")));
956     try std.testing.expect(version.same(testEntryHash(database_session.workingRoot(), "items"), analyzed_relation.root.hash));
957     var analyzed_stats = (try catalog.relationStats(std.testing.allocator, "items")).?;
958     analyzed_stats.deinit();
959 
960     var second_relation = try RelationSession.open(std.testing.allocator, &catalog, "items");
961     var second_relation_live = true;
962     errdefer if (second_relation_live) second_relation.deinit();
963     try second_relation.put(2, &.{.{ .text = "two" }});
964     var second_write = try database_session.beginWrite(
965         &workspace,
966         std.testing.allocator,
967         testing_write_limits,
968         .{ .durability = .buffered },
969     );
970     defer second_write.deinit();
971     try second_write.stageRelation(&second_relation);
972     second_relation_live = false;
973     var second_flush = try second_write.flush();
974     defer second_flush.deinit();
975 
976     try std.testing.expect(!version.same(analyze_flush.database, second_flush.onlyRelation().database));
977     var edited_relation = try RelationSession.open(std.testing.allocator, &catalog, "items");
978     defer edited_relation.deinit();
979     try std.testing.expect(version.same(edited_relation.root.stats.hash, version.emptyHash("sql.stats.none")));
980     try std.testing.expect(version.same(testEntryHash(database_session.workingRoot(), "items"), edited_relation.root.hash));
981     try std.testing.expect((try catalog.relationStats(std.testing.allocator, "items")) == null);
982     try std.testing.expectEqual(@as(usize, 2), edited_relation.root.table.summary.entries);
983 }
984 
985 fn testEntryHash(root: *const version.DatabaseRoot, name: []const u8) version.Hash {
986     for (root.entries) |entry| {
987         if (std.mem.eql(u8, entry.name, name)) return entry.hash;
988     }
989     unreachable;
990 }
991 
992 fn testingHeader() wal.Header {
993     return .{
994         .sequence = 2101,
995         .salt = .{ .first = 0xabcd_0303, .second = 0xdcba_0404 },
996     };
997 }