Files
cobalt/src/protocol.zig
T
2026-09-08 16:14:04 +02:00

3778 lines
155 KiB
Zig

const std = @import("std");
const encode = @import("encode.zig");
const dbmod = @import("db.zig");
const rowmod = @import("row.zig");
const authmod = @import("auth.zig");
const ttlmod = @import("ttl.zig");
const collmod = @import("collections.zig");
const aggregatemod = @import("aggregate.zig");
const windowmod = @import("window.zig");
const querymod = @import("query.zig");
const Allocator = std.mem.Allocator;
/// Guard against a corrupt or hostile length prefix causing an unbounded
/// allocation before a single byte of the frame body has been validated.
pub const maxFrameBytes: u32 = 64 * 1024 * 1024;
pub const Op = enum(u8) {
ping = 0,
get = 1,
set = 2,
del = 3,
scan = 4,
prefix = 5,
stats = 6,
flush = 7,
compact = 8,
batch = 9,
table_select = 10,
auth = 11,
user_admin = 12,
auth_token = 13,
metrics = 14,
ttl_op = 15,
coll_ttl_op = 16,
coll_op = 17,
txn_op = 18,
table_join = 19,
table_aggregate = 20,
table_explain = 21,
table_update = 22,
table_delete = 23,
table_export = 24,
table_import = 25,
table_window = 26,
subscribe = 27,
publish = 28,
unsubscribe = 29,
pub fn fromByte(b: u8) !Op {
return std.meta.intToEnum(Op, b) catch error.UnknownOp;
}
};
pub const Status = enum(u8) {
ok = 0,
not_found = 1,
bad_request = 2,
io_error = 3,
conflict = 4,
internal = 5,
rate_limited = 6,
unauthorized = 7,
forbidden = 8,
pub fn fromByte(b: u8) !Status {
return std.meta.intToEnum(Status, b) catch error.UnknownStatus;
}
};
pub const ProtoError = error{
UnknownOp,
UnknownStatus,
FrameTooLarge,
Malformed,
};
/// A frame is the unit exchanged over the wire in both directions: a
/// single tag byte (an `Op` on the request side, a `Status` on the
/// response side) followed by a little-endian u32 payload length and the
/// payload itself.
pub const Frame = struct {
tag: u8,
payload: []u8,
allocator: Allocator,
pub fn deinit(self: *Frame) void {
self.allocator.free(self.payload);
self.* = undefined;
}
};
pub fn writeFrame(writer: anytype, tag: u8, payload: []const u8) !void {
if (payload.len > maxFrameBytes) return error.FrameTooLarge;
var header: [5]u8 = undefined;
header[0] = tag;
std.mem.writeInt(u32, header[1..5], @intCast(payload.len), encode.endian);
try writer.writeAll(&header);
if (payload.len > 0) try writer.writeAll(payload);
}
pub fn readFrame(allocator: Allocator, reader: anytype) !Frame {
var header: [5]u8 = undefined;
try reader.readNoEof(&header);
const tag = header[0];
const len = std.mem.readInt(u32, header[1..5], encode.endian);
if (len > maxFrameBytes) return error.FrameTooLarge;
const payload = try allocator.alloc(u8, len);
errdefer allocator.free(payload);
if (len > 0) try reader.readNoEof(payload);
return .{ .tag = tag, .payload = payload, .allocator = allocator };
}
/// Bounds-checked cursor over a decoded payload. Every read returns
/// `error.Malformed` instead of panicking when the peer sent a short or
/// truncated buffer, since payload bytes come from an untrusted socket.
pub const Cursor = struct {
data: []const u8,
pos: usize = 0,
pub fn takeByte(self: *Cursor) !u8 {
if (self.pos + 1 > self.data.len) return error.Malformed;
const v = self.data[self.pos];
self.pos += 1;
return v;
}
pub fn takeU32(self: *Cursor) !u32 {
if (self.pos + 4 > self.data.len) return error.Malformed;
const v = encode.readU32(self.data, self.pos);
self.pos += 4;
return v;
}
pub fn takeU64(self: *Cursor) !u64 {
if (self.pos + 8 > self.data.len) return error.Malformed;
const v = encode.readU64(self.data, self.pos);
self.pos += 8;
return v;
}
pub fn takeBytes(self: *Cursor, n: usize) ![]const u8 {
if (self.pos + n > self.data.len) return error.Malformed;
const v = self.data[self.pos .. self.pos + n];
self.pos += n;
return v;
}
pub fn takeLenPrefixed(self: *Cursor) ![]const u8 {
const n = try self.takeU32();
return self.takeBytes(n);
}
pub fn atEnd(self: *Cursor) bool {
return self.pos == self.data.len;
}
};
fn putLenPrefixed(writer: encode.ByteWriter, v: []const u8) !void {
try writer.putU32(@intCast(v.len));
try writer.slice(v);
}
// --- set ---
pub fn encodeSet(allocator: Allocator, key: []const u8, value: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, key);
try w.slice(value);
return buf.toOwnedSlice();
}
pub const SetArgs = struct { key: []const u8, value: []const u8 };
pub fn decodeSet(payload: []const u8) !SetArgs {
var c = Cursor{ .data = payload };
const key = try c.takeLenPrefixed();
const value = c.data[c.pos..];
return .{ .key = key, .value = value };
}
// --- auth ---
pub fn encodeAuth(allocator: Allocator, username: []const u8, password: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, username);
try w.slice(password);
return buf.toOwnedSlice();
}
pub const AuthArgs = struct { username: []const u8, password: []const u8 };
pub fn decodeAuth(payload: []const u8) !AuthArgs {
var c = Cursor{ .data = payload };
const username = try c.takeLenPrefixed();
const password = c.data[c.pos..];
return .{ .username = username, .password = password };
}
/// A successful `auth` response's payload: empty when the server has no
/// session store wired up (see server.zig's `enableSessions`), or the raw
/// session token bytes when it does. There is no separate decode function
/// on the client side - `frame.payload` itself IS the token (or empty),
/// no framing needed for a single opaque blob.
pub fn encodeAuthOk(allocator: Allocator, token: ?[]const u8) ![]u8 {
return allocator.dupe(u8, token orelse "");
}
// --- auth_token ---
/// Reconnects a fresh connection to a role without presenting the
/// password again, by presenting a token previously handed out in an
/// `auth` response (see `session.zig`'s `SessionStore.issue`). The
/// payload is nothing but the opaque token bytes - no framing needed for
/// a single blob, exactly like `encodeAuthOk`.
pub fn encodeAuthToken(allocator: Allocator, token: []const u8) ![]u8 {
return allocator.dupe(u8, token);
}
pub fn decodeAuthToken(payload: []const u8) []const u8 {
return payload;
}
// --- user_admin ---
/// Remote counterpart of the CLI's local `user add|passwd|role|del|list`,
/// carried as one opcode so `server.zig`'s `requiredRole` can gate all five
/// actions behind a single `admin`-only check instead of five separate
/// opcodes. `role`/`rounds` are only meaningful for the actions that use
/// them (`create` reads both, `set_role` reads only `role`, `set_password`
/// reads only `rounds`); callers send 0 for the fields an action ignores.
pub const UserAdminAction = enum(u8) {
create = 0,
set_password = 1,
set_role = 2,
delete = 3,
list = 4,
pub fn fromByte(b: u8) !UserAdminAction {
return std.meta.intToEnum(UserAdminAction, b) catch error.Malformed;
}
};
pub fn encodeUserAdmin(allocator: Allocator, action: UserAdminAction, username: []const u8, password: []const u8, role: authmod.Role, rounds: u32) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(@intFromEnum(action));
try w.byte(@intFromEnum(role));
try w.putU32(rounds);
try putLenPrefixed(w, username);
try w.slice(password);
return buf.toOwnedSlice();
}
pub const UserAdminArgs = struct {
action: UserAdminAction,
roleByte: u8,
rounds: u32,
username: []const u8,
password: []const u8,
};
pub fn decodeUserAdmin(payload: []const u8) !UserAdminArgs {
var c = Cursor{ .data = payload };
const action = try UserAdminAction.fromByte(try c.takeByte());
const roleByte = try c.takeByte();
const rounds = try c.takeU32();
const username = try c.takeLenPrefixed();
const password = c.data[c.pos..];
return .{ .action = action, .roleByte = roleByte, .rounds = rounds, .username = username, .password = password };
}
/// Encodes the response of a `list` user_admin request: every username
/// paired with its role byte. Reuses `authmod.UserInfo`/`freeUserList`
/// rather than inventing a parallel type, the same way `encodeStats` reuses
/// `dbmod.Stats` and `encodeRows` reuses `rowmod.Row`.
pub fn encodeUserList(allocator: Allocator, users: []const authmod.UserInfo) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(users.len));
for (users) |u| {
try putLenPrefixed(w, u.username);
try w.byte(@intFromEnum(u.role));
}
return buf.toOwnedSlice();
}
/// Decodes a user list response into independently owned entries - free
/// with `authmod.freeUserList`.
pub fn decodeUserList(allocator: Allocator, payload: []const u8) ![]authmod.UserInfo {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(authmod.UserInfo).initCapacity(allocator, count);
errdefer {
for (list.items) |u| u.deinit(allocator);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const uname = try c.takeLenPrefixed();
const role = authmod.Role.fromByte(try c.takeByte()) catch return error.Malformed;
const owned = try allocator.dupe(u8, uname);
list.appendAssumeCapacity(.{ .username = owned, .role = role });
}
return list.toOwnedSlice();
}
// --- ttl_op ---
/// Remote counterpart of the CLI's local `expire`/`persist`/`ttl`/
/// `expire-sweep` (main.zig, backed by `ttl.zig`), carried as one opcode the
/// same way `user_admin` bundles five user-management actions under one
/// opcode - see server.zig's `requiredRole`, which (unlike `user_admin`'s
/// blanket `admin` gate) inspects this action byte: `query` is read-only
/// and needs only `readonly`; `expire`/`persist`/`sweep` all mutate (sweep
/// deletes every due key) and need `readwrite`, matching every other write
/// opcode.
pub const TtlAction = enum(u8) {
expire = 0,
persist = 1,
query = 2,
sweep = 3,
pub fn fromByte(b: u8) !TtlAction {
return std.meta.intToEnum(TtlAction, b) catch error.Malformed;
}
};
/// `seconds` is only meaningful for `expire` - callers send 0 for the other
/// three actions, same "unused fields carry a zero" convention as
/// `encodeUserAdmin`'s `role`/`rounds`. `key` is empty for `sweep`, which
/// operates on every due key in the store rather than one specific key.
pub fn encodeTtlOp(allocator: Allocator, action: TtlAction, key: []const u8, seconds: u64) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(@intFromEnum(action));
try w.putU64(seconds);
try w.slice(key);
return buf.toOwnedSlice();
}
pub const TtlOpArgs = struct { action: TtlAction, seconds: u64, key: []const u8 };
pub fn decodeTtlOp(payload: []const u8) !TtlOpArgs {
var c = Cursor{ .data = payload };
const action = try TtlAction.fromByte(try c.takeByte());
const seconds = try c.takeU64();
const key = c.data[c.pos..];
return .{ .action = action, .seconds = seconds, .key = key };
}
/// Encodes the response to a `query` ttl_op: reuses `ttlmod.TtlResult`
/// rather than inventing a parallel type, the same way `encodeStats` reuses
/// `dbmod.Stats` and `encodeUserList` reuses `authmod.UserInfo`.
pub fn encodeTtlResult(allocator: Allocator, result: ttlmod.TtlResult) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
switch (result) {
.noKey => try w.byte(0),
.noExpiry => try w.byte(1),
.seconds => |s| {
try w.byte(2);
try w.putU64(s);
},
}
return buf.toOwnedSlice();
}
pub fn decodeTtlResult(payload: []const u8) !ttlmod.TtlResult {
var c = Cursor{ .data = payload };
const kind = try c.takeByte();
return switch (kind) {
0 => .noKey,
1 => .noExpiry,
2 => .{ .seconds = try c.takeU64() },
else => error.Malformed,
};
}
/// Encodes the response to a `sweep` ttl_op: how many due keys were reaped.
pub fn encodeTtlSweepCount(allocator: Allocator, count: u64) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU64(count);
return buf.toOwnedSlice();
}
pub fn decodeTtlSweepCount(payload: []const u8) !u64 {
var c = Cursor{ .data = payload };
return c.takeU64();
}
// --- coll_ttl_op ---
/// Remote counterpart of the CLI's local `coll <kind> expire|ttl|persist
/// <name>`/`coll expire-sweep` (main.zig, backed by `collttl.zig`), same
/// bundling shape as `ttl_op` above but for a whole typed collection
/// (Counter/Hash/Set/List/ZSet) instead of one flat key - so the payload
/// carries an extra `Kind` tag byte the flat version does not need.
/// Reuses `TtlAction` directly rather than a parallel enum: the four
/// actions (expire/persist/query/sweep) mean exactly the same thing here,
/// just against `collttl.zig` instead of `ttl.zig`.
pub fn encodeCollTtlOp(allocator: Allocator, action: TtlAction, kind: collmod.Kind, name: []const u8, seconds: u64) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(@intFromEnum(action));
// `sweep` operates across every kind at once (collttl.sweepExpired
// scans the whole marker namespace, not one kind's slice of it), so
// the kind byte is meaningless for that action - callers pass any
// valid `Kind` and `decodeCollTtlOp` still decodes it, but
// `handleCollTtlOp` never reads it for `.sweep`. Same "unused field
// still round-trips" convention as `encodeTtlOp`'s zeroed `seconds`
// on non-`expire` actions.
try w.byte(kind.tagByte());
try w.putU64(seconds);
try w.slice(name);
return buf.toOwnedSlice();
}
pub const CollTtlOpArgs = struct { action: TtlAction, kind: collmod.Kind, seconds: u64, name: []const u8 };
pub fn decodeCollTtlOp(payload: []const u8) !CollTtlOpArgs {
var c = Cursor{ .data = payload };
const action = try TtlAction.fromByte(try c.takeByte());
const kind = collmod.Kind.fromTagByte(try c.takeByte()) orelse return error.Malformed;
const seconds = try c.takeU64();
const name = c.data[c.pos..];
return .{ .action = action, .kind = kind, .seconds = seconds, .name = name };
}
// --- coll_op ---
/// Remote counterpart of the CLI's local `coll <kind> <op> ...` (main.zig's
/// `runColl`, backed by `collections.zig`) - until now the Redis-like
/// collections (Counter/Hash/Set/List/ZSet) were reachable only from a
/// process that had the `Db` open directly, the same gap `ttl_op`/
/// `coll_ttl_op` already closed for TTL management. One opcode bundles
/// every collection operation (the same "one opcode, many actions" shape
/// as `user_admin` and `ttl_op`) rather than one opcode per op, since the
/// five kinds share almost nothing about their argument count or type.
/// The payload carries only the action tag, the collection name, and a
/// variable list of string arguments in the exact CLI text form `runColl`
/// already parses locally (values/members/indices/scores) - so
/// `handleCollOp` (server.zig) can reuse the very same
/// `std.fmt.parseInt`/`parseFloat` calls instead of inventing a second
/// binary number encoding just for this opcode.
pub const CollAction = enum(u8) {
counter_get = 0,
counter_set = 1,
counter_incrby = 2,
hash_set = 3,
hash_get = 4,
hash_del = 5,
hash_len = 6,
hash_getall = 7,
set_add = 8,
set_rem = 9,
set_ismember = 10,
set_card = 11,
set_members = 12,
// `set_inter`/`set_union`/`set_diff`: `name` is the first set, `args`
// are every remaining set name - the same split `runColl` already uses
// (`args[0]` vs `args[1..]`) for these three multi-set operations.
set_inter = 13,
set_union = 14,
set_diff = 15,
list_lpush = 16,
list_rpush = 17,
list_lpop = 18,
list_rpop = 19,
list_len = 20,
list_range = 21,
zset_add = 22,
zset_score = 23,
zset_incrby = 24,
zset_rem = 25,
zset_card = 26,
zset_rangebyrank = 27,
zset_rangebyscore = 28,
zset_rank = 29,
pub fn fromByte(b: u8) !CollAction {
return std.meta.intToEnum(CollAction, b) catch error.Malformed;
}
/// Which `collections.Kind` namespace this action's lazy TTL check
/// (`collttl.expireIfDue`, called on `name` before every dispatch in
/// `handleCollOp`) and `requiredRole` gating both need - grouped by the
/// action's numeric range rather than a second explicit table, since
/// the enum above is itself laid out kind-by-kind.
pub fn kind(self: CollAction) collmod.Kind {
return switch (self) {
.counter_get, .counter_set, .counter_incrby => .counter,
.hash_set, .hash_get, .hash_del, .hash_len, .hash_getall => .hash,
.set_add, .set_rem, .set_ismember, .set_card, .set_members, .set_inter, .set_union, .set_diff => .set,
.list_lpush, .list_rpush, .list_lpop, .list_rpop, .list_len, .list_range => .list,
.zset_add, .zset_score, .zset_incrby, .zset_rem, .zset_card, .zset_rangebyrank, .zset_rangebyscore, .zset_rank => .zset,
};
}
/// True for actions that only read - see `server.zig`'s `requiredRole`,
/// which needs this to decide `readonly` vs `readwrite` from the action
/// byte alone, the same action-byte peek `ttl_op`/`coll_ttl_op` already
/// use for their own bundled actions.
pub fn isReadOnly(self: CollAction) bool {
return switch (self) {
.counter_get, .hash_get, .hash_len, .hash_getall, .set_ismember, .set_card, .set_members, .set_inter, .set_union, .set_diff, .list_len, .list_range, .zset_score, .zset_card, .zset_rangebyrank, .zset_rangebyscore, .zset_rank => true,
else => false,
};
}
};
pub fn encodeCollOp(allocator: Allocator, action: CollAction, name: []const u8, args: []const []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(@intFromEnum(action));
try putLenPrefixed(w, name);
try w.putU32(@intCast(args.len));
for (args) |a| try putLenPrefixed(w, a);
return buf.toOwnedSlice();
}
pub const CollOpArgs = struct {
action: CollAction,
name: []const u8,
args: [][]const u8,
};
/// `name` and every entry of `args` are borrowed slices into `payload`,
/// valid only as long as the frame that carried them is alive - the same
/// convention `decodeBatch`/`decodeTableSelect` use. Only the `args`
/// pointer array itself is owned by the caller (free with
/// `allocator.free`); never its contents.
pub fn decodeCollOp(allocator: Allocator, payload: []const u8) !CollOpArgs {
var c = Cursor{ .data = payload };
const action = try CollAction.fromByte(try c.takeByte());
const name = try c.takeLenPrefixed();
const count = try c.takeU32();
var list = try std.ArrayList([]const u8).initCapacity(allocator, count);
errdefer list.deinit();
var i: u32 = 0;
while (i < count) : (i += 1) {
list.appendAssumeCapacity(try c.takeLenPrefixed());
}
return .{ .action = action, .name = name, .args = try list.toOwnedSlice() };
}
/// One reply shape covers every `coll_op` action, tagged by the first byte -
/// the response-side counterpart of the request-side `CollAction`. `.nil`
/// is the wire form of the CLI's `(nil)` (a hash field/list pop/zset score
/// that does not exist); `.ok` is a bare acknowledgement (counter set,
/// hash set/del as a flag would double up with `.int`, so those still use
/// `.int` with 0/1 - see `encodeCollReply`).
pub const CollReplyTag = enum(u8) {
nil = 0,
ok = 1,
int = 2,
real = 3,
bytes = 4,
stringList = 5,
pairList = 6,
scorePairList = 7,
};
pub const CollPair = struct { field: []const u8, value: []const u8 };
pub const CollScorePair = struct { member: []const u8, score: f64 };
pub const CollReply = union(enum) {
nil,
ok,
int: i64,
real: f64,
bytes: []const u8,
stringList: []const []const u8,
pairList: []const CollPair,
scorePairList: []const CollScorePair,
};
pub fn encodeCollReply(allocator: Allocator, reply: CollReply) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
switch (reply) {
.nil => try w.byte(@intFromEnum(CollReplyTag.nil)),
.ok => try w.byte(@intFromEnum(CollReplyTag.ok)),
.int => |v| {
try w.byte(@intFromEnum(CollReplyTag.int));
try w.putU64(@bitCast(v));
},
.real => |v| {
try w.byte(@intFromEnum(CollReplyTag.real));
try w.putU64(@bitCast(v));
},
.bytes => |v| {
try w.byte(@intFromEnum(CollReplyTag.bytes));
try w.slice(v);
},
.stringList => |items| {
try w.byte(@intFromEnum(CollReplyTag.stringList));
try w.putU32(@intCast(items.len));
for (items) |it| try putLenPrefixed(w, it);
},
.pairList => |items| {
try w.byte(@intFromEnum(CollReplyTag.pairList));
try w.putU32(@intCast(items.len));
for (items) |p| {
try putLenPrefixed(w, p.field);
try putLenPrefixed(w, p.value);
}
},
.scorePairList => |items| {
try w.byte(@intFromEnum(CollReplyTag.scorePairList));
try w.putU32(@intCast(items.len));
for (items) |p| {
try putLenPrefixed(w, p.member);
try w.putU64(@bitCast(p.score));
}
},
}
return buf.toOwnedSlice();
}
pub const OwnedCollPair = struct { field: []u8, value: []u8 };
pub const OwnedCollScorePair = struct { member: []u8, score: f64 };
/// Decoded, independently owned counterpart of `CollReply` - every byte
/// slice is a fresh allocation the caller must free via `deinit`, unlike
/// `CollOpArgs` above which stays borrowed (that side is consumed
/// immediately inside `dispatch` before the frame is freed; this side
/// crosses back out to a `Client` caller that keeps it around).
pub const OwnedCollReply = union(enum) {
nil,
ok,
int: i64,
real: f64,
bytes: []u8,
stringList: [][]u8,
pairList: []OwnedCollPair,
scorePairList: []OwnedCollScorePair,
pub fn deinit(self: *OwnedCollReply, allocator: Allocator) void {
switch (self.*) {
.bytes => |b| allocator.free(b),
.stringList => |items| {
for (items) |it| allocator.free(it);
allocator.free(items);
},
.pairList => |items| {
for (items) |p| {
allocator.free(p.field);
allocator.free(p.value);
}
allocator.free(items);
},
.scorePairList => |items| {
for (items) |p| allocator.free(p.member);
allocator.free(items);
},
.nil, .ok, .int, .real => {},
}
self.* = undefined;
}
};
pub fn decodeCollReply(allocator: Allocator, payload: []const u8) !OwnedCollReply {
var c = Cursor{ .data = payload };
const tag = std.meta.intToEnum(CollReplyTag, try c.takeByte()) catch return error.Malformed;
return switch (tag) {
.nil => .nil,
.ok => .ok,
.int => .{ .int = @bitCast(try c.takeU64()) },
.real => .{ .real = @bitCast(try c.takeU64()) },
.bytes => .{ .bytes = try allocator.dupe(u8, c.data[c.pos..]) },
.stringList => blk: {
const count = try c.takeU32();
var list = try std.ArrayList([]u8).initCapacity(allocator, count);
errdefer {
for (list.items) |it| allocator.free(it);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const raw = try c.takeLenPrefixed();
list.appendAssumeCapacity(try allocator.dupe(u8, raw));
}
break :blk .{ .stringList = try list.toOwnedSlice() };
},
.pairList => blk: {
const count = try c.takeU32();
var list = try std.ArrayList(OwnedCollPair).initCapacity(allocator, count);
errdefer {
for (list.items) |p| {
allocator.free(p.field);
allocator.free(p.value);
}
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const field = try c.takeLenPrefixed();
const value = try c.takeLenPrefixed();
list.appendAssumeCapacity(.{ .field = try allocator.dupe(u8, field), .value = try allocator.dupe(u8, value) });
}
break :blk .{ .pairList = try list.toOwnedSlice() };
},
.scorePairList => blk: {
const count = try c.takeU32();
var list = try std.ArrayList(OwnedCollScorePair).initCapacity(allocator, count);
errdefer {
for (list.items) |p| allocator.free(p.member);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const member = try c.takeLenPrefixed();
const score: f64 = @bitCast(try c.takeU64());
list.appendAssumeCapacity(.{ .member = try allocator.dupe(u8, member), .score = score });
}
break :blk .{ .scorePairList = try list.toOwnedSlice() };
},
};
}
// --- txn_op ---
/// Remote counterpart of the CLI's local `txn <script-file>` (main.zig's
/// `runTxn`, backed by `txn.zig`/`txn_table.zig`): a batch of put/delete row
/// operations, possibly spanning several tables, applied as one atomic
/// `Transaction` commit - the same "every op succeeds together or none of
/// them do" guarantee the local CLI gives, now reachable from a process that
/// only has a `Client`, not a `*Db`. Unlike `coll_op`/`ttl_op` (one action
/// against one key/collection per request) this opcode carries a LIST of
/// entries, since a transaction script is inherently a batch.
pub const TxnVerb = enum(u8) {
put = 0,
del = 1,
pub fn fromByte(b: u8) !TxnVerb {
return std.meta.intToEnum(TxnVerb, b) catch error.Malformed;
}
};
/// One staged operation as the caller supplies it. `fields` is ignored for
/// `.del` (callers pass `&.{}`) - same "unused argument still there, just
/// empty" convention as `encodeUserAdmin`'s zeroed `role`/`rounds` on
/// actions that do not need them.
pub const TxnOpEntry = struct {
verb: TxnVerb,
table: []const u8,
pk: rowmod.Value,
fields: []const rowmod.Field = &.{},
};
pub fn encodeTxnOp(allocator: Allocator, entries: []const TxnOpEntry) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(entries.len));
for (entries) |e| {
try w.byte(@intFromEnum(e.verb));
try putLenPrefixed(w, e.table);
const pkBytes = try rowmod.encodeValue(allocator, e.pk);
defer allocator.free(pkBytes);
try putLenPrefixed(w, pkBytes);
// Reuses `Row.encode` for the field list exactly the way
// `encodeRows` does for a whole result set, instead of inventing a
// second name+value framing just for this opcode.
var fieldsRow = rowmod.Row.init(allocator);
defer fieldsRow.deinit();
for (e.fields) |f| try fieldsRow.set(f.name, f.value);
const fieldsBytes = try fieldsRow.encode(allocator);
defer allocator.free(fieldsBytes);
try putLenPrefixed(w, fieldsBytes);
}
return buf.toOwnedSlice();
}
/// Decoded entry with every slice borrowed from the frame `payload` that
/// carried it - same convention as `decodeCollOp`/`decodeTableSelect`, valid
/// only as long as that payload is alive. `pkBytes`/`fieldsBytes` still need
/// `rowmod.decodeValue`/`rowmod.Row.decode` to become a real `Value`/field
/// list; kept undecoded here so a malformed table name or verb byte fails
/// fast without touching row-decoding at all.
pub const DecodedTxnOpEntry = struct {
verb: TxnVerb,
table: []const u8,
pkBytes: []const u8,
fieldsBytes: []const u8,
};
pub fn decodeTxnOp(allocator: Allocator, payload: []const u8) ![]DecodedTxnOpEntry {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(DecodedTxnOpEntry).initCapacity(allocator, count);
errdefer list.deinit();
var i: u32 = 0;
while (i < count) : (i += 1) {
const verb = try TxnVerb.fromByte(try c.takeByte());
const table = try c.takeLenPrefixed();
const pkBytes = try c.takeLenPrefixed();
const fieldsBytes = try c.takeLenPrefixed();
list.appendAssumeCapacity(.{ .verb = verb, .table = table, .pkBytes = pkBytes, .fieldsBytes = fieldsBytes });
}
return list.toOwnedSlice();
}
/// Encodes the response to a successful `txn_op`: how many operations were
/// committed - always every entry in the request, since a partial commit is
/// impossible (`error.Conflict` aborts the whole transaction instead, and
/// surfaces as `Status.conflict` with no payload, the same shape as
/// `user_admin`'s `UserExists` conflict).
pub fn encodeTxnResult(allocator: Allocator, applied: u32) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(applied);
return buf.toOwnedSlice();
}
pub fn decodeTxnResult(payload: []const u8) !u32 {
var c = Cursor{ .data = payload };
return c.takeU32();
}
// --- scan / prefix ---
pub const ScanArgs = struct {
start: ?[]const u8 = null,
end: ?[]const u8 = null,
limit: u32 = 0,
reverse: bool = false,
};
const scanFlagHasStart: u8 = 1 << 0;
const scanFlagHasEnd: u8 = 1 << 1;
const scanFlagReverse: u8 = 1 << 2;
pub fn encodeScan(allocator: Allocator, args: ScanArgs) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
var flags: u8 = 0;
if (args.start != null) flags |= scanFlagHasStart;
if (args.end != null) flags |= scanFlagHasEnd;
if (args.reverse) flags |= scanFlagReverse;
try w.byte(flags);
if (args.start) |s| try putLenPrefixed(w, s);
if (args.end) |e| try putLenPrefixed(w, e);
try w.putU32(args.limit);
return buf.toOwnedSlice();
}
pub fn decodeScan(payload: []const u8) !ScanArgs {
var c = Cursor{ .data = payload };
const flags = try c.takeByte();
var args = ScanArgs{};
if (flags & scanFlagHasStart != 0) args.start = try c.takeLenPrefixed();
if (flags & scanFlagHasEnd != 0) args.end = try c.takeLenPrefixed();
args.reverse = flags & scanFlagReverse != 0;
args.limit = try c.takeU32();
return args;
}
pub fn encodePrefix(allocator: Allocator, prefix: []const u8, limit: u32) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(limit);
try w.slice(prefix);
return buf.toOwnedSlice();
}
pub const PrefixArgs = struct { prefix: []const u8, limit: u32 };
pub fn decodePrefix(payload: []const u8) !PrefixArgs {
var c = Cursor{ .data = payload };
const limit = try c.takeU32();
const prefix = c.data[c.pos..];
return .{ .prefix = prefix, .limit = limit };
}
// --- entry lists (scan / prefix responses) ---
pub fn encodeEntries(allocator: Allocator, entries: []const dbmod.Entry) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(entries.len));
for (entries) |e| {
try putLenPrefixed(w, e.key);
try putLenPrefixed(w, e.value);
}
return buf.toOwnedSlice();
}
pub const OwnedEntry = struct {
key: []u8,
value: []u8,
pub fn deinit(self: *OwnedEntry, allocator: Allocator) void {
allocator.free(self.key);
allocator.free(self.value);
}
};
/// Copies every key and value out of `payload` so the caller can free
/// the frame that carried them and keep the entries alive independently.
pub fn decodeEntries(allocator: Allocator, payload: []const u8) ![]OwnedEntry {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(OwnedEntry).initCapacity(allocator, count);
errdefer {
for (list.items) |*e| e.deinit(allocator);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const key = try c.takeLenPrefixed();
const value = try c.takeLenPrefixed();
const ownedKey = try allocator.dupe(u8, key);
errdefer allocator.free(ownedKey);
const ownedValue = try allocator.dupe(u8, value);
list.appendAssumeCapacity(.{ .key = ownedKey, .value = ownedValue });
}
return list.toOwnedSlice();
}
pub fn freeOwnedEntries(allocator: Allocator, entries: []OwnedEntry) void {
for (entries) |*e| e.deinit(allocator);
allocator.free(entries);
}
// --- batch ---
pub const BatchOp = union(enum) {
put: struct { key: []const u8, value: []const u8 },
del: []const u8,
};
const batchKindPut: u8 = 0;
const batchKindDel: u8 = 1;
pub fn encodeBatch(allocator: Allocator, ops: []const BatchOp) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(ops.len));
for (ops) |op| {
switch (op) {
.put => |kv| {
try w.byte(batchKindPut);
try putLenPrefixed(w, kv.key);
try putLenPrefixed(w, kv.value);
},
.del => |key| {
try w.byte(batchKindDel);
try putLenPrefixed(w, key);
},
}
}
return buf.toOwnedSlice();
}
pub const DecodedBatchOp = union(enum) {
put: struct { key: []const u8, value: []const u8 },
del: []const u8,
};
pub fn decodeBatch(allocator: Allocator, payload: []const u8) ![]DecodedBatchOp {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(DecodedBatchOp).initCapacity(allocator, count);
errdefer list.deinit();
var i: u32 = 0;
while (i < count) : (i += 1) {
const kind = try c.takeByte();
switch (kind) {
batchKindPut => {
const key = try c.takeLenPrefixed();
const value = try c.takeLenPrefixed();
list.appendAssumeCapacity(.{ .put = .{ .key = key, .value = value } });
},
batchKindDel => {
const key = try c.takeLenPrefixed();
list.appendAssumeCapacity(.{ .del = key });
},
else => return error.Malformed,
}
}
return list.toOwnedSlice();
}
// --- table select ---
/// Wire request for a remote `table select`: a table name, the raw text of
/// an `expr.zig` predicate expression (empty text selects every row, the
/// same convention `query.Table.select(null)` uses locally), an
/// offset/limit page window using the same "0 = unlimited" convention as
/// `ScanArgs.limit` above - so a large remote result can be paged instead
/// of always coming back in one frame bounded only by `maxFrameBytes` - and
/// a `query.OrderBy` sort key list (possibly empty) applied server-side via
/// `Table.selectPageOrdered` before that page window. The order-by count
/// byte (and each field name/direction pair behind it) sit BEFORE
/// `exprText` rather than after it, because `exprText` is decoded as
/// "everything left in the payload" - anything meant to survive decoding
/// has to come first. A single byte bounds the count to 255 columns, far
/// more than any real query needs.
pub fn encodeTableSelect(allocator: Allocator, table: []const u8, exprText: []const u8, offset: u32, limit: u32, orderBy: []const querymod.OrderBy) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
try w.putU32(offset);
try w.putU32(limit);
try w.byte(@intCast(orderBy.len));
for (orderBy) |ob| {
try w.byte(if (ob.descending) 1 else 0);
try putLenPrefixed(w, ob.field);
}
try w.slice(exprText);
return buf.toOwnedSlice();
}
pub const TableSelectArgs = struct {
table: []const u8,
exprText: []const u8,
offset: u32 = 0,
limit: u32 = 0,
orderBy: []const querymod.OrderBy = &.{},
};
/// `table`, `exprText`, and every `orderBy[i].field` are borrowed slices
/// into `payload` - the same convention `decodeCollOp` uses. Only the
/// `orderBy` array itself is owned by the caller (free with
/// `allocator.free`); never its contents.
pub fn decodeTableSelect(allocator: Allocator, payload: []const u8) !TableSelectArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const offset = try c.takeU32();
const limit = try c.takeU32();
const count = try c.takeByte();
var list = try std.ArrayList(querymod.OrderBy).initCapacity(allocator, count);
errdefer list.deinit();
var i: u8 = 0;
while (i < count) : (i += 1) {
const descending = try c.takeByte();
const field = try c.takeLenPrefixed();
list.appendAssumeCapacity(.{ .field = field, .descending = descending != 0 });
}
const exprText = c.data[c.pos..];
return .{ .table = table, .exprText = exprText, .offset = offset, .limit = limit, .orderBy = try list.toOwnedSlice() };
}
/// Encodes a `table select` result set as a length-prefixed list of rows,
/// each row itself encoded with `Row.encode` (the same self-describing
/// field format a Table stores on disk).
pub fn encodeRows(allocator: Allocator, rows: []const rowmod.Row) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(rows.len));
for (rows) |*r| {
const encoded = try r.encode(allocator);
defer allocator.free(encoded);
try putLenPrefixed(w, encoded);
}
return buf.toOwnedSlice();
}
/// Decodes a `table select` result set into independently owned Rows -
/// callers must free each with `Row.deinit` (see `freeRows` below).
pub fn decodeRows(allocator: Allocator, payload: []const u8) ![]rowmod.Row {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(rowmod.Row).initCapacity(allocator, count);
errdefer {
for (list.items) |*r| r.deinit();
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const raw = try c.takeLenPrefixed();
const row = rowmod.Row.decode(allocator, raw) catch return error.Malformed;
list.appendAssumeCapacity(row);
}
return list.toOwnedSlice();
}
pub fn freeRows(allocator: Allocator, rows: []rowmod.Row) void {
for (rows) |*r| r.deinit();
allocator.free(rows);
}
// --- table join ---
/// Which of `join.zig`'s four equi-join variants a `table_join` request
/// wants - the wire counterpart of the CLI's `table join`
/// `[--left|--right|--full]` flags (no flag means `.inner`).
pub const JoinMode = enum(u8) {
inner = 0,
left = 1,
right = 2,
full = 3,
pub fn fromByte(b: u8) !JoinMode {
return std.meta.intToEnum(JoinMode, b) catch error.UnknownJoinMode;
}
};
/// Wire request for a remote `table join`: which variant (`mode`), the two
/// tables and the fields they are joined on, and an optional `expr.zig`
/// predicate text for each side - the same `leftPred`/`rightPred` filter
/// `join.innerJoin` and its outer variants already take, evaluated exactly
/// like `table_select`'s `exprText` (empty selects every row on that side).
pub fn encodeTableJoin(
allocator: Allocator,
mode: JoinMode,
leftTable: []const u8,
leftField: []const u8,
rightTable: []const u8,
rightField: []const u8,
offset: u32,
limit: u32,
leftExprText: []const u8,
rightExprText: []const u8,
) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(@intFromEnum(mode));
try putLenPrefixed(w, leftTable);
try putLenPrefixed(w, leftField);
try putLenPrefixed(w, rightTable);
try putLenPrefixed(w, rightField);
try w.putU32(offset);
try w.putU32(limit);
try putLenPrefixed(w, leftExprText);
try w.slice(rightExprText);
return buf.toOwnedSlice();
}
pub const TableJoinArgs = struct {
mode: JoinMode,
leftTable: []const u8,
leftField: []const u8,
rightTable: []const u8,
rightField: []const u8,
offset: u32 = 0,
limit: u32 = 0,
leftExprText: []const u8,
rightExprText: []const u8,
};
pub fn decodeTableJoin(payload: []const u8) !TableJoinArgs {
var c = Cursor{ .data = payload };
const mode = try JoinMode.fromByte(try c.takeByte());
const leftTable = try c.takeLenPrefixed();
const leftField = try c.takeLenPrefixed();
const rightTable = try c.takeLenPrefixed();
const rightField = try c.takeLenPrefixed();
const offset = try c.takeU32();
const limit = try c.takeU32();
const leftExprText = try c.takeLenPrefixed();
const rightExprText = c.data[c.pos..];
return .{
.mode = mode,
.leftTable = leftTable,
.leftField = leftField,
.rightTable = rightTable,
.rightField = rightField,
.offset = offset,
.limit = limit,
.leftExprText = leftExprText,
.rightExprText = rightExprText,
};
}
/// One row pair from any of `join.zig`'s four variants, flattened to a
/// single wire shape: `innerJoin` never leaves either side null,
/// `leftOuterJoin`/`rightOuterJoin` leave exactly one side nullable, and
/// `fullOuterJoin` allows either - rather than four near-identical wire
/// encodings, every variant's result is converted to this one shape before
/// `encodeJoinedRows`, and the CLI decides what "(nil)" means from which
/// `--left`/`--right`/`--full` flag it sent, exactly like the local `table
/// join` printer already does per `JoinMode`.
pub const JoinPair = struct { left: ?rowmod.Row, right: ?rowmod.Row };
fn putOptionalRow(allocator: Allocator, w: encode.ByteWriter, row: ?rowmod.Row) !void {
if (row) |*r| {
try w.byte(1);
const encoded = try r.encode(allocator);
defer allocator.free(encoded);
try putLenPrefixed(w, encoded);
} else {
try w.byte(0);
}
}
pub fn encodeJoinedRows(allocator: Allocator, pairs: []const JoinPair) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(pairs.len));
for (pairs) |p| {
try putOptionalRow(allocator, w, p.left);
try putOptionalRow(allocator, w, p.right);
}
return buf.toOwnedSlice();
}
fn takeOptionalRow(allocator: Allocator, c: *Cursor) !?rowmod.Row {
const present = try c.takeByte();
if (present == 0) return null;
const raw = try c.takeLenPrefixed();
return rowmod.Row.decode(allocator, raw) catch return error.Malformed;
}
/// Decodes a `table_join` result set into independently owned `JoinPair`s -
/// callers must free each present side with `Row.deinit` (see
/// `freeJoinPairs` below).
pub fn decodeJoinedRows(allocator: Allocator, payload: []const u8) ![]JoinPair {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(JoinPair).initCapacity(allocator, count);
errdefer {
for (list.items) |*p| {
if (p.left) |*l| l.deinit();
if (p.right) |*r| r.deinit();
}
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const left = try takeOptionalRow(allocator, &c);
const right = try takeOptionalRow(allocator, &c);
list.appendAssumeCapacity(.{ .left = left, .right = right });
}
return list.toOwnedSlice();
}
pub fn freeJoinPairs(allocator: Allocator, pairs: []JoinPair) void {
for (pairs) |*p| {
if (p.left) |*l| l.deinit();
if (p.right) |*r| r.deinit();
}
allocator.free(pairs);
}
// --- table aggregate ---
/// Wire request for a remote `table aggregate`: a table name, an optional
/// GROUP BY field (empty means no grouping - the network counterpart of the
/// CLI's `*` sentinel for `table aggregate <table> *`, chosen client-side so
/// the wire itself needs no separate mode byte), the list of
/// `aggregate.zig` specs to compute, and an `expr.zig` predicate text
/// filtering rows BEFORE aggregation runs - the same empty-means-every-row
/// convention `table_select`'s `exprText` already uses.
/// `offset`/`limit` window the result GROUPS (not the input rows) when
/// `groupField` is non-empty, the same `join.applyPage`/`Table.selectPage`
/// convention as `table_join`'s wire format - 0/0 means unlimited. Ignored
/// server-side when `groupField` is empty (`runGlobal` always produces
/// exactly one result row, nothing to page).
/// `orderBy` is a `query.OrderBy` sort key list (possibly empty) applied
/// server-side to the result GROUPS via `aggregate.sortGroupsBy`, after
/// HAVING narrows them and before `offset`/`limit` window them - same
/// convention and wire shape (count byte + descending byte + length-prefixed
/// field name, per column) as `encodeTableSelect`'s `orderBy`. Each
/// `orderBy[i].field` must name either `groupField` or one of `specs`'
/// `specLabel`s (validated server-side by `sortGroupsBy`, not here).
/// Meaningless (and ignored server-side) for the ungrouped `groupField.len
/// == 0` path, same as `offset`/`limit` already are - `runGlobal` always
/// produces exactly one result row, nothing to order.
///
/// `havingText` is a second, independent `expr.zig` predicate text
/// (empty means no HAVING) filtering the result GROUPS AFTER aggregation,
/// evaluated server-side before `offset`/`limit` window them - the wire
/// counterpart of the CLI's `having <expr>` suffix. Encoded length-prefixed
/// (not "rest of payload" like `exprText`) precisely because `exprText`
/// still needs to occupy that final slot.
pub fn encodeTableAggregate(allocator: Allocator, table: []const u8, groupField: []const u8, specs: []const aggregatemod.AggSpec, offset: u32, limit: u32, orderBy: []const querymod.OrderBy, havingText: []const u8, exprText: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
try putLenPrefixed(w, groupField);
try w.putU32(@intCast(specs.len));
for (specs) |spec| {
try w.byte(@intFromEnum(spec.kind));
try putLenPrefixed(w, spec.field);
}
try w.putU32(offset);
try w.putU32(limit);
try w.byte(@intCast(orderBy.len));
for (orderBy) |ob| {
try w.byte(if (ob.descending) 1 else 0);
try putLenPrefixed(w, ob.field);
}
try putLenPrefixed(w, havingText);
try w.slice(exprText);
return buf.toOwnedSlice();
}
pub const TableAggregateArgs = struct {
table: []const u8,
groupField: []const u8,
/// Borrows `field` slices from the decoded payload but owns the slice
/// itself - caller frees with `allocator.free(args.specs)`, same shape
/// as `decodeBatch`'s returned list.
specs: []aggregatemod.AggSpec,
offset: u32 = 0,
limit: u32 = 0,
/// Empty means no ORDER BY - see `encodeTableAggregate`'s doc. Borrows
/// `field` slices from the decoded payload but owns the slice itself,
/// same convention as `specs`/`TableSelectArgs.orderBy`.
orderBy: []const querymod.OrderBy = &.{},
/// Empty means no HAVING clause - see `encodeTableAggregate`'s doc.
havingText: []const u8,
exprText: []const u8,
};
pub fn decodeTableAggregate(allocator: Allocator, payload: []const u8) !TableAggregateArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const groupField = try c.takeLenPrefixed();
const count = try c.takeU32();
var specs = try std.ArrayList(aggregatemod.AggSpec).initCapacity(allocator, count);
errdefer specs.deinit();
var i: u32 = 0;
while (i < count) : (i += 1) {
const kindByte = try c.takeByte();
const kind = std.meta.intToEnum(aggregatemod.AggKind, kindByte) catch return error.Malformed;
const field = try c.takeLenPrefixed();
specs.appendAssumeCapacity(.{ .kind = kind, .field = field });
}
const offset = try c.takeU32();
const limit = try c.takeU32();
const orderCount = try c.takeByte();
var orderBy = try std.ArrayList(querymod.OrderBy).initCapacity(allocator, orderCount);
errdefer orderBy.deinit();
var j: u8 = 0;
while (j < orderCount) : (j += 1) {
const descending = try c.takeByte();
const field = try c.takeLenPrefixed();
orderBy.appendAssumeCapacity(.{ .field = field, .descending = descending != 0 });
}
const havingText = try c.takeLenPrefixed();
const exprText = c.data[c.pos..];
return .{ .table = table, .groupField = groupField, .specs = try specs.toOwnedSlice(), .offset = offset, .limit = limit, .orderBy = try orderBy.toOwnedSlice(), .havingText = havingText, .exprText = exprText };
}
fn putEncodedValue(allocator: Allocator, w: encode.ByteWriter, value: rowmod.Value) !void {
const encoded = try rowmod.encodeValue(allocator, value);
defer allocator.free(encoded);
try putLenPrefixed(w, encoded);
}
fn takeDecodedValue(allocator: Allocator, c: *Cursor) !rowmod.Value {
const raw = try c.takeLenPrefixed();
return rowmod.decodeValue(allocator, raw) catch return error.Malformed;
}
/// Encodes a `runGlobal` (no `GROUP BY`) result: exactly `specs.len` values,
/// same order as the request's specs.
pub fn encodeAggGlobalResult(allocator: Allocator, values: []const rowmod.Value) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(values.len));
for (values) |v| try putEncodedValue(allocator, w, v);
return buf.toOwnedSlice();
}
/// Decodes a `runGlobal` result into independently owned `Value`s - callers
/// must free with `freeAggValues`.
pub fn decodeAggGlobalResult(allocator: Allocator, payload: []const u8) ![]rowmod.Value {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(rowmod.Value).initCapacity(allocator, count);
errdefer {
for (list.items) |v| v.free(allocator);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) list.appendAssumeCapacity(try takeDecodedValue(allocator, &c));
return list.toOwnedSlice();
}
pub fn freeAggValues(allocator: Allocator, values: []rowmod.Value) void {
for (values) |v| v.free(allocator);
allocator.free(values);
}
/// Encodes a grouped `run` result: one entry per `GroupRow` (the group-key
/// value, then exactly `specs.len` result values), in the same first-seen
/// order `GroupBy.finish()` produced them.
pub fn encodeAggGroupRows(allocator: Allocator, groups: []const aggregatemod.GroupRow) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(groups.len));
for (groups) |g| {
try putEncodedValue(allocator, w, g.group);
try w.putU32(@intCast(g.values.len));
for (g.values) |v| try putEncodedValue(allocator, w, v);
}
return buf.toOwnedSlice();
}
/// Decodes a grouped `run` result into independently owned `GroupRow`s -
/// callers must free each with `GroupRow.deinit`, then the slice itself
/// (see `freeAggGroupRows`).
pub fn decodeAggGroupRows(allocator: Allocator, payload: []const u8) ![]aggregatemod.GroupRow {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(aggregatemod.GroupRow).initCapacity(allocator, count);
errdefer {
for (list.items) |*g| g.deinit(allocator);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const group = try takeDecodedValue(allocator, &c);
// Armed for the rest of THIS iteration's block only - Zig disarms an
// errdefer once the block it was declared in exits normally, and
// `appendAssumeCapacity` below is that block's last statement, so a
// later iteration's failure can never reach back and double-free an
// already-appended `group` (the exact class of bug `aggregate.zig`'s
// `GroupBy.observe` hit and fixed - see its comment for the details).
errdefer group.free(allocator);
const valueCount = try c.takeU32();
var values = try allocator.alloc(rowmod.Value, valueCount);
errdefer allocator.free(values);
var filled: u32 = 0;
errdefer for (values[0..filled]) |v| v.free(allocator);
var j: u32 = 0;
while (j < valueCount) : (j += 1) {
values[j] = try takeDecodedValue(allocator, &c);
filled += 1;
}
list.appendAssumeCapacity(.{ .group = group, .values = values });
}
return list.toOwnedSlice();
}
pub fn freeAggGroupRows(allocator: Allocator, groups: []aggregatemod.GroupRow) void {
for (groups) |*g| g.deinit(allocator);
allocator.free(groups);
}
// --- table window ---
/// Wire request for a remote `table window`: a table name, the LIST of
/// `window.zig` `WindowSpec`s to compute (one or more columns sharing the
/// same `PARTITION BY`/`ORDER BY`/`frame` - `window.computeMany`'s shape,
/// mirroring `table_aggregate`'s `specs` list), the `PARTITION BY`/`ORDER
/// BY` field lists (either possibly empty, same "no partition"/"no order"
/// meaning as local `table window`'s `partitionBy`/`orderBy`), `offset`/
/// `limit` windowing the PRINTED rows in the same partition-then-order walk
/// `window.orderedIndices` produces (not raw scan order, not `computeMany`'s
/// per-row results, which stay aligned 1:1 with every matching row
/// regardless of paging - see `handleTableWindow`'s doc), and an `expr.zig`
/// predicate text filtering rows BEFORE annotation (same
/// empty-means-every-row convention every other `table_*` opcode's
/// `exprText` already uses). `orderBy` reuses `querymod.OrderBy`'s wire
/// shape (descending byte + length-prefixed field name) exactly like
/// `encodeTableAggregate`'s. `specs` is a byte count followed by
/// `(kind byte, length-prefixed field)` pairs, same shape `partitionBy`
/// already uses for its own count-prefixed field list. `frame` is a single
/// byte (`@intFromEnum(windowmod.FrameMode)`) written right before
/// `exprText` - after every other fixed-size field but before the
/// unbounded, unprefixed trailing text, same placement
/// `encodeTableAggregate`'s `havingText` uses relative to ITS trailing
/// `exprText`.
/// Writes a `WindowSpec`'s `offset`/`default` fields, shared by `lag`/`lead`
/// (offset = rows back/forward from the current row) and `nth_value`
/// (offset = 1-based position within the frame) - both pairs share the same
/// wire shape, so one writer covers all three `WindowKind`s. `default` uses
/// the tag+payload shape `row.zig`'s `encodeValue` does, but written
/// directly (not through `putEncodedValue`, which allocates a throwaway
/// buffer via `rowmod.encodeValue` only to copy it right back out) since a
/// `WindowSpec.default` is always a small scalar or short borrowed string,
/// never worth a heap round trip.
fn putLagLeadExtra(w: encode.ByteWriter, offset: u64, default: rowmod.Value) !void {
try w.putU64(offset);
try w.byte(@intFromEnum(@as(rowmod.ValueTag, default)));
switch (default) {
.none => {},
.boolean => |v| try w.byte(if (v) 1 else 0),
.int => |v| try w.putU64(@bitCast(v)),
.float => |v| try w.putU64(@bitCast(v)),
.text, .bytes => |s| try putLenPrefixed(w, s),
}
}
/// Reads back what `putLagLeadExtra` wrote. `.text`/`.bytes` are returned as
/// slices BORROWED from `c`'s backing payload (never allocated) - the same
/// convention `WindowSpec.field`/`TableWindowArgs.partitionBy` already use,
/// safe because the payload buffer outlives the whole request handler.
fn takeLagLeadExtra(c: *Cursor) !struct { offset: u64, default: rowmod.Value } {
const offset = try c.takeU64();
const tagByte = try c.takeByte();
const tag = std.meta.intToEnum(rowmod.ValueTag, tagByte) catch return error.Malformed;
const default: rowmod.Value = switch (tag) {
.none => .{ .none = {} },
.boolean => .{ .boolean = (try c.takeByte()) != 0 },
.int => .{ .int = @bitCast(try c.takeU64()) },
.float => .{ .float = @bitCast(try c.takeU64()) },
.text => .{ .text = try c.takeLenPrefixed() },
.bytes => .{ .bytes = try c.takeLenPrefixed() },
};
return .{ .offset = offset, .default = default };
}
pub fn encodeTableWindow(allocator: Allocator, table: []const u8, specs: []const windowmod.WindowSpec, partitionBy: []const []const u8, orderBy: []const querymod.OrderBy, offset: u32, limit: u32, exprText: []const u8, frame: windowmod.FrameMode) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
try w.byte(@intCast(specs.len));
for (specs) |spec| {
try w.byte(@intFromEnum(spec.kind));
try putLenPrefixed(w, spec.field);
if (spec.kind == .lag or spec.kind == .lead or spec.kind == .nth_value) try putLagLeadExtra(w, spec.offset, spec.default);
}
try w.byte(@intCast(partitionBy.len));
for (partitionBy) |field| try putLenPrefixed(w, field);
try w.byte(@intCast(orderBy.len));
for (orderBy) |ob| {
try w.byte(if (ob.descending) 1 else 0);
try putLenPrefixed(w, ob.field);
}
try w.putU32(offset);
try w.putU32(limit);
try w.byte(@intFromEnum(frame));
try w.slice(exprText);
return buf.toOwnedSlice();
}
pub const TableWindowArgs = struct {
table: []const u8,
/// Borrows field-name slices from the decoded payload but owns the
/// slice itself, same convention as `partitionBy`/`orderBy` below.
/// Always non-empty for a well-formed request (`decodeTableWindow`
/// itself does not enforce this - `handleTableWindow` rejects an empty
/// list as `bad_request`, same as `table_aggregate`'s empty-specs
/// check).
specs: []const windowmod.WindowSpec = &.{},
/// Borrows field-name slices from the decoded payload but owns the
/// slice itself, same convention as `TableAggregateArgs.orderBy`. Empty
/// means "one implicit partition" (the whole result set), same as local
/// `table window`'s default.
partitionBy: []const []const u8 = &.{},
orderBy: []const querymod.OrderBy = &.{},
offset: u32 = 0,
limit: u32 = 0,
frame: windowmod.FrameMode = .rows,
exprText: []const u8,
};
pub fn decodeTableWindow(allocator: Allocator, payload: []const u8) !TableWindowArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const specCount = try c.takeByte();
var specs = try std.ArrayList(windowmod.WindowSpec).initCapacity(allocator, specCount);
errdefer specs.deinit();
var s: u8 = 0;
while (s < specCount) : (s += 1) {
const kindByte = try c.takeByte();
const kind = std.meta.intToEnum(windowmod.WindowKind, kindByte) catch return error.Malformed;
const field = try c.takeLenPrefixed();
if (kind == .lag or kind == .lead or kind == .nth_value) {
const extra = try takeLagLeadExtra(&c);
specs.appendAssumeCapacity(.{ .kind = kind, .field = field, .offset = extra.offset, .default = extra.default });
} else {
specs.appendAssumeCapacity(.{ .kind = kind, .field = field });
}
}
const partitionCount = try c.takeByte();
var partitionBy = try std.ArrayList([]const u8).initCapacity(allocator, partitionCount);
errdefer partitionBy.deinit();
var i: u8 = 0;
while (i < partitionCount) : (i += 1) partitionBy.appendAssumeCapacity(try c.takeLenPrefixed());
const orderCount = try c.takeByte();
var orderBy = try std.ArrayList(querymod.OrderBy).initCapacity(allocator, orderCount);
errdefer orderBy.deinit();
var j: u8 = 0;
while (j < orderCount) : (j += 1) {
const descending = try c.takeByte();
const obField = try c.takeLenPrefixed();
orderBy.appendAssumeCapacity(.{ .field = obField, .descending = descending != 0 });
}
const offset = try c.takeU32();
const limit = try c.takeU32();
const frameByte = try c.takeByte();
const frame = std.meta.intToEnum(windowmod.FrameMode, frameByte) catch return error.Malformed;
const exprText = c.data[c.pos..];
return .{
.table = table,
.specs = try specs.toOwnedSlice(),
.partitionBy = try partitionBy.toOwnedSlice(),
.orderBy = try orderBy.toOwnedSlice(),
.offset = offset,
.limit = limit,
.frame = frame,
.exprText = exprText,
};
}
/// One annotated row from a `table_window` result: the original row
/// (unmodified, same fields the request matched) paired with one computed
/// window value PER requested spec, in the same order as the request's
/// `specs` list - the network shape of what local `table window` prints per
/// row (`printRow` fields, then `<label>=<value>` per spec). Already in the
/// server-paginated partition-then-order display order (see
/// `handleTableWindow`), so the client prints these in order with no
/// further sorting.
pub const WindowRow = struct { row: rowmod.Row, values: []rowmod.Value };
pub fn encodeWindowRows(allocator: Allocator, rows: []const WindowRow) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(rows.len));
for (rows) |wr| {
const encoded = try wr.row.encode(allocator);
defer allocator.free(encoded);
try putLenPrefixed(w, encoded);
try w.putU32(@intCast(wr.values.len));
for (wr.values) |v| try putEncodedValue(allocator, w, v);
}
return buf.toOwnedSlice();
}
/// Decodes a `table_window` result into independently owned `WindowRow`s -
/// callers must free each (`.row.deinit()`, each `.values[i].free(allocator)`
/// then `allocator.free(.values)`), then the slice itself (see
/// `freeWindowRows`).
pub fn decodeWindowRows(allocator: Allocator, payload: []const u8) ![]WindowRow {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList(WindowRow).initCapacity(allocator, count);
errdefer {
for (list.items) |*wr| {
wr.row.deinit();
for (wr.values) |v| v.free(allocator);
allocator.free(wr.values);
}
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const raw = try c.takeLenPrefixed();
var row = rowmod.Row.decode(allocator, raw) catch return error.Malformed;
errdefer row.deinit();
const valueCount = try c.takeU32();
var values = try allocator.alloc(rowmod.Value, valueCount);
errdefer allocator.free(values);
var filled: u32 = 0;
errdefer for (values[0..filled]) |v| v.free(allocator);
var j: u32 = 0;
while (j < valueCount) : (j += 1) {
values[j] = try takeDecodedValue(allocator, &c);
filled += 1;
}
list.appendAssumeCapacity(.{ .row = row, .values = values });
}
return list.toOwnedSlice();
}
pub fn freeWindowRows(allocator: Allocator, rows: []WindowRow) void {
for (rows) |*wr| {
wr.row.deinit();
for (wr.values) |v| v.free(allocator);
allocator.free(wr.values);
}
allocator.free(rows);
}
// --- pubsub ---
/// Wire request for `subscribe`: the list of channel names to listen on (at
/// least one - `server.zig`'s `handleSubscribe` rejects an empty list as
/// malformed rather than opening a subscription that can never receive
/// anything). Unlike every other opcode in this file, a `subscribe` request
/// does not get a single reply frame back: after the initial acknowledgment
/// (see `encodeSubscribeAck`), the connection becomes a one-way stream of
/// `encodePubSubMessage` frames until it closes - see `pubsub.zig`'s doc
/// comment on `Hub` and `server.zig`'s `handleSubscribe`.
pub fn encodeSubscribe(allocator: Allocator, channels: []const []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(@intCast(channels.len));
for (channels) |c| try putLenPrefixed(w, c);
return buf.toOwnedSlice();
}
pub const SubscribeArgs = struct {
channels: [][]u8,
pub fn deinit(self: SubscribeArgs, allocator: Allocator) void {
for (self.channels) |c| allocator.free(c);
allocator.free(self.channels);
}
};
/// Decodes a `subscribe` request into independently owned channel-name
/// copies - free with `SubscribeArgs.deinit`. Owned (rather than borrowed
/// slices into `payload`, as most other decoders here return) because
/// `handleSubscribe` hands these straight to `pubsub.Hub.subscribe`, which
/// keeps its own copies anyway - decoding once into memory that outlives
/// the request frame avoids a second, redundant copy inside the hub for
/// every subscribe call.
pub fn decodeSubscribe(allocator: Allocator, payload: []const u8) !SubscribeArgs {
var c = Cursor{ .data = payload };
const count = try c.takeU32();
var list = try std.ArrayList([]u8).initCapacity(allocator, count);
errdefer {
for (list.items) |ch| allocator.free(ch);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const name = try c.takeLenPrefixed();
const owned = try allocator.dupe(u8, name);
list.appendAssumeCapacity(owned);
}
return .{ .channels = try list.toOwnedSlice() };
}
/// A `subscribe` request's one and only reply frame (status `.ok`, this as
/// its payload) - sent once the connection is actually registered in the
/// `pubsub.Hub`, so a client that has received this ack is guaranteed not
/// to miss a message published right after it returns. Carries the
/// (deduplicated) channel count purely as a sanity-checkable echo, the same
/// spirit as `encodeAuthOk` echoing a token back - plus the `pubsub.Hub`-
/// assigned subscriber id, the only way a later `unsubscribe` request (on a
/// SEPARATE connection - this one is receive-only from here on, see
/// `server.zig`'s `handleSubscribe`) can ever name this subscription again.
pub fn encodeSubscribeAck(allocator: Allocator, subscriberId: u64, channelCount: u32) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU64(subscriberId);
try w.putU32(channelCount);
return buf.toOwnedSlice();
}
pub const SubscribeAck = struct { subscriberId: u64, channelCount: u32 };
pub fn decodeSubscribeAck(payload: []const u8) !SubscribeAck {
var c = Cursor{ .data = payload };
const subscriberId = try c.takeU64();
const channelCount = try c.takeU32();
return .{ .subscriberId = subscriberId, .channelCount = channelCount };
}
/// Wire request for `publish`: a channel name and the message bytes (binary
/// safe, "rest of the payload" like `encodeSet`'s value). Any connection
/// may publish regardless of whether it has ever subscribed to anything -
/// publishing and subscribing are independent roles on the same opcode
/// family, exactly like a `Db` writer and a `Db` reader are independent
/// roles over the same store.
pub fn encodePublish(allocator: Allocator, channel: []const u8, message: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, channel);
try w.slice(message);
return buf.toOwnedSlice();
}
pub const PublishArgs = struct { channel: []const u8, message: []const u8 };
pub fn decodePublish(payload: []const u8) !PublishArgs {
var c = Cursor{ .data = payload };
const channel = try c.takeLenPrefixed();
const message = c.data[c.pos..];
return .{ .channel = channel, .message = message };
}
/// A `publish` request's reply payload: how many subscribers `pubsub.Hub`
/// handed the message to (see `Hub.publish`'s doc comment - 0 is a normal,
/// non-error outcome for a channel nobody is listening to).
pub fn encodePublishResult(allocator: Allocator, subscriberCount: u64) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU64(subscriberCount);
return buf.toOwnedSlice();
}
pub fn decodePublishResult(payload: []const u8) !u64 {
var c = Cursor{ .data = payload };
return c.takeU64();
}
/// Wire request for `unsubscribe`: the `pubsub.Hub`-assigned subscriber id
/// (from `encodeSubscribeAck`) plus the list of channel names to drop from
/// that subscription - the same channel-list shape `encodeSubscribe` uses,
/// with the id prefixed. Unlike `subscribe`, this is an ordinary
/// request/response opcode (see `server.zig`'s `handleUnsubscribe`): it is
/// always sent on a DIFFERENT connection than the one that subscribed,
/// since that connection became receive-only the moment its subscribe ack
/// arrived and can never send another request on itself again.
pub fn encodeUnsubscribe(allocator: Allocator, subscriberId: u64, channels: []const []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU64(subscriberId);
try w.putU32(@intCast(channels.len));
for (channels) |c| try putLenPrefixed(w, c);
return buf.toOwnedSlice();
}
pub const UnsubscribeArgs = struct {
subscriberId: u64,
channels: [][]u8,
pub fn deinit(self: UnsubscribeArgs, allocator: Allocator) void {
for (self.channels) |c| allocator.free(c);
allocator.free(self.channels);
}
};
/// Decodes an `unsubscribe` request - same owned-copy rationale as
/// `decodeSubscribe`: `handleUnsubscribe` hands the channel names straight
/// to `pubsub.Hub.unsubscribe`, which only ever borrows them for the
/// duration of that call, so decoding into memory this function's caller
/// frees (via `UnsubscribeArgs.deinit`) avoids a redundant second copy.
pub fn decodeUnsubscribe(allocator: Allocator, payload: []const u8) !UnsubscribeArgs {
var c = Cursor{ .data = payload };
const subscriberId = try c.takeU64();
const count = try c.takeU32();
var list = try std.ArrayList([]u8).initCapacity(allocator, count);
errdefer {
for (list.items) |ch| allocator.free(ch);
list.deinit();
}
var i: u32 = 0;
while (i < count) : (i += 1) {
const name = try c.takeLenPrefixed();
const owned = try allocator.dupe(u8, name);
list.appendAssumeCapacity(owned);
}
return .{ .subscriberId = subscriberId, .channels = try list.toOwnedSlice() };
}
/// An `unsubscribe` request's reply payload: how many of the requested
/// channel names were actually removed from the named subscriber's set (see
/// `pubsub.Hub.unsubscribe`'s doc comment on why naming a channel it was
/// never on is a harmless no-op that simply does not count here, not an
/// error).
pub fn encodeUnsubscribeResult(allocator: Allocator, removedCount: u32) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(removedCount);
return buf.toOwnedSlice();
}
pub fn decodeUnsubscribeResult(payload: []const u8) !u32 {
var c = Cursor{ .data = payload };
return c.takeU32();
}
/// One pushed message on an already-open `subscribe` connection: which
/// channel it arrived on (a client can subscribe to several at once, so the
/// tag travels with every message) plus the payload bytes. Every frame a
/// subscribed connection receives after its initial ack uses this shape,
/// always with status `.ok` - there is no separate framing marker, because
/// once a client has sent `subscribe` it never sends another request on
/// that connection and so never has to disambiguate a push from a reply to
/// something else it asked (see `server.zig`'s `handleSubscribe`).
pub const PubSubMessage = struct {
channel: []u8,
payload: []u8,
pub fn deinit(self: PubSubMessage, allocator: Allocator) void {
allocator.free(self.channel);
allocator.free(self.payload);
}
};
pub fn encodePubSubMessage(allocator: Allocator, channel: []const u8, payload: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, channel);
try w.slice(payload);
return buf.toOwnedSlice();
}
/// Decodes a pushed message into independently owned copies (the frame
/// they arrived in is freed right after decoding, in `Client.nextMessage` -
/// see its doc comment) - free with `PubSubMessage.deinit`.
pub fn decodePubSubMessage(allocator: Allocator, payload: []const u8) !PubSubMessage {
var c = Cursor{ .data = payload };
const channel = try c.takeLenPrefixed();
const message = c.data[c.pos..];
const channelCopy = try allocator.dupe(u8, channel);
errdefer allocator.free(channelCopy);
const payloadCopy = try allocator.dupe(u8, message);
return .{ .channel = channelCopy, .payload = payloadCopy };
}
// --- table explain ---
/// Wire request for a remote `table explain`: a table name and the raw text
/// of an `expr.zig` predicate expression (empty text means "no predicate",
/// the same convention `encodeTableSelect`'s `exprText` uses). No
/// offset/limit - explain reports a plan, not rows, so there is nothing to
/// page.
pub fn encodeTableExplain(allocator: Allocator, table: []const u8, exprText: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
try w.slice(exprText);
return buf.toOwnedSlice();
}
pub const TableExplainArgs = struct { table: []const u8, exprText: []const u8 };
pub fn decodeTableExplain(payload: []const u8) !TableExplainArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const exprText = c.data[c.pos..];
return .{ .table = table, .exprText = exprText };
}
/// Encodes a `query.Plan`. `indexName` uses the empty string as its "null"
/// sentinel (an index or field name can never itself be empty - `query.zig`
/// rejects that at `createIndex`/`createCompositeIndex` time) instead of a
/// separate presence byte, the same economy `exprText`'s "empty means no
/// predicate" convention already relies on elsewhere in this file.
pub fn encodePlan(allocator: Allocator, plan: querymod.Plan) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(@intFromEnum(plan.kind));
try putLenPrefixed(w, plan.indexName orelse "");
try w.putU32(@intCast(plan.matchedFields));
try w.putU32(@intCast(plan.totalFields));
return buf.toOwnedSlice();
}
/// Decodes a `query.Plan` - `indexName`, when present, is an owned copy the
/// caller must free with `Plan.deinit` (see `encodePlan`'s doc comment for
/// why an empty string decodes back to `null` instead of `""`).
pub fn decodePlan(allocator: Allocator, payload: []const u8) !querymod.Plan {
var c = Cursor{ .data = payload };
const kindByte = try c.takeByte();
const kind = std.meta.intToEnum(querymod.PlanKind, kindByte) catch return error.Malformed;
const rawName = try c.takeLenPrefixed();
const indexName = if (rawName.len == 0) null else try allocator.dupe(u8, rawName);
errdefer if (indexName) |n| allocator.free(n);
const matchedFields = try c.takeU32();
const totalFields = try c.takeU32();
return .{ .kind = kind, .indexName = indexName, .matchedFields = matchedFields, .totalFields = totalFields };
}
// --- table update ---
/// Wire request for a remote `table update`: a table name, the `SET`
/// clause's field list, and the raw text of an `expr.zig` predicate
/// expression (empty text updates every row, the same convention
/// `encodeTableSelect`'s `exprText` uses). The field list reuses
/// `Row.encode` exactly like `encodeTxnOp`'s per-entry `fields` does,
/// instead of inventing a second name+value framing just for this opcode -
/// and, like `encodeTableSelect`'s `orderBy`, sits BEFORE `exprText`
/// because `exprText` is decoded as "everything left in the payload",
/// so anything meant to survive decoding has to come first.
pub fn encodeTableUpdate(allocator: Allocator, table: []const u8, sets: []const rowmod.Field, exprText: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
var setsRow = rowmod.Row.init(allocator);
defer setsRow.deinit();
for (sets) |f| try setsRow.set(f.name, f.value);
const setsBytes = try setsRow.encode(allocator);
defer allocator.free(setsBytes);
try putLenPrefixed(w, setsBytes);
try w.slice(exprText);
return buf.toOwnedSlice();
}
pub const TableUpdateArgs = struct { table: []const u8, setsBytes: []const u8, exprText: []const u8 };
/// `table` and `exprText` are borrowed slices into `payload`, the same
/// convention `decodeTableExplain` uses. `setsBytes` is also borrowed and
/// still needs `rowmod.Row.decode` to become a real field list - kept
/// undecoded here so a malformed table name fails fast without touching
/// row-decoding at all, the same ownership split `DecodedTxnOpEntry` uses
/// for its own `fieldsBytes`.
pub fn decodeTableUpdate(payload: []const u8) !TableUpdateArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const setsBytes = try c.takeLenPrefixed();
const exprText = c.data[c.pos..];
return .{ .table = table, .setsBytes = setsBytes, .exprText = exprText };
}
// --- table delete ---
/// Wire request for a remote `table delete`: a table name and the raw text
/// of an `expr.zig` predicate expression (empty text deletes every row, the
/// same convention `encodeTableSelect`/`encodeTableExplain`'s `exprText`
/// uses). Identical wire shape to `encodeTableExplain` - both are just
/// "table + predicate", no `SET` clause like `encodeTableUpdate` needs - but
/// kept as its own named pair rather than reused directly, the same
/// per-opcode-gets-its-own-function precedent every other opcode here
/// follows even when two shapes coincide.
pub fn encodeTableDelete(allocator: Allocator, table: []const u8, exprText: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
try w.slice(exprText);
return buf.toOwnedSlice();
}
pub const TableDeleteArgs = struct { table: []const u8, exprText: []const u8 };
/// `table` and `exprText` are borrowed slices into `payload`, the same
/// convention `decodeTableExplain`/`decodeTableUpdate` use.
pub fn decodeTableDelete(payload: []const u8) !TableDeleteArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const exprText = c.data[c.pos..];
return .{ .table = table, .exprText = exprText };
}
// --- table export / table import ---
/// Which interchange format a `table_export`/`table_import` request uses -
/// the wire counterpart of the CLI's `csv`/`json` string argument (see
/// `main.zig`'s local `table export`/`table import`), carried as one byte
/// instead of a length-prefixed string since there are only ever two.
pub const DumpFormat = enum(u8) {
csv = 0,
json = 1,
};
/// Wire request for a remote `table export`: a table name and which format
/// (`dump.zig`'s `exportCsvWriter`/`exportJsonWriter`) to render it in. No
/// predicate/paging - export always dumps the whole table, matching the
/// local CLI's `table export <table> csv|json <path>`.
pub fn encodeTableExport(allocator: Allocator, table: []const u8, format: DumpFormat) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
try w.byte(@intFromEnum(format));
return buf.toOwnedSlice();
}
pub const TableExportArgs = struct { table: []const u8, format: DumpFormat };
pub fn decodeTableExport(payload: []const u8) !TableExportArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const formatByte = try c.takeByte();
const format = std.meta.intToEnum(DumpFormat, formatByte) catch return error.Malformed;
return .{ .table = table, .format = format };
}
/// Wire response for `table_export`: the row count `dump.zig`'s exporter
/// reported (so the CLI can print the same count the local `table export`
/// prints) followed by the rendered content bytes verbatim - a "count then
/// rest of the payload" shape, the same idea `exprText` fields elsewhere in
/// this file use for their own trailing variable-length piece.
pub fn encodeExportResult(allocator: Allocator, rowCount: u64, content: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU64(rowCount);
try w.slice(content);
return buf.toOwnedSlice();
}
pub const ExportResult = struct { rowCount: u64, content: []const u8 };
/// `content` is a borrowed slice into `payload`, the same convention
/// `decodeTableExplain`'s `exprText` uses - the caller decides whether to
/// copy it (`client.zig`'s `tableExport` dupes it into an allocation the
/// caller owns once the response frame itself is about to be freed).
pub fn decodeExportResult(payload: []const u8) !ExportResult {
var c = Cursor{ .data = payload };
const rowCount = try c.takeU64();
const content = payload[c.pos..];
return .{ .rowCount = rowCount, .content = content };
}
/// Wire request for a remote `table import`: a table name, which format the
/// following `content` bytes are encoded in, and the content itself.
/// `content` comes last (decoded as "everything left in the payload", the
/// same convention every `exprText` field in this file relies on) since it
/// is arbitrary-length and, unlike `table`, needs no length prefix of its
/// own.
pub fn encodeTableImport(allocator: Allocator, table: []const u8, format: DumpFormat, content: []const u8) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, table);
try w.byte(@intFromEnum(format));
try w.slice(content);
return buf.toOwnedSlice();
}
pub const TableImportArgs = struct { table: []const u8, format: DumpFormat, content: []const u8 };
/// `table` and `content` are borrowed slices into `payload`, the same
/// convention every other decode function in this file uses.
pub fn decodeTableImport(payload: []const u8) !TableImportArgs {
var c = Cursor{ .data = payload };
const table = try c.takeLenPrefixed();
const formatByte = try c.takeByte();
const format = std.meta.intToEnum(DumpFormat, formatByte) catch return error.Malformed;
const content = payload[c.pos..];
return .{ .table = table, .format = format, .content = content };
}
// --- stats ---
pub fn encodeStats(allocator: Allocator, stats: dbmod.Stats) ![]u8 {
var buf = std.ArrayList(u8).init(allocator);
errdefer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU64(@intCast(stats.memtableEntries));
try w.putU64(@intCast(stats.memtableBytes));
try w.putU64(@intCast(stats.sstableCount));
try w.putU64(stats.liveEntries);
try w.putU64(stats.physicalBytes);
try w.putU64(stats.cacheHits);
try w.putU64(stats.cacheMisses);
try w.putU64(stats.nextSeq);
for (stats.levels) |level| {
try w.putU64(@intCast(level.tables));
try w.putU64(level.entries);
try w.putU64(level.physicalBytes);
}
return buf.toOwnedSlice();
}
pub fn decodeStats(payload: []const u8) !dbmod.Stats {
var c = Cursor{ .data = payload };
var stats = dbmod.Stats{};
stats.memtableEntries = @intCast(try c.takeU64());
stats.memtableBytes = @intCast(try c.takeU64());
stats.sstableCount = @intCast(try c.takeU64());
stats.liveEntries = try c.takeU64();
stats.physicalBytes = try c.takeU64();
stats.cacheHits = try c.takeU64();
stats.cacheMisses = try c.takeU64();
stats.nextSeq = try c.takeU64();
var i: usize = 0;
while (i < stats.levels.len) : (i += 1) {
stats.levels[i] = .{
.tables = @intCast(try c.takeU64()),
.entries = try c.takeU64(),
.physicalBytes = try c.takeU64(),
};
}
return stats;
}
test "frame round trip through an in-memory buffer" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
try writeFrame(buf.writer(), @intFromEnum(Op.get), "hello");
var stream = std.io.fixedBufferStream(buf.items);
var frame = try readFrame(allocator, stream.reader());
defer frame.deinit();
try std.testing.expectEqual(@intFromEnum(Op.get), frame.tag);
try std.testing.expectEqualStrings("hello", frame.payload);
}
test "frame with empty payload round trips" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
try writeFrame(buf.writer(), @intFromEnum(Op.ping), "");
var stream = std.io.fixedBufferStream(buf.items);
var frame = try readFrame(allocator, stream.reader());
defer frame.deinit();
try std.testing.expectEqual(@as(usize, 0), frame.payload.len);
}
test "readFrame rejects a length prefix beyond the frame guard" {
const allocator = std.testing.allocator;
var header: [5]u8 = undefined;
header[0] = 0;
std.mem.writeInt(u32, header[1..5], maxFrameBytes + 1, encode.endian);
var stream = std.io.fixedBufferStream(&header);
try std.testing.expectError(error.FrameTooLarge, readFrame(allocator, stream.reader()));
}
test "readFrame rejects a truncated body" {
const allocator = std.testing.allocator;
var header: [5]u8 = undefined;
header[0] = 0;
std.mem.writeInt(u32, header[1..5], 10, encode.endian);
var stream = std.io.fixedBufferStream(&header);
try std.testing.expectError(error.EndOfStream, readFrame(allocator, stream.reader()));
}
test "Op.fromByte rejects unknown opcodes" {
try std.testing.expectError(error.UnknownOp, Op.fromByte(200));
try std.testing.expectEqual(Op.batch, try Op.fromByte(9));
try std.testing.expectEqual(Op.table_select, try Op.fromByte(10));
try std.testing.expectEqual(Op.auth, try Op.fromByte(11));
try std.testing.expectEqual(Op.user_admin, try Op.fromByte(12));
try std.testing.expectEqual(Op.auth_token, try Op.fromByte(13));
try std.testing.expectEqual(Op.metrics, try Op.fromByte(14));
try std.testing.expectEqual(Op.ttl_op, try Op.fromByte(15));
try std.testing.expectEqual(Op.coll_ttl_op, try Op.fromByte(16));
try std.testing.expectEqual(Op.coll_op, try Op.fromByte(17));
try std.testing.expectEqual(Op.txn_op, try Op.fromByte(18));
try std.testing.expectEqual(Op.table_join, try Op.fromByte(19));
try std.testing.expectEqual(Op.table_aggregate, try Op.fromByte(20));
try std.testing.expectEqual(Op.table_explain, try Op.fromByte(21));
try std.testing.expectEqual(Op.table_update, try Op.fromByte(22));
try std.testing.expectEqual(Op.table_delete, try Op.fromByte(23));
try std.testing.expectEqual(Op.table_export, try Op.fromByte(24));
try std.testing.expectEqual(Op.table_import, try Op.fromByte(25));
try std.testing.expectEqual(Op.table_window, try Op.fromByte(26));
try std.testing.expectEqual(Op.subscribe, try Op.fromByte(27));
try std.testing.expectEqual(Op.publish, try Op.fromByte(28));
try std.testing.expectEqual(Op.unsubscribe, try Op.fromByte(29));
}
test "Status.fromByte rejects unknown status codes" {
try std.testing.expectError(error.UnknownStatus, Status.fromByte(200));
try std.testing.expectEqual(Status.internal, try Status.fromByte(5));
try std.testing.expectEqual(Status.rate_limited, try Status.fromByte(6));
try std.testing.expectEqual(Status.unauthorized, try Status.fromByte(7));
try std.testing.expectEqual(Status.forbidden, try Status.fromByte(8));
}
test "auth payload encodes and decodes username and password" {
const allocator = std.testing.allocator;
const payload = try encodeAuth(allocator, "alice", "hunter2");
defer allocator.free(payload);
const args = try decodeAuth(payload);
try std.testing.expectEqualStrings("alice", args.username);
try std.testing.expectEqualStrings("hunter2", args.password);
}
test "auth payload handles an empty password" {
const allocator = std.testing.allocator;
const payload = try encodeAuth(allocator, "alice", "");
defer allocator.free(payload);
const args = try decodeAuth(payload);
try std.testing.expectEqualStrings("alice", args.username);
try std.testing.expectEqualStrings("", args.password);
}
test "encodeAuthOk with no token round trips to an empty payload" {
const allocator = std.testing.allocator;
const payload = try encodeAuthOk(allocator, null);
defer allocator.free(payload);
try std.testing.expectEqual(@as(usize, 0), payload.len);
}
test "encodeAuthOk with a token round trips exactly" {
const allocator = std.testing.allocator;
const payload = try encodeAuthOk(allocator, "deadbeef");
defer allocator.free(payload);
try std.testing.expectEqualStrings("deadbeef", payload);
}
test "auth_token payload round trips the opaque token bytes" {
const allocator = std.testing.allocator;
const payload = try encodeAuthToken(allocator, "abc123token");
defer allocator.free(payload);
try std.testing.expectEqualStrings("abc123token", decodeAuthToken(payload));
}
test "set payload encodes and decodes key and value" {
const allocator = std.testing.allocator;
const payload = try encodeSet(allocator, "k", "value-bytes");
defer allocator.free(payload);
const args = try decodeSet(payload);
try std.testing.expectEqualStrings("k", args.key);
try std.testing.expectEqualStrings("value-bytes", args.value);
}
test "set payload handles an empty value" {
const allocator = std.testing.allocator;
const payload = try encodeSet(allocator, "k", "");
defer allocator.free(payload);
const args = try decodeSet(payload);
try std.testing.expectEqualStrings("k", args.key);
try std.testing.expectEqualStrings("", args.value);
}
test "scan args round trip with both bounds" {
const allocator = std.testing.allocator;
const payload = try encodeScan(allocator, .{ .start = "a", .end = "z", .limit = 42, .reverse = true });
defer allocator.free(payload);
const args = try decodeScan(payload);
try std.testing.expectEqualStrings("a", args.start.?);
try std.testing.expectEqualStrings("z", args.end.?);
try std.testing.expectEqual(@as(u32, 42), args.limit);
try std.testing.expect(args.reverse);
}
test "scan args round trip with no bounds" {
const allocator = std.testing.allocator;
const payload = try encodeScan(allocator, .{});
defer allocator.free(payload);
const args = try decodeScan(payload);
try std.testing.expect(args.start == null);
try std.testing.expect(args.end == null);
try std.testing.expect(!args.reverse);
}
test "prefix args round trip" {
const allocator = std.testing.allocator;
const payload = try encodePrefix(allocator, "user:", 7);
defer allocator.free(payload);
const args = try decodePrefix(payload);
try std.testing.expectEqualStrings("user:", args.prefix);
try std.testing.expectEqual(@as(u32, 7), args.limit);
}
test "entries round trip and are independently owned" {
const allocator = std.testing.allocator;
const entries = [_]dbmod.Entry{
.{ .key = "a", .value = "1" },
.{ .key = "b", .value = "2" },
};
const payload = try encodeEntries(allocator, &entries);
defer allocator.free(payload);
const owned = try decodeEntries(allocator, payload);
defer freeOwnedEntries(allocator, owned);
try std.testing.expectEqual(@as(usize, 2), owned.len);
try std.testing.expectEqualStrings("a", owned[0].key);
try std.testing.expectEqualStrings("1", owned[0].value);
try std.testing.expectEqualStrings("b", owned[1].key);
try std.testing.expectEqualStrings("2", owned[1].value);
}
test "empty entry list round trips" {
const allocator = std.testing.allocator;
const payload = try encodeEntries(allocator, &[_]dbmod.Entry{});
defer allocator.free(payload);
const owned = try decodeEntries(allocator, payload);
defer freeOwnedEntries(allocator, owned);
try std.testing.expectEqual(@as(usize, 0), owned.len);
}
test "batch ops round trip put and delete" {
const allocator = std.testing.allocator;
const ops = [_]BatchOp{
.{ .put = .{ .key = "a", .value = "1" } },
.{ .del = "b" },
};
const payload = try encodeBatch(allocator, &ops);
defer allocator.free(payload);
const decoded = try decodeBatch(allocator, payload);
defer allocator.free(decoded);
try std.testing.expectEqual(@as(usize, 2), decoded.len);
try std.testing.expectEqualStrings("a", decoded[0].put.key);
try std.testing.expectEqualStrings("1", decoded[0].put.value);
try std.testing.expectEqualStrings("b", decoded[1].del);
}
test "decodeBatch rejects an unknown op kind" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(1);
try w.byte(9);
try std.testing.expectError(error.Malformed, decodeBatch(allocator, buf.items));
}
test "stats round trip preserves all fields including per-level detail" {
const allocator = std.testing.allocator;
var stats = dbmod.Stats{};
stats.memtableEntries = 3;
stats.memtableBytes = 128;
stats.sstableCount = 2;
stats.liveEntries = 100;
stats.physicalBytes = 4096;
stats.cacheHits = 10;
stats.cacheMisses = 4;
stats.nextSeq = 55;
stats.levels[0] = .{ .tables = 1, .entries = 50, .physicalBytes = 2048 };
stats.levels[3] = .{ .tables = 2, .entries = 50, .physicalBytes = 2048 };
const payload = try encodeStats(allocator, stats);
defer allocator.free(payload);
const decoded = try decodeStats(payload);
try std.testing.expectEqual(stats.memtableEntries, decoded.memtableEntries);
try std.testing.expectEqual(stats.nextSeq, decoded.nextSeq);
try std.testing.expectEqual(stats.levels[0].tables, decoded.levels[0].tables);
try std.testing.expectEqual(stats.levels[3].entries, decoded.levels[3].entries);
try std.testing.expectEqual(@as(f64, decoded.cacheHitRate()), decoded.cacheHitRate());
}
test "table select args round trip with a non-empty expr" {
const allocator = std.testing.allocator;
const payload = try encodeTableSelect(allocator, "users", "city eq s:warsaw", 0, 0, &.{});
defer allocator.free(payload);
const args = try decodeTableSelect(allocator, payload);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("city eq s:warsaw", args.exprText);
try std.testing.expectEqual(@as(u32, 0), args.offset);
try std.testing.expectEqual(@as(u32, 0), args.limit);
try std.testing.expectEqual(@as(usize, 0), args.orderBy.len);
}
test "table select args round trip with an empty expr means select all" {
const allocator = std.testing.allocator;
const payload = try encodeTableSelect(allocator, "users", "", 0, 0, &.{});
defer allocator.free(payload);
const args = try decodeTableSelect(allocator, payload);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("", args.exprText);
}
test "table select args round trip with a page window" {
const allocator = std.testing.allocator;
const payload = try encodeTableSelect(allocator, "users", "age gt i:10", 5, 3, &.{});
defer allocator.free(payload);
const args = try decodeTableSelect(allocator, payload);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("age gt i:10", args.exprText);
try std.testing.expectEqual(@as(u32, 5), args.offset);
try std.testing.expectEqual(@as(u32, 3), args.limit);
}
test "table select args round trip with an ascending order-by" {
const allocator = std.testing.allocator;
const payload = try encodeTableSelect(allocator, "users", "", 0, 0, &.{.{ .field = "age" }});
defer allocator.free(payload);
const args = try decodeTableSelect(allocator, payload);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 1), args.orderBy.len);
try std.testing.expectEqualStrings("age", args.orderBy[0].field);
try std.testing.expectEqual(false, args.orderBy[0].descending);
}
test "table select args round trip with a descending order-by, page, and expr all together" {
const allocator = std.testing.allocator;
const payload = try encodeTableSelect(allocator, "users", "city eq s:warsaw", 2, 5, &.{.{ .field = "score", .descending = true }});
defer allocator.free(payload);
const args = try decodeTableSelect(allocator, payload);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("city eq s:warsaw", args.exprText);
try std.testing.expectEqual(@as(u32, 2), args.offset);
try std.testing.expectEqual(@as(u32, 5), args.limit);
try std.testing.expectEqualStrings("score", args.orderBy[0].field);
try std.testing.expectEqual(true, args.orderBy[0].descending);
}
test "table select args round trip with two order-by columns in given order" {
const allocator = std.testing.allocator;
const payload = try encodeTableSelect(allocator, "users", "", 0, 0, &.{ .{ .field = "city" }, .{ .field = "score", .descending = true } });
defer allocator.free(payload);
const args = try decodeTableSelect(allocator, payload);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 2), args.orderBy.len);
try std.testing.expectEqualStrings("city", args.orderBy[0].field);
try std.testing.expectEqual(false, args.orderBy[0].descending);
try std.testing.expectEqualStrings("score", args.orderBy[1].field);
try std.testing.expectEqual(true, args.orderBy[1].descending);
}
test "decodeTableSelect rejects an order-by count claiming more columns than were actually encoded" {
const allocator = std.testing.allocator;
const payload = try encodeTableSelect(allocator, "users", "", 0, 0, &.{.{ .field = "age" }});
defer allocator.free(payload);
// The count byte sits right after the two u32 page fields: 4 (table
// len prefix) + 5 ("users") + 4 (offset) + 4 (limit).
const countBytePos = 4 + 5 + 4 + 4;
try std.testing.expectEqual(@as(u8, 1), payload[countBytePos]);
var corrupted = try allocator.dupe(u8, payload);
defer allocator.free(corrupted);
corrupted[countBytePos] = 200;
try std.testing.expectError(error.Malformed, decodeTableSelect(allocator, corrupted));
}
test "rows round trip through encode and decode" {
const allocator = std.testing.allocator;
var rows = [_]rowmod.Row{ rowmod.Row.init(allocator), rowmod.Row.init(allocator) };
defer for (&rows) |*r| r.deinit();
try rows[0].set("name", .{ .text = "ada" });
try rows[0].set("age", .{ .int = 36 });
try rows[1].set("name", .{ .text = "bob" });
try rows[1].set("age", .{ .int = 24 });
const payload = try encodeRows(allocator, &rows);
defer allocator.free(payload);
const decoded = try decodeRows(allocator, payload);
defer freeRows(allocator, decoded);
try std.testing.expectEqual(@as(usize, 2), decoded.len);
try std.testing.expectEqualStrings("ada", decoded[0].get("name").?.text);
try std.testing.expectEqual(@as(i64, 36), decoded[0].get("age").?.int);
try std.testing.expectEqualStrings("bob", decoded[1].get("name").?.text);
try std.testing.expectEqual(@as(i64, 24), decoded[1].get("age").?.int);
}
test "empty row list round trips" {
const allocator = std.testing.allocator;
const payload = try encodeRows(allocator, &[_]rowmod.Row{});
defer allocator.free(payload);
const decoded = try decodeRows(allocator, payload);
defer freeRows(allocator, decoded);
try std.testing.expectEqual(@as(usize, 0), decoded.len);
}
test "JoinMode.fromByte rejects an unknown mode" {
try std.testing.expectError(error.UnknownJoinMode, JoinMode.fromByte(9));
}
test "table_join args round trip mode, tables, fields, and both expr texts" {
const allocator = std.testing.allocator;
const payload = try encodeTableJoin(allocator, .left, "customers", "__pk", "orders", "customer_id", 0, 0, "age gt i:10", "total gt i:0");
defer allocator.free(payload);
const args = try decodeTableJoin(payload);
try std.testing.expectEqual(JoinMode.left, args.mode);
try std.testing.expectEqualStrings("customers", args.leftTable);
try std.testing.expectEqualStrings("__pk", args.leftField);
try std.testing.expectEqualStrings("orders", args.rightTable);
try std.testing.expectEqualStrings("customer_id", args.rightField);
try std.testing.expectEqual(@as(u32, 0), args.offset);
try std.testing.expectEqual(@as(u32, 0), args.limit);
try std.testing.expectEqualStrings("age gt i:10", args.leftExprText);
try std.testing.expectEqualStrings("total gt i:0", args.rightExprText);
}
test "table_join args round trip with both expr texts empty" {
const allocator = std.testing.allocator;
const payload = try encodeTableJoin(allocator, .inner, "a", "x", "b", "y", 0, 0, "", "");
defer allocator.free(payload);
const args = try decodeTableJoin(payload);
try std.testing.expectEqual(JoinMode.inner, args.mode);
try std.testing.expectEqualStrings("", args.leftExprText);
try std.testing.expectEqualStrings("", args.rightExprText);
}
test "table_join args round trip a nonzero offset and limit" {
const allocator = std.testing.allocator;
const payload = try encodeTableJoin(allocator, .full, "a", "x", "b", "y", 3, 7, "", "");
defer allocator.free(payload);
const args = try decodeTableJoin(payload);
try std.testing.expectEqual(@as(u32, 3), args.offset);
try std.testing.expectEqual(@as(u32, 7), args.limit);
}
test "join pairs round trip a fully matched pair (inner-join shape)" {
const allocator = std.testing.allocator;
var left = rowmod.Row.init(allocator);
defer left.deinit();
try left.set("name", .{ .text = "ada" });
var right = rowmod.Row.init(allocator);
defer right.deinit();
try right.set("total", .{ .int = 42 });
const pairs = [_]JoinPair{.{ .left = left, .right = right }};
const payload = try encodeJoinedRows(allocator, &pairs);
defer allocator.free(payload);
const decoded = try decodeJoinedRows(allocator, payload);
defer freeJoinPairs(allocator, decoded);
try std.testing.expectEqual(@as(usize, 1), decoded.len);
try std.testing.expectEqualStrings("ada", decoded[0].left.?.get("name").?.text);
try std.testing.expectEqual(@as(i64, 42), decoded[0].right.?.get("total").?.int);
}
test "join pairs round trip a nil left side and a nil right side" {
const allocator = std.testing.allocator;
var right = rowmod.Row.init(allocator);
defer right.deinit();
try right.set("total", .{ .int = 7 });
var left = rowmod.Row.init(allocator);
defer left.deinit();
try left.set("name", .{ .text = "carol" });
const pairs = [_]JoinPair{
.{ .left = null, .right = right },
.{ .left = left, .right = null },
};
const payload = try encodeJoinedRows(allocator, &pairs);
defer allocator.free(payload);
const decoded = try decodeJoinedRows(allocator, payload);
defer freeJoinPairs(allocator, decoded);
try std.testing.expectEqual(@as(usize, 2), decoded.len);
try std.testing.expect(decoded[0].left == null);
try std.testing.expectEqual(@as(i64, 7), decoded[0].right.?.get("total").?.int);
try std.testing.expectEqualStrings("carol", decoded[1].left.?.get("name").?.text);
try std.testing.expect(decoded[1].right == null);
}
test "empty join pair list round trips" {
const allocator = std.testing.allocator;
const payload = try encodeJoinedRows(allocator, &[_]JoinPair{});
defer allocator.free(payload);
const decoded = try decodeJoinedRows(allocator, payload);
defer freeJoinPairs(allocator, decoded);
try std.testing.expectEqual(@as(usize, 0), decoded.len);
}
test "table_aggregate args round trip a groupField, several specs, and an expr" {
const allocator = std.testing.allocator;
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
.{ .field = "total", .kind = .max },
.{ .field = "city", .kind = .count_distinct },
};
const payload = try encodeTableAggregate(allocator, "orders", "city", &specs, 0, 0, &.{}, "", "total gt i:10");
defer allocator.free(payload);
const args = try decodeTableAggregate(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("orders", args.table);
try std.testing.expectEqualStrings("city", args.groupField);
try std.testing.expectEqual(@as(u32, 0), args.offset);
try std.testing.expectEqual(@as(u32, 0), args.limit);
try std.testing.expectEqualStrings("", args.havingText);
try std.testing.expectEqualStrings("total gt i:10", args.exprText);
try std.testing.expectEqual(@as(usize, 4), args.specs.len);
try std.testing.expectEqual(aggregatemod.AggKind.count, args.specs[0].kind);
try std.testing.expectEqualStrings("", args.specs[0].field);
try std.testing.expectEqual(aggregatemod.AggKind.sum, args.specs[1].kind);
try std.testing.expectEqualStrings("total", args.specs[1].field);
try std.testing.expectEqual(aggregatemod.AggKind.max, args.specs[2].kind);
try std.testing.expectEqual(aggregatemod.AggKind.count_distinct, args.specs[3].kind);
try std.testing.expectEqualStrings("city", args.specs[3].field);
}
test "table_aggregate args round trip an empty groupField (global, no grouping) and empty expr" {
const allocator = std.testing.allocator;
const specs = [_]aggregatemod.AggSpec{.{ .field = "", .kind = .count }};
const payload = try encodeTableAggregate(allocator, "orders", "", &specs, 0, 0, &.{}, "", "");
defer allocator.free(payload);
const args = try decodeTableAggregate(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("", args.groupField);
try std.testing.expectEqualStrings("", args.exprText);
try std.testing.expectEqual(@as(usize, 1), args.specs.len);
}
test "table_aggregate args round trip a nonzero offset and limit" {
const allocator = std.testing.allocator;
const specs = [_]aggregatemod.AggSpec{.{ .field = "", .kind = .count }};
const payload = try encodeTableAggregate(allocator, "orders", "city", &specs, 2, 5, &.{}, "", "");
defer allocator.free(payload);
const args = try decodeTableAggregate(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(u32, 2), args.offset);
try std.testing.expectEqual(@as(u32, 5), args.limit);
}
test "table_aggregate args round trip a non-empty havingText alongside a non-empty exprText" {
const allocator = std.testing.allocator;
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
const payload = try encodeTableAggregate(allocator, "orders", "city", &specs, 0, 0, &.{}, "sum_total gt i:1000", "total gt i:10");
defer allocator.free(payload);
const args = try decodeTableAggregate(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("sum_total gt i:1000", args.havingText);
try std.testing.expectEqualStrings("total gt i:10", args.exprText);
}
test "table_aggregate args round trip a multi-column orderBy alongside havingText and exprText" {
const allocator = std.testing.allocator;
const specs = [_]aggregatemod.AggSpec{
.{ .field = "", .kind = .count },
.{ .field = "total", .kind = .sum },
};
const orderBy = [_]querymod.OrderBy{
.{ .field = "sum_total", .descending = true },
.{ .field = "city" },
};
const payload = try encodeTableAggregate(allocator, "orders", "city", &specs, 0, 0, &orderBy, "sum_total gt i:100", "total gt i:10");
defer allocator.free(payload);
const args = try decodeTableAggregate(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 2), args.orderBy.len);
try std.testing.expectEqualStrings("sum_total", args.orderBy[0].field);
try std.testing.expect(args.orderBy[0].descending);
try std.testing.expectEqualStrings("city", args.orderBy[1].field);
try std.testing.expect(!args.orderBy[1].descending);
try std.testing.expectEqualStrings("sum_total gt i:100", args.havingText);
try std.testing.expectEqualStrings("total gt i:10", args.exprText);
}
test "table_aggregate args round trip an empty orderBy the same as before this feature existed" {
const allocator = std.testing.allocator;
const specs = [_]aggregatemod.AggSpec{.{ .field = "", .kind = .count }};
const payload = try encodeTableAggregate(allocator, "orders", "city", &specs, 0, 0, &.{}, "", "");
defer allocator.free(payload);
const args = try decodeTableAggregate(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 0), args.orderBy.len);
}
test "decodeTableAggregate rejects an unknown agg kind byte instead of corrupting a spec" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, "orders");
try putLenPrefixed(w, "city");
try w.putU32(1);
try w.byte(200); // not a valid AggKind tag
try putLenPrefixed(w, "total");
try std.testing.expectError(error.Malformed, decodeTableAggregate(allocator, buf.items));
}
test "agg global result round trips count/sum/max values, including a none from an empty avg" {
const allocator = std.testing.allocator;
const values = [_]rowmod.Value{
.{ .int = 4 },
.{ .float = 10.5 },
.{ .none = {} },
.{ .text = "warsaw" },
};
const payload = try encodeAggGlobalResult(allocator, &values);
defer allocator.free(payload);
const decoded = try decodeAggGlobalResult(allocator, payload);
defer freeAggValues(allocator, decoded);
try std.testing.expectEqual(@as(usize, 4), decoded.len);
try std.testing.expectEqual(@as(i64, 4), decoded[0].int);
try std.testing.expectApproxEqAbs(@as(f64, 10.5), decoded[1].float, 1e-9);
try std.testing.expectEqual(rowmod.ValueTag.none, @as(rowmod.ValueTag, decoded[2]));
try std.testing.expectEqualStrings("warsaw", decoded[3].text);
}
test "empty agg global result round trips" {
const allocator = std.testing.allocator;
const payload = try encodeAggGlobalResult(allocator, &[_]rowmod.Value{});
defer allocator.free(payload);
const decoded = try decodeAggGlobalResult(allocator, payload);
defer freeAggValues(allocator, decoded);
try std.testing.expectEqual(@as(usize, 0), decoded.len);
}
test "agg group rows round trip several groups with several values each" {
const allocator = std.testing.allocator;
const groups = [_]aggregatemod.GroupRow{
.{ .group = .{ .text = "warsaw" }, .values = @constCast(&[_]rowmod.Value{ .{ .int = 2 }, .{ .int = 350 } }) },
.{ .group = .{ .text = "krakow" }, .values = @constCast(&[_]rowmod.Value{ .{ .int = 1 }, .{ .int = 40 } }) },
};
const payload = try encodeAggGroupRows(allocator, &groups);
defer allocator.free(payload);
const decoded = try decodeAggGroupRows(allocator, payload);
defer freeAggGroupRows(allocator, decoded);
try std.testing.expectEqual(@as(usize, 2), decoded.len);
try std.testing.expectEqualStrings("warsaw", decoded[0].group.text);
try std.testing.expectEqual(@as(i64, 2), decoded[0].values[0].int);
try std.testing.expectEqual(@as(i64, 350), decoded[0].values[1].int);
try std.testing.expectEqualStrings("krakow", decoded[1].group.text);
try std.testing.expectEqual(@as(i64, 40), decoded[1].values[1].int);
}
test "empty agg group rows list round trips" {
const allocator = std.testing.allocator;
const payload = try encodeAggGroupRows(allocator, &[_]aggregatemod.GroupRow{});
defer allocator.free(payload);
const decoded = try decodeAggGroupRows(allocator, payload);
defer freeAggGroupRows(allocator, decoded);
try std.testing.expectEqual(@as(usize, 0), decoded.len);
}
test "decodeAggGroupRows rejects a corrupted second group without leaking the first" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
var goodGroup = rowmod.Row.init(allocator);
defer goodGroup.deinit();
// Use rowmod.encodeValue directly for a real, well-formed first group so
// the corruption below is unambiguously in the SECOND group only.
const goodGroupValue = try rowmod.encodeValue(allocator, .{ .text = "warsaw" });
defer allocator.free(goodGroupValue);
const goodMemberValue = try rowmod.encodeValue(allocator, .{ .int = 1 });
defer allocator.free(goodMemberValue);
try w.putU32(2); // claims two groups, only one is well-formed
try putLenPrefixed(w, goodGroupValue);
try w.putU32(1);
try putLenPrefixed(w, goodMemberValue);
// Second group: a length-prefixed blob whose declared value tag is
// invalid - `takeDecodedValue` must surface `error.Malformed` here
// rather than fabricating a bogus `Value`.
try putLenPrefixed(w, &[_]u8{99});
try std.testing.expectError(error.Malformed, decodeAggGroupRows(allocator, buf.items));
}
test "table_explain args round trip a table and an expr" {
const allocator = std.testing.allocator;
const payload = try encodeTableExplain(allocator, "users", "city eq s:warsaw");
defer allocator.free(payload);
const args = try decodeTableExplain(payload);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("city eq s:warsaw", args.exprText);
}
test "table_explain args round trip an empty expr" {
const allocator = std.testing.allocator;
const payload = try encodeTableExplain(allocator, "users", "");
defer allocator.free(payload);
const args = try decodeTableExplain(payload);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("", args.exprText);
}
test "table_update args round trip a table, set fields, and an expr" {
const allocator = std.testing.allocator;
const sets = [_]rowmod.Field{
.{ .name = "active", .value = .{ .int = 1 } },
.{ .name = "city", .value = .{ .text = "krakow" } },
};
const payload = try encodeTableUpdate(allocator, "users", &sets, "city eq s:warsaw");
defer allocator.free(payload);
const args = try decodeTableUpdate(payload);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("city eq s:warsaw", args.exprText);
var decoded = try rowmod.Row.decode(allocator, args.setsBytes);
defer decoded.deinit();
try std.testing.expectEqual(rowmod.Value{ .int = 1 }, decoded.get("active").?);
try std.testing.expectEqualStrings("krakow", decoded.get("city").?.text);
}
test "table_update args round trip an empty expr, updating every row" {
const allocator = std.testing.allocator;
const sets = [_]rowmod.Field{.{ .name = "seen", .value = .{ .int = 1 } }};
const payload = try encodeTableUpdate(allocator, "users", &sets, "");
defer allocator.free(payload);
const args = try decodeTableUpdate(payload);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("", args.exprText);
var decoded = try rowmod.Row.decode(allocator, args.setsBytes);
defer decoded.deinit();
try std.testing.expectEqual(@as(usize, 1), decoded.count());
}
test "table_delete args round trip a table and an expr" {
const allocator = std.testing.allocator;
const payload = try encodeTableDelete(allocator, "users", "city eq s:warsaw");
defer allocator.free(payload);
const args = try decodeTableDelete(payload);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("city eq s:warsaw", args.exprText);
}
test "table_delete args round trip an empty expr, deleting every row" {
const allocator = std.testing.allocator;
const payload = try encodeTableDelete(allocator, "users", "");
defer allocator.free(payload);
const args = try decodeTableDelete(payload);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqualStrings("", args.exprText);
}
test "table_export args round trip a table and each format" {
const allocator = std.testing.allocator;
const csvPayload = try encodeTableExport(allocator, "users", .csv);
defer allocator.free(csvPayload);
const csvArgs = try decodeTableExport(csvPayload);
try std.testing.expectEqualStrings("users", csvArgs.table);
try std.testing.expectEqual(DumpFormat.csv, csvArgs.format);
const jsonPayload = try encodeTableExport(allocator, "users", .json);
defer allocator.free(jsonPayload);
const jsonArgs = try decodeTableExport(jsonPayload);
try std.testing.expectEqualStrings("users", jsonArgs.table);
try std.testing.expectEqual(DumpFormat.json, jsonArgs.format);
}
test "table_export rejects a format byte outside the DumpFormat enum" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, "users");
try w.byte(99);
try std.testing.expectError(error.Malformed, decodeTableExport(buf.items));
}
test "export result round trips a row count and content bytes" {
const allocator = std.testing.allocator;
const payload = try encodeExportResult(allocator, 3, "__pk\ni:1\ni:2\ni:3\n");
defer allocator.free(payload);
const result = try decodeExportResult(payload);
try std.testing.expectEqual(@as(u64, 3), result.rowCount);
try std.testing.expectEqualStrings("__pk\ni:1\ni:2\ni:3\n", result.content);
}
test "export result round trips a zero row count with empty content" {
const allocator = std.testing.allocator;
const payload = try encodeExportResult(allocator, 0, "");
defer allocator.free(payload);
const result = try decodeExportResult(payload);
try std.testing.expectEqual(@as(u64, 0), result.rowCount);
try std.testing.expectEqualStrings("", result.content);
}
test "table_import args round trip a table, format, and content" {
const allocator = std.testing.allocator;
const payload = try encodeTableImport(allocator, "users", .json, "[{\"__pk\": \"i:1\"}]");
defer allocator.free(payload);
const args = try decodeTableImport(payload);
try std.testing.expectEqualStrings("users", args.table);
try std.testing.expectEqual(DumpFormat.json, args.format);
try std.testing.expectEqualStrings("[{\"__pk\": \"i:1\"}]", args.content);
}
test "table_import rejects a format byte outside the DumpFormat enum" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, "users");
try w.byte(7);
try w.slice("ignored");
try std.testing.expectError(error.Malformed, decodeTableImport(buf.items));
}
test "Plan round trips full_scan with a null indexName" {
const allocator = std.testing.allocator;
const payload = try encodePlan(allocator, .{ .kind = .full_scan });
defer allocator.free(payload);
var decoded = try decodePlan(allocator, payload);
defer decoded.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.full_scan, decoded.kind);
try std.testing.expect(decoded.indexName == null);
try std.testing.expectEqual(@as(usize, 0), decoded.matchedFields);
try std.testing.expectEqual(@as(usize, 0), decoded.totalFields);
}
test "Plan round trips index_range with an indexName and field counts" {
const allocator = std.testing.allocator;
const payload = try encodePlan(allocator, .{ .kind = .index_range, .indexName = "score", .matchedFields = 1, .totalFields = 1 });
defer allocator.free(payload);
var decoded = try decodePlan(allocator, payload);
defer decoded.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.index_range, decoded.kind);
try std.testing.expectEqualStrings("score", decoded.indexName.?);
try std.testing.expectEqual(@as(usize, 1), decoded.matchedFields);
try std.testing.expectEqual(@as(usize, 1), decoded.totalFields);
}
test "Plan round trips composite_eq with a strict prefix match" {
const allocator = std.testing.allocator;
const payload = try encodePlan(allocator, .{ .kind = .composite_eq, .indexName = "city_age", .matchedFields = 1, .totalFields = 2 });
defer allocator.free(payload);
var decoded = try decodePlan(allocator, payload);
defer decoded.deinit(allocator);
try std.testing.expectEqual(querymod.PlanKind.composite_eq, decoded.kind);
try std.testing.expectEqualStrings("city_age", decoded.indexName.?);
try std.testing.expectEqual(@as(usize, 1), decoded.matchedFields);
try std.testing.expectEqual(@as(usize, 2), decoded.totalFields);
}
test "decodePlan rejects an unknown PlanKind byte instead of corrupting a plan" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(200); // not a valid PlanKind tag
try putLenPrefixed(w, "");
try w.putU32(0);
try w.putU32(0);
try std.testing.expectError(error.Malformed, decodePlan(allocator, buf.items));
}
test "decodeRows rejects a corrupted row payload without leaking prior rows" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
var goodRow = rowmod.Row.init(allocator);
defer goodRow.deinit();
try goodRow.set("a", .{ .int = 1 });
const encoded = try goodRow.encode(allocator);
defer allocator.free(encoded);
try w.putU32(2);
try putLenPrefixed(w, encoded);
try putLenPrefixed(w, &.{ 9, 9, 9 });
try std.testing.expectError(error.Malformed, decodeRows(allocator, buf.items));
}
test "Cursor.takeLenPrefixed rejects a length that overruns the buffer" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(100);
try w.slice("short");
var c = Cursor{ .data = buf.items };
try std.testing.expectError(error.Malformed, c.takeLenPrefixed());
}
test "UserAdminAction.fromByte rejects an unknown action" {
try std.testing.expectError(error.Malformed, UserAdminAction.fromByte(200));
try std.testing.expectEqual(UserAdminAction.list, try UserAdminAction.fromByte(4));
}
test "user_admin create payload round trips role and rounds" {
const allocator = std.testing.allocator;
const payload = try encodeUserAdmin(allocator, .create, "alice", "hunter2", .admin, 100_000);
defer allocator.free(payload);
const args = try decodeUserAdmin(payload);
try std.testing.expectEqual(UserAdminAction.create, args.action);
try std.testing.expectEqualStrings("alice", args.username);
try std.testing.expectEqualStrings("hunter2", args.password);
try std.testing.expectEqual(@intFromEnum(authmod.Role.admin), args.roleByte);
try std.testing.expectEqual(@as(u32, 100_000), args.rounds);
}
test "user_admin delete payload carries only the username" {
const allocator = std.testing.allocator;
const payload = try encodeUserAdmin(allocator, .delete, "bob", "", .readonly, 0);
defer allocator.free(payload);
const args = try decodeUserAdmin(payload);
try std.testing.expectEqual(UserAdminAction.delete, args.action);
try std.testing.expectEqualStrings("bob", args.username);
try std.testing.expectEqualStrings("", args.password);
}
test "user_admin list payload round trips with an empty username" {
const allocator = std.testing.allocator;
const payload = try encodeUserAdmin(allocator, .list, "", "", .readonly, 0);
defer allocator.free(payload);
const args = try decodeUserAdmin(payload);
try std.testing.expectEqual(UserAdminAction.list, args.action);
try std.testing.expectEqualStrings("", args.username);
}
test "user list round trips every username with its role" {
const allocator = std.testing.allocator;
const users = [_]authmod.UserInfo{
.{ .username = @constCast("alice"), .role = .readonly },
.{ .username = @constCast("root"), .role = .admin },
};
const payload = try encodeUserList(allocator, &users);
defer allocator.free(payload);
const decoded = try decodeUserList(allocator, payload);
defer authmod.freeUserList(allocator, decoded);
try std.testing.expectEqual(@as(usize, 2), decoded.len);
try std.testing.expectEqualStrings("alice", decoded[0].username);
try std.testing.expectEqual(authmod.Role.readonly, decoded[0].role);
try std.testing.expectEqualStrings("root", decoded[1].username);
try std.testing.expectEqual(authmod.Role.admin, decoded[1].role);
}
test "empty user list round trips" {
const allocator = std.testing.allocator;
const payload = try encodeUserList(allocator, &[_]authmod.UserInfo{});
defer allocator.free(payload);
const decoded = try decodeUserList(allocator, payload);
defer authmod.freeUserList(allocator, decoded);
try std.testing.expectEqual(@as(usize, 0), decoded.len);
}
test "decodeUserList rejects an unknown role byte" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(1);
try putLenPrefixed(w, "ghost");
try w.byte(200);
try std.testing.expectError(error.Malformed, decodeUserList(allocator, buf.items));
}
test "TtlAction.fromByte rejects an unknown action" {
try std.testing.expectError(error.Malformed, TtlAction.fromByte(200));
try std.testing.expectEqual(TtlAction.sweep, try TtlAction.fromByte(3));
}
test "ttl_op expire payload round trips key and seconds" {
const allocator = std.testing.allocator;
const payload = try encodeTtlOp(allocator, .expire, "session:1", 30);
defer allocator.free(payload);
const args = try decodeTtlOp(payload);
try std.testing.expectEqual(TtlAction.expire, args.action);
try std.testing.expectEqualStrings("session:1", args.key);
try std.testing.expectEqual(@as(u64, 30), args.seconds);
}
test "ttl_op sweep payload carries an empty key and zero seconds" {
const allocator = std.testing.allocator;
const payload = try encodeTtlOp(allocator, .sweep, "", 0);
defer allocator.free(payload);
const args = try decodeTtlOp(payload);
try std.testing.expectEqual(TtlAction.sweep, args.action);
try std.testing.expectEqualStrings("", args.key);
try std.testing.expectEqual(@as(u64, 0), args.seconds);
}
test "ttl result round trips noKey, noExpiry, and seconds" {
const allocator = std.testing.allocator;
const noKeyPayload = try encodeTtlResult(allocator, .noKey);
defer allocator.free(noKeyPayload);
try std.testing.expectEqual(ttlmod.TtlResult.noKey, try decodeTtlResult(noKeyPayload));
const noExpiryPayload = try encodeTtlResult(allocator, .noExpiry);
defer allocator.free(noExpiryPayload);
try std.testing.expectEqual(ttlmod.TtlResult.noExpiry, try decodeTtlResult(noExpiryPayload));
const secondsPayload = try encodeTtlResult(allocator, .{ .seconds = 42 });
defer allocator.free(secondsPayload);
const decoded = try decodeTtlResult(secondsPayload);
try std.testing.expectEqual(@as(u64, 42), decoded.seconds);
}
test "decodeTtlResult rejects an unknown kind byte" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(200);
try std.testing.expectError(error.Malformed, decodeTtlResult(buf.items));
}
test "ttl sweep count round trips" {
const allocator = std.testing.allocator;
const payload = try encodeTtlSweepCount(allocator, 7);
defer allocator.free(payload);
try std.testing.expectEqual(@as(u64, 7), try decodeTtlSweepCount(payload));
}
test "coll_ttl_op expire payload round trips action, kind, name and seconds" {
const allocator = std.testing.allocator;
const payload = try encodeCollTtlOp(allocator, .expire, .hash, "profile", 30);
defer allocator.free(payload);
const args = try decodeCollTtlOp(payload);
try std.testing.expectEqual(TtlAction.expire, args.action);
try std.testing.expectEqual(collmod.Kind.hash, args.kind);
try std.testing.expectEqualStrings("profile", args.name);
try std.testing.expectEqual(@as(u64, 30), args.seconds);
}
test "coll_ttl_op round trips every kind's tag byte" {
const allocator = std.testing.allocator;
const kinds = [_]collmod.Kind{ .counter, .hash, .set, .list, .zset };
for (kinds) |k| {
const payload = try encodeCollTtlOp(allocator, .query, k, "n", 0);
defer allocator.free(payload);
const args = try decodeCollTtlOp(payload);
try std.testing.expectEqual(k, args.kind);
}
}
test "coll_ttl_op sweep payload carries an empty name and zero seconds" {
const allocator = std.testing.allocator;
const payload = try encodeCollTtlOp(allocator, .sweep, .counter, "", 0);
defer allocator.free(payload);
const args = try decodeCollTtlOp(payload);
try std.testing.expectEqual(TtlAction.sweep, args.action);
try std.testing.expectEqualStrings("", args.name);
try std.testing.expectEqual(@as(u64, 0), args.seconds);
}
test "decodeCollTtlOp rejects an unknown kind byte" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(@intFromEnum(TtlAction.query));
try w.byte('?');
try w.putU64(0);
try std.testing.expectError(error.Malformed, decodeCollTtlOp(buf.items));
}
test "decodeCollTtlOp rejects an unknown action byte before the kind byte is even read" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(200);
try w.byte('H');
try w.putU64(0);
try std.testing.expectError(error.Malformed, decodeCollTtlOp(buf.items));
}
test "CollAction.fromByte rejects an unknown action" {
try std.testing.expectError(error.Malformed, CollAction.fromByte(200));
try std.testing.expectEqual(CollAction.zset_rank, try CollAction.fromByte(29));
}
test "CollAction.kind groups every action under its collection kind" {
try std.testing.expectEqual(collmod.Kind.counter, CollAction.counter_incrby.kind());
try std.testing.expectEqual(collmod.Kind.hash, CollAction.hash_getall.kind());
try std.testing.expectEqual(collmod.Kind.set, CollAction.set_diff.kind());
try std.testing.expectEqual(collmod.Kind.list, CollAction.list_range.kind());
try std.testing.expectEqual(collmod.Kind.zset, CollAction.zset_rangebyscore.kind());
}
test "CollAction.isReadOnly separates reads from writes" {
try std.testing.expect(CollAction.counter_get.isReadOnly());
try std.testing.expect(!CollAction.counter_set.isReadOnly());
try std.testing.expect(CollAction.zset_rank.isReadOnly());
try std.testing.expect(!CollAction.zset_add.isReadOnly());
try std.testing.expect(CollAction.set_inter.isReadOnly());
try std.testing.expect(!CollAction.list_lpush.isReadOnly());
}
test "coll_op payload round trips action, name, and a variable arg list" {
const allocator = std.testing.allocator;
const args = [_][]const u8{ "alice", "10" };
const payload = try encodeCollOp(allocator, .zset_add, "leaderboard", &args);
defer allocator.free(payload);
const decoded = try decodeCollOp(allocator, payload);
defer allocator.free(decoded.args);
try std.testing.expectEqual(CollAction.zset_add, decoded.action);
try std.testing.expectEqualStrings("leaderboard", decoded.name);
try std.testing.expectEqual(@as(usize, 2), decoded.args.len);
try std.testing.expectEqualStrings("alice", decoded.args[0]);
try std.testing.expectEqualStrings("10", decoded.args[1]);
}
test "coll_op payload round trips a zero-arg action" {
const allocator = std.testing.allocator;
const payload = try encodeCollOp(allocator, .hash_getall, "profile", &.{});
defer allocator.free(payload);
const decoded = try decodeCollOp(allocator, payload);
defer allocator.free(decoded.args);
try std.testing.expectEqual(CollAction.hash_getall, decoded.action);
try std.testing.expectEqualStrings("profile", decoded.name);
try std.testing.expectEqual(@as(usize, 0), decoded.args.len);
}
test "decodeCollOp rejects an unknown action byte" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(250);
try putLenPrefixed(w, "x");
try w.putU32(0);
try std.testing.expectError(error.Malformed, decodeCollOp(allocator, buf.items));
}
test "coll reply round trips nil and ok" {
const allocator = std.testing.allocator;
const nilPayload = try encodeCollReply(allocator, .nil);
defer allocator.free(nilPayload);
var nilReply = try decodeCollReply(allocator, nilPayload);
defer nilReply.deinit(allocator);
try std.testing.expect(nilReply == .nil);
const okPayload = try encodeCollReply(allocator, .ok);
defer allocator.free(okPayload);
var okReply = try decodeCollReply(allocator, okPayload);
defer okReply.deinit(allocator);
try std.testing.expect(okReply == .ok);
}
test "coll reply round trips int and real" {
const allocator = std.testing.allocator;
const intPayload = try encodeCollReply(allocator, .{ .int = -42 });
defer allocator.free(intPayload);
var intReply = try decodeCollReply(allocator, intPayload);
defer intReply.deinit(allocator);
try std.testing.expectEqual(@as(i64, -42), intReply.int);
const realPayload = try encodeCollReply(allocator, .{ .real = 3.5 });
defer allocator.free(realPayload);
var realReply = try decodeCollReply(allocator, realPayload);
defer realReply.deinit(allocator);
try std.testing.expectEqual(@as(f64, 3.5), realReply.real);
}
test "coll reply round trips raw bytes independently of the source payload" {
const allocator = std.testing.allocator;
const payload = try encodeCollReply(allocator, .{ .bytes = "hello" });
defer allocator.free(payload);
var reply = try decodeCollReply(allocator, payload);
defer reply.deinit(allocator);
try std.testing.expectEqualStrings("hello", reply.bytes);
}
test "coll reply round trips a string list, including empty" {
const allocator = std.testing.allocator;
const items = [_][]const u8{ "a", "bb", "ccc" };
const payload = try encodeCollReply(allocator, .{ .stringList = &items });
defer allocator.free(payload);
var reply = try decodeCollReply(allocator, payload);
defer reply.deinit(allocator);
try std.testing.expectEqual(@as(usize, 3), reply.stringList.len);
try std.testing.expectEqualStrings("bb", reply.stringList[1]);
const emptyPayload = try encodeCollReply(allocator, .{ .stringList = &.{} });
defer allocator.free(emptyPayload);
var emptyReply = try decodeCollReply(allocator, emptyPayload);
defer emptyReply.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), emptyReply.stringList.len);
}
test "coll reply round trips a pair list (hash getall shape)" {
const allocator = std.testing.allocator;
const pairs = [_]CollPair{
.{ .field = "name", .value = "ada" },
.{ .field = "age", .value = "36" },
};
const payload = try encodeCollReply(allocator, .{ .pairList = &pairs });
defer allocator.free(payload);
var reply = try decodeCollReply(allocator, payload);
defer reply.deinit(allocator);
try std.testing.expectEqual(@as(usize, 2), reply.pairList.len);
try std.testing.expectEqualStrings("name", reply.pairList[0].field);
try std.testing.expectEqualStrings("ada", reply.pairList[0].value);
try std.testing.expectEqualStrings("age", reply.pairList[1].field);
try std.testing.expectEqualStrings("36", reply.pairList[1].value);
}
test "coll reply round trips a score pair list (zset range shape), including negative scores" {
const allocator = std.testing.allocator;
const pairs = [_]CollScorePair{
.{ .member = "bob", .score = -1.5 },
.{ .member = "ada", .score = 10.25 },
};
const payload = try encodeCollReply(allocator, .{ .scorePairList = &pairs });
defer allocator.free(payload);
var reply = try decodeCollReply(allocator, payload);
defer reply.deinit(allocator);
try std.testing.expectEqual(@as(usize, 2), reply.scorePairList.len);
try std.testing.expectEqualStrings("bob", reply.scorePairList[0].member);
try std.testing.expectEqual(@as(f64, -1.5), reply.scorePairList[0].score);
try std.testing.expectEqualStrings("ada", reply.scorePairList[1].member);
try std.testing.expectEqual(@as(f64, 10.25), reply.scorePairList[1].score);
}
test "decodeCollReply rejects an unknown tag byte" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.byte(250);
try std.testing.expectError(error.Malformed, decodeCollReply(allocator, buf.items));
}
test "txn_op entries round trip put and del across two tables" {
const allocator = std.testing.allocator;
const putFields = [_]rowmod.Field{
.{ .name = "balance", .value = .{ .int = 100 } },
.{ .name = "city", .value = .{ .text = "warsaw" } },
};
const entries = [_]TxnOpEntry{
.{ .verb = .put, .table = "accounts", .pk = .{ .int = 1 }, .fields = &putFields },
.{ .verb = .del, .table = "sessions", .pk = .{ .text = "abc" } },
};
const payload = try encodeTxnOp(allocator, &entries);
defer allocator.free(payload);
const decoded = try decodeTxnOp(allocator, payload);
defer allocator.free(decoded);
try std.testing.expectEqual(@as(usize, 2), decoded.len);
try std.testing.expectEqual(TxnVerb.put, decoded[0].verb);
try std.testing.expectEqualStrings("accounts", decoded[0].table);
const pk0 = try rowmod.decodeValue(allocator, decoded[0].pkBytes);
try std.testing.expectEqual(@as(i64, 1), pk0.int);
var fields0 = try rowmod.Row.decode(allocator, decoded[0].fieldsBytes);
defer fields0.deinit();
try std.testing.expectEqual(@as(i64, 100), fields0.get("balance").?.int);
try std.testing.expectEqualStrings("warsaw", fields0.get("city").?.text);
try std.testing.expectEqual(TxnVerb.del, decoded[1].verb);
try std.testing.expectEqualStrings("sessions", decoded[1].table);
var pk1 = try rowmod.decodeValue(allocator, decoded[1].pkBytes);
defer pk1.free(allocator);
try std.testing.expectEqualStrings("abc", pk1.text);
var fields1 = try rowmod.Row.decode(allocator, decoded[1].fieldsBytes);
defer fields1.deinit();
try std.testing.expectEqual(@as(usize, 0), fields1.count());
}
test "empty txn_op entry list round trips" {
const allocator = std.testing.allocator;
const payload = try encodeTxnOp(allocator, &[_]TxnOpEntry{});
defer allocator.free(payload);
const decoded = try decodeTxnOp(allocator, payload);
defer allocator.free(decoded);
try std.testing.expectEqual(@as(usize, 0), decoded.len);
}
test "decodeTxnOp rejects an unknown verb byte" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(1);
try w.byte(9);
try std.testing.expectError(error.Malformed, decodeTxnOp(allocator, buf.items));
}
test "txn_op result round trips the applied count" {
const allocator = std.testing.allocator;
const payload = try encodeTxnResult(allocator, 7);
defer allocator.free(payload);
try std.testing.expectEqual(@as(u32, 7), try decodeTxnResult(payload));
}
test "table_window args round trip a ranking spec with a partition, an order-by, and an expr" {
const allocator = std.testing.allocator;
const orderBy = [_]querymod.OrderBy{.{ .field = "day", .descending = false }};
const partitionBy = [_][]const u8{"city"};
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .row_number }};
const payload = try encodeTableWindow(allocator, "orders", &specs, &partitionBy, &orderBy, 0, 0, "total gt i:10", .rows);
defer allocator.free(payload);
const args = try decodeTableWindow(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.partitionBy);
defer allocator.free(args.orderBy);
try std.testing.expectEqualStrings("orders", args.table);
try std.testing.expectEqual(@as(usize, 1), args.specs.len);
try std.testing.expectEqual(windowmod.WindowKind.row_number, args.specs[0].kind);
try std.testing.expectEqualStrings("", args.specs[0].field);
try std.testing.expectEqual(@as(usize, 1), args.partitionBy.len);
try std.testing.expectEqualStrings("city", args.partitionBy[0]);
try std.testing.expectEqual(@as(usize, 1), args.orderBy.len);
try std.testing.expectEqualStrings("day", args.orderBy[0].field);
try std.testing.expect(!args.orderBy[0].descending);
try std.testing.expectEqualStrings("total gt i:10", args.exprText);
}
test "table_window args round trip a sum spec with no partition, no order-by, and an empty expr" {
const allocator = std.testing.allocator;
const specs = [_]windowmod.WindowSpec{.{ .field = "total", .kind = .sum }};
const payload = try encodeTableWindow(allocator, "orders", &specs, &.{}, &.{}, 0, 0, "", .rows);
defer allocator.free(payload);
const args = try decodeTableWindow(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.partitionBy);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 1), args.specs.len);
try std.testing.expectEqual(windowmod.WindowKind.sum, args.specs[0].kind);
try std.testing.expectEqualStrings("total", args.specs[0].field);
try std.testing.expectEqual(@as(usize, 0), args.partitionBy.len);
try std.testing.expectEqual(@as(usize, 0), args.orderBy.len);
try std.testing.expectEqualStrings("", args.exprText);
}
test "table_window args round trip a count_distinct spec alongside sum, no extra wire bytes needed" {
const allocator = std.testing.allocator;
const orderBy = [_]querymod.OrderBy{.{ .field = "day" }};
const specs = [_]windowmod.WindowSpec{
.{ .field = "tag", .kind = .count_distinct },
.{ .field = "amount", .kind = .sum },
};
const payload = try encodeTableWindow(allocator, "orders", &specs, &.{}, &orderBy, 0, 0, "", .rows);
defer allocator.free(payload);
const args = try decodeTableWindow(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.partitionBy);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 2), args.specs.len);
try std.testing.expectEqual(windowmod.WindowKind.count_distinct, args.specs[0].kind);
try std.testing.expectEqualStrings("tag", args.specs[0].field);
try std.testing.expectEqual(windowmod.WindowKind.sum, args.specs[1].kind);
}
test "table_window args round trip first_value/last_value specs alongside a partition/order/RANGE frame" {
const allocator = std.testing.allocator;
const orderBy = [_]querymod.OrderBy{.{ .field = "day" }};
const partitionBy = [_][]const u8{"city"};
const specs = [_]windowmod.WindowSpec{
.{ .field = "amount", .kind = .first_value },
.{ .field = "amount", .kind = .last_value },
};
const payload = try encodeTableWindow(allocator, "orders", &specs, &partitionBy, &orderBy, 0, 0, "", .range);
defer allocator.free(payload);
const args = try decodeTableWindow(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.partitionBy);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 2), args.specs.len);
try std.testing.expectEqual(windowmod.WindowKind.first_value, args.specs[0].kind);
try std.testing.expectEqualStrings("amount", args.specs[0].field);
try std.testing.expectEqual(windowmod.WindowKind.last_value, args.specs[1].kind);
try std.testing.expectEqualStrings("amount", args.specs[1].field);
try std.testing.expectEqual(windowmod.FrameMode.range, args.frame);
}
test "table_window args round trip MULTIPLE specs sharing one partition/order/frame" {
const allocator = std.testing.allocator;
const specs = [_]windowmod.WindowSpec{
.{ .field = "total", .kind = .sum },
.{ .field = "total", .kind = .avg },
.{ .field = "", .kind = .rank },
};
const payload = try encodeTableWindow(allocator, "orders", &specs, &.{}, &.{}, 0, 0, "", .rows);
defer allocator.free(payload);
const args = try decodeTableWindow(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.partitionBy);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 3), args.specs.len);
try std.testing.expectEqual(windowmod.WindowKind.sum, args.specs[0].kind);
try std.testing.expectEqualStrings("total", args.specs[0].field);
try std.testing.expectEqual(windowmod.WindowKind.avg, args.specs[1].kind);
try std.testing.expectEqualStrings("total", args.specs[1].field);
try std.testing.expectEqual(windowmod.WindowKind.rank, args.specs[2].kind);
try std.testing.expectEqualStrings("", args.specs[2].field);
}
test "table_window args round trip a nonzero offset and limit" {
const allocator = std.testing.allocator;
const specs = [_]windowmod.WindowSpec{.{ .field = "", .kind = .rank }};
const payload = try encodeTableWindow(allocator, "orders", &specs, &.{}, &.{}, 2, 5, "", .rows);
defer allocator.free(payload);
const args = try decodeTableWindow(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.partitionBy);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(u32, 2), args.offset);
try std.testing.expectEqual(@as(u32, 5), args.limit);
}
test "table_window args round trip a RANGE frame, and default to ROWS when the byte is 0" {
const allocator = std.testing.allocator;
const order = [_]querymod.OrderBy{.{ .field = "day", .descending = false }};
const specs = [_]windowmod.WindowSpec{.{ .field = "amount", .kind = .sum }};
const rangePayload = try encodeTableWindow(allocator, "orders", &specs, &.{}, &order, 0, 0, "", .range);
defer allocator.free(rangePayload);
const rangeArgs = try decodeTableWindow(allocator, rangePayload);
defer allocator.free(rangeArgs.specs);
defer allocator.free(rangeArgs.partitionBy);
defer allocator.free(rangeArgs.orderBy);
try std.testing.expectEqual(windowmod.FrameMode.range, rangeArgs.frame);
const rowsPayload = try encodeTableWindow(allocator, "orders", &specs, &.{}, &order, 0, 0, "", .rows);
defer allocator.free(rowsPayload);
const rowsArgs = try decodeTableWindow(allocator, rowsPayload);
defer allocator.free(rowsArgs.specs);
defer allocator.free(rowsArgs.partitionBy);
defer allocator.free(rowsArgs.orderBy);
try std.testing.expectEqual(windowmod.FrameMode.rows, rowsArgs.frame);
}
test "decodeTableWindow rejects an unknown frame byte instead of silently defaulting to ROWS" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, "orders");
try w.byte(1); // specs count
try w.byte(@intFromEnum(windowmod.WindowKind.sum));
try putLenPrefixed(w, "total");
try w.byte(0); // partitionBy count
try w.byte(0); // orderBy count
try w.putU32(0);
try w.putU32(0);
try w.byte(200); // not a valid FrameMode tag
try std.testing.expectError(error.Malformed, decodeTableWindow(allocator, buf.items));
}
test "table_window args round trip a multi-column partition and multi-column descending order-by" {
const allocator = std.testing.allocator;
const partitionBy = [_][]const u8{ "city", "region" };
const orderBy = [_]querymod.OrderBy{
.{ .field = "day", .descending = true },
.{ .field = "amount", .descending = false },
};
const specs = [_]windowmod.WindowSpec{.{ .field = "amount", .kind = .max }};
const payload = try encodeTableWindow(allocator, "orders", &specs, &partitionBy, &orderBy, 0, 0, "", .rows);
defer allocator.free(payload);
const args = try decodeTableWindow(allocator, payload);
defer allocator.free(args.specs);
defer allocator.free(args.partitionBy);
defer allocator.free(args.orderBy);
try std.testing.expectEqual(@as(usize, 2), args.partitionBy.len);
try std.testing.expectEqualStrings("city", args.partitionBy[0]);
try std.testing.expectEqualStrings("region", args.partitionBy[1]);
try std.testing.expectEqual(@as(usize, 2), args.orderBy.len);
try std.testing.expect(args.orderBy[0].descending);
try std.testing.expect(!args.orderBy[1].descending);
}
test "decodeTableWindow rejects an unknown window kind byte instead of corrupting a spec" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try putLenPrefixed(w, "orders");
try w.byte(1); // specs count
try w.byte(200); // not a valid WindowKind tag
try putLenPrefixed(w, "total");
try w.byte(0); // partitionBy count
try w.byte(0); // orderBy count
try w.putU32(0);
try w.putU32(0);
try std.testing.expectError(error.Malformed, decodeTableWindow(allocator, buf.items));
}
test "window rows round trip through encode and decode" {
const allocator = std.testing.allocator;
var rows = [_]rowmod.Row{ rowmod.Row.init(allocator), rowmod.Row.init(allocator) };
defer for (&rows) |*r| r.deinit();
try rows[0].set("city", .{ .text = "warsaw" });
try rows[1].set("city", .{ .text = "krakow" });
var values0 = [_]rowmod.Value{.{ .int = 1 }};
var values1 = [_]rowmod.Value{.{ .int = 2 }};
const windowRows = [_]WindowRow{
.{ .row = rows[0], .values = &values0 },
.{ .row = rows[1], .values = &values1 },
};
const payload = try encodeWindowRows(allocator, &windowRows);
defer allocator.free(payload);
const decoded = try decodeWindowRows(allocator, payload);
defer freeWindowRows(allocator, decoded);
try std.testing.expectEqual(@as(usize, 2), decoded.len);
try std.testing.expectEqualStrings("warsaw", decoded[0].row.get("city").?.text);
try std.testing.expectEqual(@as(usize, 1), decoded[0].values.len);
try std.testing.expectEqual(@as(i64, 1), decoded[0].values[0].int);
try std.testing.expectEqualStrings("krakow", decoded[1].row.get("city").?.text);
try std.testing.expectEqual(@as(i64, 2), decoded[1].values[0].int);
}
test "window rows round trip MULTIPLE values per row, same order as the request's specs" {
const allocator = std.testing.allocator;
var rows = [_]rowmod.Row{rowmod.Row.init(allocator)};
defer for (&rows) |*r| r.deinit();
try rows[0].set("city", .{ .text = "warsaw" });
var values0 = [_]rowmod.Value{ .{ .int = 30 }, .{ .float = 15.0 }, .{ .int = 1 } };
const windowRows = [_]WindowRow{.{ .row = rows[0], .values = &values0 }};
const payload = try encodeWindowRows(allocator, &windowRows);
defer allocator.free(payload);
const decoded = try decodeWindowRows(allocator, payload);
defer freeWindowRows(allocator, decoded);
try std.testing.expectEqual(@as(usize, 1), decoded.len);
try std.testing.expectEqual(@as(usize, 3), decoded[0].values.len);
try std.testing.expectEqual(@as(i64, 30), decoded[0].values[0].int);
try std.testing.expectEqual(@as(f64, 15.0), decoded[0].values[1].float);
try std.testing.expectEqual(@as(i64, 1), decoded[0].values[2].int);
}
test "empty window row list round trips" {
const allocator = std.testing.allocator;
const payload = try encodeWindowRows(allocator, &[_]WindowRow{});
defer allocator.free(payload);
const decoded = try decodeWindowRows(allocator, payload);
defer freeWindowRows(allocator, decoded);
try std.testing.expectEqual(@as(usize, 0), decoded.len);
}
test "subscribe args round trip a multi-channel list" {
const allocator = std.testing.allocator;
const payload = try encodeSubscribe(allocator, &.{ "orders", "alerts" });
defer allocator.free(payload);
const args = try decodeSubscribe(allocator, payload);
defer args.deinit(allocator);
try std.testing.expectEqual(@as(usize, 2), args.channels.len);
try std.testing.expectEqualStrings("orders", args.channels[0]);
try std.testing.expectEqualStrings("alerts", args.channels[1]);
}
test "subscribe args round trip an empty channel list" {
const allocator = std.testing.allocator;
const payload = try encodeSubscribe(allocator, &.{});
defer allocator.free(payload);
const args = try decodeSubscribe(allocator, payload);
defer args.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), args.channels.len);
}
test "decodeSubscribe rejects a truncated channel count" {
const allocator = std.testing.allocator;
try std.testing.expectError(error.Malformed, decodeSubscribe(allocator, &.{ 0, 0 }));
}
test "decodeSubscribe rejects a channel count larger than the payload can hold" {
const allocator = std.testing.allocator;
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const w = encode.ByteWriter.init(&buf);
try w.putU32(5);
try std.testing.expectError(error.Malformed, decodeSubscribe(allocator, buf.items));
}
test "subscribe ack round trips a subscriber id and channel count" {
const allocator = std.testing.allocator;
const payload = try encodeSubscribeAck(allocator, 42, 3);
defer allocator.free(payload);
const ack = try decodeSubscribeAck(payload);
try std.testing.expectEqual(@as(u64, 42), ack.subscriberId);
try std.testing.expectEqual(@as(u32, 3), ack.channelCount);
}