use nodedb_physical::physical_plan::UpdateValue;
fn encode<T: zerompk::ToMessagePack>(context: &str, value: &T) -> crate::Result<Vec<u8>> {
zerompk::to_msgpack_vec(value).map_err(|e| crate::Error::Serialization {
format: "msgpack".into(),
detail: format!("wal kv {context}: {e}"),
})
}
pub(crate) fn encode_kv_put(
collection: &str,
key: &[u8],
value: &[u8],
ttl_ms: u64,
expire_at_ms: Option<u64>,
) -> crate::Result<Vec<u8>> {
match expire_at_ms {
None => encode("put", &("kv_put", collection, key, value, ttl_ms)),
Some(expire_at_ms) => encode(
"put",
&("kv_put", collection, key, value, ttl_ms, expire_at_ms),
),
}
}
pub(crate) fn encode_kv_insert_on_conflict_update(
collection: &str,
key: &[u8],
value: &[u8],
ttl_ms: u64,
updates: &[(String, UpdateValue)],
expire_at_ms: Option<u64>,
) -> crate::Result<Vec<u8>> {
match expire_at_ms {
None => encode(
"insert on conflict update",
&(
"kv_insert_on_conflict_update",
collection,
key,
value,
ttl_ms,
updates,
),
),
Some(expire_at_ms) => encode(
"insert on conflict update",
&(
"kv_insert_on_conflict_update",
collection,
key,
value,
ttl_ms,
updates,
expire_at_ms,
),
),
}
}
pub(crate) struct KvTransferFields<'a> {
pub collection: &'a str,
pub source_key: &'a [u8],
pub dest_key: &'a [u8],
pub field: &'a str,
pub amount: f64,
pub debit_surrogate: u32,
pub credit_surrogate: u32,
}
pub(crate) fn encode_kv_transfer(f: KvTransferFields<'_>) -> crate::Result<Vec<u8>> {
encode(
"transfer",
&(
"kv_transfer",
f.collection,
f.source_key,
f.dest_key,
f.field,
f.amount,
f.debit_surrogate,
f.credit_surrogate,
),
)
}
pub(crate) fn encode_kv_transfer_item(
source_collection: &str,
dest_collection: &str,
item_key: &[u8],
dest_key: &[u8],
surrogate: u32,
) -> crate::Result<Vec<u8>> {
encode(
"transfer item",
&(
"kv_transfer_item",
source_collection,
dest_collection,
item_key,
dest_key,
surrogate,
),
)
}
pub(crate) fn encode_kv_cas(
collection: &str,
key: &[u8],
expected: &[u8],
new_value: &[u8],
surrogate: u32,
) -> crate::Result<Vec<u8>> {
encode(
"cas",
&("kv_cas", collection, key, expected, new_value, surrogate),
)
}
pub(crate) fn encode_kv_incr_float(
collection: &str,
key: &[u8],
delta: f64,
surrogate: u32,
) -> crate::Result<Vec<u8>> {
encode(
"incr_float",
&("kv_incr_float", collection, key, delta, surrogate),
)
}
pub(crate) fn encode_kv_field_set(
collection: &str,
key: &[u8],
updates: &[(String, Vec<u8>)],
surrogate: u32,
) -> crate::Result<Vec<u8>> {
encode(
"field set",
&("kv_field_set", collection, key, updates, surrogate),
)
}
pub(crate) fn encode_kv_getset(
collection: &str,
key: &[u8],
new_value: &[u8],
surrogate: u32,
) -> crate::Result<Vec<u8>> {
encode(
"getset",
&("kv_getset", collection, key, new_value, surrogate),
)
}
pub(crate) fn encode_kv_delete(collection: &str, keys: &[Vec<u8>]) -> crate::Result<Vec<u8>> {
encode("delete", &("kv_delete", collection, keys))
}
pub(crate) fn encode_kv_batch_put(
collection: &str,
entries: &[(Vec<u8>, Vec<u8>)],
ttl_ms: u64,
expire_at_ms: Option<u64>,
) -> crate::Result<Vec<u8>> {
match expire_at_ms {
None => encode("batch put", &("kv_batch_put", collection, entries, ttl_ms)),
Some(expire_at_ms) => encode(
"batch put",
&("kv_batch_put", collection, entries, ttl_ms, expire_at_ms),
),
}
}
pub(crate) fn encode_kv_expire(
collection: &str,
key: &[u8],
ttl_ms: u64,
expire_at_ms: u64,
) -> crate::Result<Vec<u8>> {
encode(
"expire",
&("kv_expire", collection, key, ttl_ms, expire_at_ms),
)
}
pub(crate) fn encode_kv_persist(collection: &str, key: &[u8]) -> crate::Result<Vec<u8>> {
encode("persist", &("kv_persist", collection, key))
}
pub(crate) fn encode_kv_register_index(
collection: &str,
field: &str,
field_position: usize,
backfill: bool,
) -> crate::Result<Vec<u8>> {
encode(
"register index",
&(
"kv_register_index",
collection,
field,
field_position,
backfill,
),
)
}
pub(crate) fn encode_kv_drop_index(collection: &str, field: &str) -> crate::Result<Vec<u8>> {
encode("drop index", &("kv_drop_index", collection, field))
}
pub(crate) fn encode_kv_incr(
collection: &str,
key: &[u8],
delta: i64,
ttl_ms: u64,
surrogate: u32,
expire_at_ms: Option<u64>,
) -> crate::Result<Vec<u8>> {
match expire_at_ms {
None => encode(
"incr",
&("kv_incr", collection, key, delta, ttl_ms, surrogate),
),
Some(expire_at_ms) => encode(
"incr",
&(
"kv_incr",
collection,
key,
delta,
ttl_ms,
surrogate,
expire_at_ms,
),
),
}
}
pub(crate) struct KvRegisterSortedIndexFields<'a> {
pub collection: &'a str,
pub index_name: &'a str,
pub sort_columns: &'a [(String, String)],
pub key_column: &'a str,
pub window_type: &'a str,
pub window_timestamp_column: &'a str,
pub window_start_ms: u64,
pub window_end_ms: u64,
}
pub(crate) fn encode_kv_register_sorted_index(
f: KvRegisterSortedIndexFields<'_>,
) -> crate::Result<Vec<u8>> {
encode(
"register sorted index",
&(
"kv_register_sorted_index",
f.collection,
f.index_name,
f.sort_columns,
f.key_column,
f.window_type,
f.window_timestamp_column,
f.window_start_ms,
f.window_end_ms,
),
)
}
pub(crate) fn encode_kv_drop_sorted_index(index_name: &str) -> crate::Result<Vec<u8>> {
encode("drop sorted index", &("kv_drop_sorted_index", index_name))
}
pub(crate) fn encode_kv_truncate(collection: &str) -> crate::Result<Vec<u8>> {
encode("truncate", &("kv_truncate", collection))
}
#[cfg(test)]
mod tests {
use nodedb_physical::physical_plan::UpdateValue;
use super::{
KvTransferFields, encode_kv_batch_put, encode_kv_cas, encode_kv_expire,
encode_kv_field_set, encode_kv_getset, encode_kv_incr, encode_kv_incr_float,
encode_kv_insert_on_conflict_update, encode_kv_put, encode_kv_register_index,
encode_kv_transfer, encode_kv_transfer_item,
};
#[test]
fn kv_put_without_expire_at_matches_historical_shape() {
let entry = encode_kv_put("users", b"k1", b"v1", 5_000, None).unwrap();
let expected =
zerompk::to_msgpack_vec(&("kv_put", "users", b"k1", b"v1", 5_000u64)).unwrap();
assert_eq!(entry, expected);
let (disc, collection, key, value, ttl_ms) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, Vec<u8>, u64)>(&entry).unwrap();
assert_eq!(disc, "kv_put");
assert_eq!(collection, "users");
assert_eq!(key, b"k1");
assert_eq!(value, b"v1");
assert_eq!(ttl_ms, 5_000);
}
#[test]
fn kv_put_with_expire_at_carries_absolute_instant() {
let entry = encode_kv_put("users", b"k1", b"v1", 5_000, Some(1_700_000_000_000)).unwrap();
let (disc, collection, key, value, ttl_ms, expire_at_ms) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, Vec<u8>, u64, u64)>(&entry).unwrap();
assert_eq!(disc, "kv_put");
assert_eq!(collection, "users");
assert_eq!(key, b"k1");
assert_eq!(value, b"v1");
assert_eq!(ttl_ms, 5_000);
assert_eq!(expire_at_ms, 1_700_000_000_000);
assert!(
zerompk::from_msgpack::<(&str, String, Vec<u8>, Vec<u8>, u64)>(&entry).is_err(),
"extended payload must not decode as the five-element tuple"
);
}
#[test]
fn kv_batch_put_without_expire_at_matches_historical_shape() {
let entries = vec![
(b"k1".to_vec(), b"v1".to_vec()),
(b"k2".to_vec(), b"v2".to_vec()),
];
let entry = encode_kv_batch_put("users", &entries, 5_000, None).unwrap();
let expected =
zerompk::to_msgpack_vec(&("kv_batch_put", "users", &entries, 5_000u64)).unwrap();
assert_eq!(entry, expected);
let (disc, collection, decoded_entries, ttl_ms) =
zerompk::from_msgpack::<(&str, String, Vec<(Vec<u8>, Vec<u8>)>, u64)>(&entry).unwrap();
assert_eq!(disc, "kv_batch_put");
assert_eq!(collection, "users");
assert_eq!(decoded_entries, entries);
assert_eq!(ttl_ms, 5_000);
}
#[test]
fn kv_batch_put_with_expire_at_carries_absolute_instant() {
let entries = vec![
(b"k1".to_vec(), b"v1".to_vec()),
(b"k2".to_vec(), b"v2".to_vec()),
];
let entry = encode_kv_batch_put("users", &entries, 5_000, Some(1_700_000_000_000)).unwrap();
let (disc, collection, decoded_entries, ttl_ms, expire_at_ms) =
zerompk::from_msgpack::<(&str, String, Vec<(Vec<u8>, Vec<u8>)>, u64, u64)>(&entry)
.unwrap();
assert_eq!(disc, "kv_batch_put");
assert_eq!(collection, "users");
assert_eq!(decoded_entries, entries);
assert_eq!(ttl_ms, 5_000);
assert_eq!(expire_at_ms, 1_700_000_000_000);
assert!(
zerompk::from_msgpack::<(&str, String, Vec<(Vec<u8>, Vec<u8>)>, u64)>(&entry).is_err(),
"extended payload must not decode as the four-element tuple"
);
}
#[test]
fn kv_transfer_encodes_delta_shape_with_both_surrogates() {
let entry = encode_kv_transfer(KvTransferFields {
collection: "accounts",
source_key: b"alice",
dest_key: b"bob",
field: "balance",
amount: 30.0,
debit_surrogate: 7,
credit_surrogate: 8,
})
.unwrap();
let (
disc,
collection,
source_key,
dest_key,
field,
amount,
debit_surrogate,
credit_surrogate,
) = zerompk::from_msgpack::<(&str, String, Vec<u8>, Vec<u8>, String, f64, u32, u32)>(
&entry,
)
.unwrap();
assert_eq!(disc, "kv_transfer");
assert_eq!(collection, "accounts");
assert_eq!(source_key, b"alice");
assert_eq!(dest_key, b"bob");
assert_eq!(field, "balance");
assert_eq!(amount, 30.0);
assert_eq!(debit_surrogate, 7);
assert_eq!(credit_surrogate, 8);
}
#[test]
fn kv_transfer_item_encodes_delta_shape_with_surrogate() {
let entry =
encode_kv_transfer_item("inventory", "trades", b"sword_1", b"sword_moved", 42).unwrap();
let (disc, source_collection, dest_collection, item_key, dest_key, surrogate) =
zerompk::from_msgpack::<(&str, String, String, Vec<u8>, Vec<u8>, u32)>(&entry).unwrap();
assert_eq!(disc, "kv_transfer_item");
assert_eq!(source_collection, "inventory");
assert_eq!(dest_collection, "trades");
assert_eq!(item_key, b"sword_1");
assert_eq!(dest_key, b"sword_moved");
assert_eq!(surrogate, 42);
}
#[test]
fn kv_cas_encodes_expected_and_new_value_with_surrogate() {
let entry = encode_kv_cas("state", b"p1", b"idle", b"in_match", 9).unwrap();
let (disc, collection, key, expected, new_value, surrogate) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, Vec<u8>, Vec<u8>, u32)>(&entry)
.unwrap();
assert_eq!(disc, "kv_cas");
assert_eq!(collection, "state");
assert_eq!(key, b"p1");
assert_eq!(expected, b"idle");
assert_eq!(new_value, b"in_match");
assert_eq!(surrogate, 9);
}
#[test]
fn kv_incr_float_encodes_delta_with_surrogate() {
let entry = encode_kv_incr_float("scores", b"dmg", 3.125, 5).unwrap();
let (disc, collection, key, delta, surrogate) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, f64, u32)>(&entry).unwrap();
assert_eq!(disc, "kv_incr_float");
assert_eq!(collection, "scores");
assert_eq!(key, b"dmg");
assert_eq!(delta, 3.125);
assert_eq!(surrogate, 5);
}
#[test]
fn kv_field_set_encodes_updates_with_surrogate() {
let updates = vec![
("score".to_string(), b"42".to_vec()),
("name".to_string(), b"alice".to_vec()),
];
let entry = encode_kv_field_set("players", b"p1", &updates, 11).unwrap();
let (disc, collection, key, decoded_updates, surrogate) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, Vec<(String, Vec<u8>)>, u32)>(&entry)
.unwrap();
assert_eq!(disc, "kv_field_set");
assert_eq!(collection, "players");
assert_eq!(key, b"p1");
assert_eq!(decoded_updates, updates);
assert_eq!(surrogate, 11);
}
#[test]
fn kv_insert_on_conflict_update_without_expire_at_carries_updates() {
let updates = vec![("score".to_string(), UpdateValue::Literal(b"42".to_vec()))];
let entry =
encode_kv_insert_on_conflict_update("players", b"p1", b"excluded", 0, &updates, None)
.unwrap();
let (disc, collection, key, value, ttl_ms, decoded_updates) = zerompk::from_msgpack::<(
&str,
String,
Vec<u8>,
Vec<u8>,
u64,
Vec<(String, UpdateValue)>,
)>(&entry)
.unwrap();
assert_eq!(disc, "kv_insert_on_conflict_update");
assert_eq!(collection, "players");
assert_eq!(key, b"p1");
assert_eq!(value, b"excluded");
assert_eq!(ttl_ms, 0);
assert_eq!(decoded_updates, updates);
assert!(
zerompk::from_msgpack::<(
&str,
String,
Vec<u8>,
Vec<u8>,
u64,
Vec<(String, UpdateValue)>,
u64
)>(&entry)
.is_err(),
"six-element payload must not decode as the seven-element tuple"
);
}
#[test]
fn kv_insert_on_conflict_update_with_expire_at_carries_absolute_instant() {
let updates = vec![("score".to_string(), UpdateValue::Literal(b"42".to_vec()))];
let entry = encode_kv_insert_on_conflict_update(
"players",
b"p1",
b"excluded",
5_000,
&updates,
Some(1_700_000_000_000),
)
.unwrap();
let (disc, collection, key, value, ttl_ms, decoded_updates, expire_at_ms) =
zerompk::from_msgpack::<(
&str,
String,
Vec<u8>,
Vec<u8>,
u64,
Vec<(String, UpdateValue)>,
u64,
)>(&entry)
.unwrap();
assert_eq!(disc, "kv_insert_on_conflict_update");
assert_eq!(collection, "players");
assert_eq!(key, b"p1");
assert_eq!(value, b"excluded");
assert_eq!(ttl_ms, 5_000);
assert_eq!(decoded_updates, updates);
assert_eq!(expire_at_ms, 1_700_000_000_000);
}
#[test]
fn kv_register_index_round_trips_backfill_flag() {
let entry_backfill_true = encode_kv_register_index("players", "name", 2, true).unwrap();
let (disc, collection, field, field_position, backfill) =
zerompk::from_msgpack::<(&str, String, String, usize, bool)>(&entry_backfill_true)
.unwrap();
assert_eq!(disc, "kv_register_index");
assert_eq!(collection, "players");
assert_eq!(field, "name");
assert_eq!(field_position, 2);
assert!(backfill);
let entry_backfill_false = encode_kv_register_index("players", "name", 2, false).unwrap();
let (_, _, _, _, backfill_false) =
zerompk::from_msgpack::<(&str, String, String, usize, bool)>(&entry_backfill_false)
.unwrap();
assert!(!backfill_false);
assert_ne!(entry_backfill_true, entry_backfill_false);
}
#[test]
fn kv_expire_always_carries_the_resolved_absolute_instant() {
let entry = encode_kv_expire("sessions", b"tok1", 5_000, 6_000).unwrap();
let (disc, collection, key, ttl_ms, expire_at_ms) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, u64, u64)>(&entry).unwrap();
assert_eq!(disc, "kv_expire");
assert_eq!(collection, "sessions");
assert_eq!(key, b"tok1");
assert_eq!(ttl_ms, 5_000);
assert_eq!(expire_at_ms, 6_000);
}
#[test]
fn kv_expire_with_zero_ttl_still_carries_an_absolute_instant() {
let entry = encode_kv_expire("sessions", b"tok2", 0, 1_234).unwrap();
let (disc, collection, key, ttl_ms, expire_at_ms) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, u64, u64)>(&entry).unwrap();
assert_eq!(disc, "kv_expire");
assert_eq!(collection, "sessions");
assert_eq!(key, b"tok2");
assert_eq!(ttl_ms, 0);
assert_eq!(expire_at_ms, 1_234);
}
#[test]
fn kv_incr_without_expire_at_matches_historical_shape() {
let entry = encode_kv_incr("counters", b"hits", 3, 0, 7, None).unwrap();
let expected =
zerompk::to_msgpack_vec(&("kv_incr", "counters", b"hits", 3i64, 0u64, 7u32)).unwrap();
assert_eq!(entry, expected);
let (disc, collection, key, delta, ttl_ms, surrogate) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, i64, u64, u32)>(&entry).unwrap();
assert_eq!(disc, "kv_incr");
assert_eq!(collection, "counters");
assert_eq!(key, b"hits");
assert_eq!(delta, 3);
assert_eq!(ttl_ms, 0);
assert_eq!(surrogate, 7);
}
#[test]
fn kv_incr_with_expire_at_carries_absolute_instant() {
let entry = encode_kv_incr(
"counters",
b"daily",
1,
86_400_000,
9,
Some(1_700_000_000_000),
)
.unwrap();
let (disc, collection, key, delta, ttl_ms, surrogate, expire_at_ms) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, i64, u64, u32, u64)>(&entry).unwrap();
assert_eq!(disc, "kv_incr");
assert_eq!(collection, "counters");
assert_eq!(key, b"daily");
assert_eq!(delta, 1);
assert_eq!(ttl_ms, 86_400_000);
assert_eq!(surrogate, 9);
assert_eq!(expire_at_ms, 1_700_000_000_000);
assert!(
zerompk::from_msgpack::<(&str, String, Vec<u8>, i64, u64, u32)>(&entry).is_err(),
"extended payload must not decode as the six-element tuple"
);
}
#[test]
fn kv_getset_encodes_new_value_with_surrogate() {
let entry = encode_kv_getset("session", b"tok", b"new-token", 3).unwrap();
let (disc, collection, key, new_value, surrogate) =
zerompk::from_msgpack::<(&str, String, Vec<u8>, Vec<u8>, u32)>(&entry).unwrap();
assert_eq!(disc, "kv_getset");
assert_eq!(collection, "session");
assert_eq!(key, b"tok");
assert_eq!(new_value, b"new-token");
assert_eq!(surrogate, 3);
}
}