tiny.sql.table
Defined in tiny.sql.
API (19)
Actions
Public operations.
Reader.count: Returns how many rows the table holds in this reader's snapshot.Reader.getReader.getIntoReader.lastRowIdReader.openReader.scan: Starts a scan of the rows fromstartup toendintarget.Reader.scanProjectedReader.summarizeReader.valueLength
Types and contracts
Public types and contracts.
EntryErrorOptionsProjection: Chooses what a table scan carries back for each row, so a scan then pays only for the part of each row it will look at.ReaderScanTableValueCapacityValueLimitsValueStorage
Source
Source: lib/sql/src/root.zig:45
zig
pub const table = @import("table.zig");Source: lib/sql/src/table.zig
zig
const std = @import("std");const alloc_phase = @import("alloc_phase");const file = @import("file.zig");const key = @import("key.zig");const page = @import("page.zig");const row = @import("row.zig");const trace = @import("trace.zig");const tree = @import("tree.zig");const wal = @import("wal.zig");const Allocator = std.mem.Allocator;const reader_get_instruction_page_alignment = 4096;pub const Error = tree.Error || row.Error || key.Error;pub const ValueLimits = struct { max_value_bytes: usize = 0, pub fn inspect(table: *const Table, rowid: i64) Error!?ValueLimits { const value_bytes = (try table.valueLength(rowid)) orelse return null; return .{ .max_value_bytes = value_bytes }; } pub fn include(self: *ValueLimits, limits: ValueLimits) void { self.max_value_bytes = @max(self.max_value_bytes, limits.max_value_bytes); }};pub const ValueCapacity = struct { max_value_bytes: usize, storage_bytes: usize, pub fn derive(limits: ValueLimits) ValueCapacity { return .{ .max_value_bytes = limits.max_value_bytes, .storage_bytes = limits.max_value_bytes, }; }};pub const ValueStorage = struct { phase: alloc_phase.capacity.Phase, capacity: ValueCapacity, bytes: []u8, pub const Limits: type = ValueLimits; pub const Capacity: type = ValueCapacity; pub const Exhaustion = error{ValueCapacityExceeded}; pub const InitError: type = Allocator.Error; pub const claim: alloc_phase.capacity.Declaration = .{ .source = .{ .id = "sql.table_value_storage", .kind = .phase_static, .limit_source = .caller, .storage = .{ .covered = &.{ .{ .id = "one_reusable_encoded_row_value_region", .lifetime = .steady, .detail = "one reusable encoded row value region", }, }, .excluded = &.{ "tree traversal, database/WAL bytes, file handles, and operating-system page cache", "file read cache, pager history, search hit storage, and decoded result fields", "caller-owned keys and trace instrumentation", }, }, .capacity = .{ .inputs = &.{ alloc_phase.capacity.bindInput(Limits, "max_value_bytes", "max_value_bytes"), }, .type_selectors = &.{}, .nodes = &.{ .{ .input = 0 }, }, .assertions = &.{.{ .scope = .closure_total, .measure = .retained, .relation = .exact, .expression = 0, }}, }, .overload = .{ .kind = .reject_before_mutation, .detail = "oversize values return ValueCapacityExceeded before reusable bytes mutate", }, .risks = .{ .transitive = .{ .status = .open, .detail = "tree reads use separate file, pager, cache, and trace owners", }, .foreign = .{ .status = .open, .detail = "base-page reads may enter the host filesystem and OS cache", }, }, .obligations = &.{ .{ .key = "sql_table_value_capacity", .role = .capacity_model }, .{ .key = "sql_table_value_oom_retry", .role = .custom }, .{ .key = "sql_table_value_sealed", .role = .overload }, .{ .key = "sql_table_value_semantics", .role = .custom }, }, }, .bindings = .{ .owner = @This(), .seal = .{ .family = alloc_phase.capacity.selector(@This().activate), .premise = .{ .class = .checked_semantic_fact, .authority = .checker, }, }, .teardown = .{ .family = alloc_phase.capacity.selector(@This().deinit), .premise = .{ .class = .checked_semantic_fact, .authority = .checker, }, }, }, }; pub fn init(allocator: Allocator, limits: ValueLimits) InitError!ValueStorage { const capacity = ValueCapacity.derive(limits); const bytes = if (capacity.storage_bytes == 0) @as([]u8, &.{}) else try allocator.alloc(u8, capacity.storage_bytes); return .{ .phase = .initialization, .capacity = capacity, .bytes = bytes, }; } pub fn activate(self: *ValueStorage) void { std.debug.assert(self.phase == .initialization); std.debug.assert(self.bytes.len == self.capacity.storage_bytes); self.phase = .steady; } pub fn get( self: *ValueStorage, table: *const Table, rowid: i64, ) (Error || Exhaustion)!?[]const u8 { std.debug.assert(self.phase == .steady); const value = table.getInto(rowid, self.bytes) catch |err| switch (err) { error.OutputTooSmall => return error.ValueCapacityExceeded, else => return err, }; return value; } pub fn deinit(self: *ValueStorage, allocator: Allocator) void { std.debug.assert(self.phase != .teardown); std.debug.assert(self.bytes.len == self.capacity.storage_bytes); self.phase = .teardown; if (self.bytes.len != 0) allocator.free(self.bytes); self.bytes = &.{}; }};comptime { alloc_phase.capacity.requireAllocatorRejectingOwnerShape(ValueStorage);}pub const Options = struct { tree: tree.Options = .{},};/// Chooses what a table scan carries back for each row, so a scan then pays/// only for the part of each row it will look at. `key` gives the row's key and/// an empty byte slice, `record` gives the record bytes as they are stored,/// which for a large row is the pointer to its overflow pages, and `value`/// gives the decoded row value and reads the overflow pages when the record/// points at them. The projection is chosen once for a whole scan, where `scan`/// picks `value` for callers that do not say, and the type is re-exported from/// the B-tree layer so a table scan and a tree scan take the same values.pub const Projection = tree.Projection;pub const Entry = struct { rowid: i64, bytes: []const u8, pub fn view(self: Entry) row.Error!row.View { return try row.View.init(self.bytes); }};pub const Reader = struct { rows: tree.Reader, pub fn open(snapshot: file.Snapshot, options: Options) Error!Reader { return .{ .rows = try tree.Reader.open(snapshot, options.tree) }; } pub fn lastRowId(self: *const Reader) Error!?i64 { var buffer: [key.rowid_size]u8 = undefined; const last = (try self.rows.lastKey(&buffer)) orelse return null; return try key.decodeRowId(last); } pub fn get(self: *const Reader, allocator: Allocator, rowid: i64) align(reader_get_instruction_page_alignment) Error!?[]u8 { const phase = trace.scope("table.get"); defer phase.end(); var key_bytes: [key.rowid_size]u8 = undefined; return try self.rows.get(allocator, try key.encodeRowId(&key_bytes, rowid)); } pub fn valueLength(self: *const Reader, rowid: i64) Error!?usize { var key_bytes: [key.rowid_size]u8 = undefined; return try self.rows.valueLength(try key.encodeRowId(&key_bytes, rowid)); } pub fn getInto(self: *const Reader, rowid: i64, target: []u8) Error!?[]u8 { var key_bytes: [key.rowid_size]u8 = undefined; return try self.rows.getInto(try key.encodeRowId(&key_bytes, rowid), target); } /// Starts a scan of the rows from `start` up to `end` in `target`. pub fn scan( self: *const Reader, target: *Scan, allocator: Allocator, start: ?i64, end: ?i64, ) Error!void { try self.scanProjected(target, allocator, start, end, .value); } pub fn scanProjected( self: *const Reader, target: *Scan, allocator: Allocator, start: ?i64, end: ?i64, projection: Projection, ) Error!void { const phase = trace.scope("table.scan"); defer phase.end(); var start_bytes: [key.rowid_size]u8 = undefined; var end_bytes: [key.rowid_size]u8 = undefined; const start_key = if (start) |rowid| try key.encodeRowId(&start_bytes, rowid) else null; const end_key = if (end) |rowid| try key.encodeRowId(&end_bytes, rowid) else null; try self.rows.scan(&target.rows, allocator, start_key, end_key, projection); } pub fn summarize(self: *const Reader) Error!tree.Summary { const phase = trace.scope("table.summarize"); defer phase.end(); return try self.rows.summarize(); } /// Returns how many rows the table holds in this reader's snapshot. A /// caller sizes a scan's output by it without walking the table. pub fn count(self: *const Reader) Error!usize { return try self.rows.count(); }};pub const Table = struct { rows: tree.Tree, pub fn open(database: *file.Database, options: Options) Error!Table { return .{ .rows = try tree.Tree.open(database, options.tree) }; } pub fn reader(self: *const Table, snapshot: file.Snapshot) Error!Reader { return .{ .rows = try self.rows.reader(snapshot) }; } pub fn lastRowId(self: *const Table) Error!?i64 { var read = try self.rows.database.beginRead(); defer read.deinit(); const opened = try self.reader(read.snapshot()); return try opened.lastRowId(); } pub fn put(self: *Table, rowid: i64, values: []const row.Value, options: file.CommitOptions) Error!file.Commit { const phase = trace.scope("table.put"); defer phase.end(); var write = try tree.Write.beginTree(&self.rows); defer write.deinit(); try self.putIn(&write, rowid, values); return try write.commit(options); } pub fn putIn(self: *Table, write: *tree.Write, rowid: i64, values: []const row.Value) Error!void { var key_bytes: [key.rowid_size]u8 = undefined; var row_bytes: [page.size]u8 = undefined; const encoded_key = try key.encodeRowId(&key_bytes, rowid); const encoded_row = try row.encode(&row_bytes, values); try write.put(&self.rows, encoded_key, encoded_row); } pub fn putEncoded(self: *Table, rowid: i64, bytes: []const u8, options: file.CommitOptions) Error!file.Commit { const phase = trace.scope("table.put_encoded"); defer phase.end(); var write = try tree.Write.beginTree(&self.rows); defer write.deinit(); try self.putEncodedIn(&write, rowid, bytes); return try write.commit(options); } pub fn putEncodedIn(self: *Table, write: *tree.Write, rowid: i64, bytes: []const u8) Error!void { _ = try row.View.init(bytes); var key_bytes: [key.rowid_size]u8 = undefined; try write.put(&self.rows, try key.encodeRowId(&key_bytes, rowid), bytes); } pub fn get(self: *const Table, allocator: Allocator, rowid: i64) Error!?[]u8 { var read = try self.rows.database.beginRead(); defer read.deinit(); const opened = try self.reader(read.snapshot()); return try opened.get(allocator, rowid); } pub fn valueLength(self: *const Table, rowid: i64) Error!?usize { var read = try self.rows.database.beginRead(); defer read.deinit(); const opened = try self.reader(read.snapshot()); return try opened.valueLength(rowid); } pub fn getInto(self: *const Table, rowid: i64, target: []u8) Error!?[]u8 { var read = try self.rows.database.beginRead(); defer read.deinit(); const opened = try self.reader(read.snapshot()); return try opened.getInto(rowid, target); } pub fn delete(self: *Table, rowid: i64, options: file.CommitOptions) Error!file.Commit { const phase = trace.scope("table.delete"); defer phase.end(); var write = try tree.Write.beginTree(&self.rows); defer write.deinit(); try self.deleteIn(&write, rowid); return try write.commit(options); } pub fn deleteIn(self: *Table, write: *tree.Write, rowid: i64) Error!void { var key_bytes: [key.rowid_size]u8 = undefined; try write.delete(&self.rows, try key.encodeRowId(&key_bytes, rowid)); } /// Starts a scan of the rows from `start` up to `end` in `target`. pub fn scan( self: *const Table, target: *Scan, allocator: Allocator, start: ?i64, end: ?i64, ) Error!void { try self.scanProjected(target, allocator, start, end, .value); } pub fn scanProjected( self: *const Table, target: *Scan, allocator: Allocator, start: ?i64, end: ?i64, projection: Projection, ) Error!void { var read = try self.rows.database.beginRead(); defer read.deinit(); const opened = try self.reader(read.snapshot()); try opened.scanProjected(target, allocator, start, end, projection); } pub fn summarize(self: *const Table) Error!tree.Summary { var read = try self.rows.database.beginRead(); defer read.deinit(); const opened = try self.reader(read.snapshot()); return try opened.summarize(); } pub fn count(self: *const Table) Error!usize { var read = try self.rows.database.beginRead(); defer read.deinit(); const opened = try self.reader(read.snapshot()); return try opened.count(); } pub fn summarizeIn(self: *const Table, write: *const tree.Write) Error!tree.Summary { const phase = trace.scope("table.summarize_in"); defer phase.end(); return try self.rows.summarizeIn(write); }};pub const Scan = struct { rows: tree.Scan, pub fn deinit(self: *Scan) void { self.rows.deinit(); self.* = undefined; } pub fn next(self: *Scan) Error!?Entry { if (try self.rows.next()) |entry| { return .{ .rowid = try key.decodeRowId(entry.key), .bytes = entry.bytes, }; } return null; }};test "table value capacity matches the independent maximum model" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_capacity"), null, null, null, null, null, null, ); } for (0..4097) |value_bytes| { const expected = ValueCapacity{ .max_value_bytes = value_bytes, .storage_bytes = value_bytes, }; try std.testing.expectEqual( expected, ValueCapacity.derive(.{ .max_value_bytes = value_bytes }), ); } try std.testing.expectEqual( ValueCapacity{ .max_value_bytes = std.math.maxInt(usize), .storage_bytes = std.math.maxInt(usize), }, ValueCapacity.derive(.{ .max_value_bytes = std.math.maxInt(usize) }), );}fn checkValueStorageInitFailures(allocator: Allocator) !void { var storage = try ValueStorage.init(allocator, .{ .max_value_bytes = 4096 }); storage.deinit(allocator);}test "table value storage initialization cleans allocation failure and retries" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_oom_retry"), null, null, null, null, null, null, ); } try std.testing.checkAllAllocationFailures( std.testing.allocator, checkValueStorageInitFailures, .{}, ); var storage = try ValueStorage.init(std.testing.allocator, .{ .max_value_bytes = 4096 }); defer storage.deinit(std.testing.allocator); storage.activate(); try std.testing.expectEqual(alloc_phase.capacity.Phase.steady, storage.phase);}fn checkSealedValueReads( table: *const Table, storage: *ValueStorage, rejecting: *ValueStorage, phase_allocator: *alloc_phase.SealedPhaseAllocator, large: []const u8,) !void { const storage_pointer = storage.bytes.ptr; const storage_capacity = storage.capacity; phase_allocator.seal(); storage.activate(); rejecting.activate(); const inline_bytes = (try storage.get(table, 1)).?; try std.testing.expect(storage.bytes.ptr == storage_pointer); try std.testing.expectEqual(storage_capacity, storage.capacity); try std.testing.expectEqualStrings( "inline", (try (try row.View.init(inline_bytes)).column(0)).text, ); const overflow_bytes = (try storage.get(table, 2)).?; try std.testing.expect(storage.bytes.ptr == storage_pointer); try std.testing.expectEqual(storage_capacity, storage.capacity); try std.testing.expectEqualSlices( u8, large, (try (try row.View.init(overflow_bytes)).column(0)).blob, ); try std.testing.expect((try storage.get(table, 3)) == null); @memset(rejecting.bytes, 0xa5); try std.testing.expectError(error.ValueCapacityExceeded, rejecting.get(table, 2)); for (rejecting.bytes) |byte| try std.testing.expectEqual(@as(u8, 0xa5), byte);}test "table value storage is sealed before inline overflow and rejection reads" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_sealed"), null, null, null, null, null, null, ); } var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = "table-value-sealed.db", .wal = "table-value-sealed.wal" }, .header = testingHeader(), }); defer database.deinit(); try database.reserve(.{ .wal_frames = 64 }); var table = try Table.open(&database, .{}); _ = try table.put(1, &.{.{ .text = "inline" }}, .{ .durability = .buffered }); var large: [page.overflow_capacity + 37]u8 = undefined; for (&large, 0..) |*byte, index| byte.* = @intCast(index % 251); var encoded_large: [large.len + 32]u8 = undefined; _ = try table.putEncoded( 2, try row.encode(&encoded_large, &.{.{ .blob = &large }}), .{ .durability = .buffered }, ); const inline_limits = (try ValueLimits.inspect(&table, 1)).?; var combined_limits = inline_limits; combined_limits.include((try ValueLimits.inspect(&table, 2)).?); try std.testing.expect((try ValueLimits.inspect(&table, 3)) == null); var phase_allocator = try alloc_phase.SealedPhaseAllocator.init(std.testing.allocator); var storage = ValueStorage.init( phase_allocator.initializationAllocator(), combined_limits, ) catch |err| { phase_allocator.abortInitialization(); phase_allocator.deinit(); return err; }; var rejecting = ValueStorage.init( phase_allocator.initializationAllocator(), inline_limits, ) catch |err| { storage.deinit(phase_allocator.initializationAllocator()); phase_allocator.abortInitialization(); phase_allocator.deinit(); return err; }; defer { if (phase_allocator.phase() == .initialization) phase_allocator.abortInitialization(); if (phase_allocator.phase() == .steady) phase_allocator.beginTeardown(); if (rejecting.phase != .teardown) rejecting.deinit(phase_allocator.teardownAllocator()); if (storage.phase != .teardown) storage.deinit(phase_allocator.teardownAllocator()); phase_allocator.deinit(); } try checkSealedValueReads(&table, &storage, &rejecting, &phase_allocator, &large);}test "table value storage preserves typed row semantics" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(ValueStorage, "sql_table_value_semantics"), null, null, null, null, null, null, ); } var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = "table-value-semantics.db", .wal = "table-value-semantics.wal" }, .header = testingHeader(), }); defer database.deinit(); try database.reserve(.{ .wal_frames = 64 }); var table = try Table.open(&database, .{}); _ = try table.put( 7, &.{ .{ .integer = -42 }, .{ .text = "placed" }, .nil }, .{ .durability = .buffered }, ); const limits = (try ValueLimits.inspect(&table, 7)).?; var storage = try ValueStorage.init(std.testing.allocator, limits); defer storage.deinit(std.testing.allocator); storage.activate(); const view = try row.View.init((try storage.get(&table, 7)).?); try std.testing.expectEqual(@as(i64, -42), (try view.column(0)).integer); try std.testing.expectEqualStrings("placed", (try view.column(1)).text); try std.testing.expectEqual(row.Storage.nil, std.meta.activeTag(try view.column(2)));}test "rowid table stores typed rows and scans in rowid order" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = "table.db", .wal = "table.wal" }, .header = testingHeader(), }); defer database.deinit(); try database.reserve(.{ .wal_frames = 64 }); var table = try Table.open(&database, .{}); _ = try table.put(7, &.{ .{ .integer = 7 }, .{ .text = "seven" } }, .{ .durability = .buffered }); _ = try table.put(-2, &.{ .{ .integer = -2 }, .{ .text = "minus-two" } }, .{ .durability = .buffered }); _ = try table.put(0, &.{ .{ .integer = 0 }, .{ .text = "zero" } }, .{ .durability = .buffered }); const found = (try table.get(std.testing.allocator, -2)).?; defer std.testing.allocator.free(found); const view = try row.View.init(found); try std.testing.expectEqual(@as(i64, -2), (try view.column(0)).integer); try std.testing.expectEqualStrings("minus-two", (try view.column(1)).text); var scan: Scan = undefined; try table.scan(&scan, std.testing.allocator, null, null); defer scan.deinit(); const first = (try scan.next()).?; const second = (try scan.next()).?; const third = (try scan.next()).?; try std.testing.expectEqual(@as(i64, -2), first.rowid); try std.testing.expectEqual(@as(i64, 0), second.rowid); try std.testing.expectEqual(@as(i64, 7), third.rowid); try std.testing.expect(try scan.next() == null);}test "rowid table key projection avoids overflow materialization" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = "table-projection.db", .wal = "table-projection.wal" }, .header = testingHeader(), }); defer database.deinit(); try database.reserve(.{ .wal_frames = 64 }); var table = try Table.open(&database, .{}); const large: [2048]u8 = @splat(0xa5); _ = try table.put( 7, &.{.{ .blob = &large }}, .{ .durability = .buffered }, ); var key_failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, ); var key_scan: Scan = undefined; try table.scanProjected( &key_scan, key_failing.allocator(), null, null, .key, ); defer key_scan.deinit(); const entry = (try key_scan.next()).?; try std.testing.expectEqual(@as(i64, 7), entry.rowid); try std.testing.expectEqual(@as(usize, 0), entry.bytes.len); try std.testing.expect(try key_scan.next() == null); var value_failing = std.testing.FailingAllocator.init( std.testing.allocator, .{ .fail_index = 0 }, ); var value_scan: Scan = undefined; try table.scanProjected( &value_scan, value_failing.allocator(), null, null, .value, ); defer value_scan.deinit(); try std.testing.expectError(error.OutOfMemory, value_scan.next());}test "rowid table reports the last assigned rowid" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = "table.db", .wal = "table.wal" }, .header = testingHeader(), }); defer database.deinit(); try database.reserve(.{ .wal_frames = 256 }); var table = try Table.open(&database, .{}); try std.testing.expectEqual(@as(?i64, null), try table.lastRowId()); _ = try table.put(7, &.{ .{ .integer = 7 }, .{ .text = "seven" } }, .{ .durability = .buffered }); try std.testing.expectEqual(@as(?i64, 7), try table.lastRowId()); _ = try table.put(3, &.{ .{ .integer = 3 }, .{ .text = "three" } }, .{ .durability = .buffered }); try std.testing.expectEqual(@as(?i64, 7), try table.lastRowId()); var rowid: i64 = 8; while (rowid <= 600) : (rowid += 1) { _ = try table.put(rowid, &.{ .{ .integer = rowid }, .{ .text = "filler" } }, .{ .durability = .buffered }); } try std.testing.expectEqual(@as(?i64, 600), try table.lastRowId());}test "rowid table deletes and recovers after reopen" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); { var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = "table.db", .wal = "table.wal" }, .header = testingHeader(), }); defer database.deinit(); try database.reserve(.{ .wal_frames = 64 }); var table = try Table.open(&database, .{}); _ = try table.put(1, &.{.{ .text = "one" }}, .{ .durability = .buffered }); _ = try table.put(2, &.{.{ .text = "two" }}, .{ .durability = .buffered }); _ = try table.delete(1, .{ .durability = .buffered }); try database.syncWal(); } var reopened = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = "table.db", .wal = "table.wal" }, .header = recoveredHeader(), }); defer reopened.deinit(); var table = try Table.open(&reopened, .{}); const missing = try table.get(std.testing.allocator, 1); if (missing) |bytes| std.testing.allocator.free(bytes); try std.testing.expect(missing == null); const found = (try table.get(std.testing.allocator, 2)).?; defer std.testing.allocator.free(found); const view = try row.View.init(found); try std.testing.expectEqualStrings("two", (try view.column(0)).text);}fn testingHeader() wal.Header { return .{ .sequence = 601, .salt = .{ .first = 0x7171_6262, .second = 0x5353_4444 }, };}fn recoveredHeader() wal.Header { return .{ .sequence = 602, .salt = .{ .first = 0x8888_9999, .second = 0xaaaa_bbbb }, };}Audit
| Definitions | 13 |
|---|---|
| Public names | 13 |
| Members | 2 |
| Version | 26.7.0 |
| Revision | daab053ee433 |