use mlua::prelude::*;
use serde_json::{Map, Value};
use tokio::sync::Mutex;
use super::{json_to_lua, lua_to_json};
use crate::knl;
pub const SESSION_API: &[(&str, &str)] = &[
("id", "id() -> string — the stream this session writes"),
(
"scope_id",
"scope_id() -> string — the authority the stream is written under",
),
(
"owner",
"owner() -> string — the principal the scope belongs to (or \"anon\" / \"system\")",
),
(
"append",
"append(event) -> seq — record a fact: { kind, beat?, meta? (shallow), data? }; a key \
outside that envelope, a kernel-only kind and a nested meta are refused, and the budget \
does not move [raises: validation, closed, busy, storage]",
),
(
"events",
"events(from?) -> rows, truncated — the record from `from` on, as fresh tables in the \
shape it was written in (kind / beat / meta / data, plus the kernel's stamps), capped at \
the kernel's row limit; `truncated` says the cap cut the read short, and the rest is read \
by paging on `from` [raises: busy, storage, corruption]",
),
(
"len",
"len() -> integer — how many events are recorded [raises: busy, storage]",
),
(
"view",
"view(name, opts?) -> table — the one named fold: \"tail\" { n }; anything else is a \
query [raises: validation, busy, storage, corruption]",
),
(
"query",
"query(sql, params?, opts?) -> rows, truncated — read the log with one SELECT / WITH; \
$stream is this session, $sessions is opts.sessions (default { this session }) \
[raises: validation, busy, storage, corruption, timeout]",
),
(
"reserve",
"reserve(n) -> true | false, tag — the deduction that asks: refuse if remaining < n, \
atomic, both answers recorded [raises: validation, closed, busy, storage, corruption]",
),
(
"spend",
"spend(n) -> nil — the deduction that does not ask; independent of reserve, so calling \
both for one beat deducts twice; the write is the answer, read the balance \
with remaining() [raises: validation, closed, busy, storage]",
),
(
"remaining",
"remaining() -> integer | nil — the balance, nil without a budget; a store that cannot be \
read raises rather than reporting a stale one [raises: busy, storage, corruption]",
),
(
"exhausted",
"exhausted() -> boolean — whether the budget is used up (false without one) \
[raises: busy, storage, corruption]",
),
(
"close",
"close(reason?, detail?) -> nil — record session_closed and end the session; idempotent \
[raises: validation, busy, storage]",
),
(
"__close",
"__close(err) — the <close> scope boundary: scope_exit, or error with the message as detail \
[raises: busy, storage — only on a clean exit; an unwinding one is logged]",
),
];
pub const MODULE_API: &[(&str, &str)] = &[
(
"open",
"open(opts?) -> session — owner? / budget? / store? (absent is the host's database, one \
file per project; \"mem\" is an in-memory database for tests and mocks — one session, \
one process, nothing shared; or { sqlite = path }); parent? opens a child on the \
parent's database with budget = { from_parent = n, tag? }, moving n out of the parent's \
balance in one write, and a parent on \"mem\" is refused because a tree needs a file \
store [raises: validation, refused, closed, busy, storage]",
),
(
"resume",
"resume(opts) -> session — reopen a stream and re-fold it; an absent store means the \
host's database, as on open; a closed session is not resumable \
[raises: validation, closed, busy, storage, corruption]",
),
(
"new_beat_id",
"new_beat_id() -> string — mint a time-ordered beat id for the caller to stamp",
),
(
"error",
"error(err) -> { kind, method, retryable, message } — read a raised failure as a table; \
an unrecognised one comes back with kind = nil and the whole text as message",
),
(
"api",
"api() -> { session = …, module = …, errors = { kind }, schema = { table, columns }, \
fields = { amount, tag, … } } — the declared surface, the columns a query may name, and \
the `data` paths a view reaches into",
),
];
pub mod types {
use schema_bridge::{Field, Schema, SchemaBridge};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Json(pub Value);
impl SchemaBridge for Json {
fn to_ts() -> String {
"unknown".to_string()
}
fn to_schema() -> Schema {
Schema::Any
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
untagged,
expecting = "a label: meta is shallow (a string, a number or a boolean)"
)]
pub enum MetaValue {
Text(String),
Number(f64),
Flag(bool),
}
impl SchemaBridge for MetaValue {
fn to_ts() -> String {
"string | number | boolean".to_string()
}
fn to_schema() -> Schema {
Schema::Union(vec![Schema::String, Schema::Number, Schema::Boolean])
}
}
pub type Meta = BTreeMap<String, MetaValue>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct SessionId(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct ScopeId(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct Owner(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct BeatId(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct Seq(pub u64);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct Count(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct Amount(pub i64);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct Remaining(pub Option<i64>);
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct Exhausted(pub bool);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct Sql(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct CloseReason(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct CloseDetail(pub String);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Raised;
impl SchemaBridge for Raised {
fn to_ts() -> String {
"unknown".to_string()
}
fn to_schema() -> Schema {
Schema::Any
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ViewName(pub String);
impl SchemaBridge for ViewName {
fn to_ts() -> String {
format!("{:?}", crate::knl::projection::VIEW_TAIL)
}
fn to_schema() -> Schema {
Schema::Enum(vec![crate::knl::projection::VIEW_TAIL.to_string()])
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(deny_unknown_fields)]
pub struct ViewOpts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
untagged,
expecting = r#"a store: "mem", or a table { sqlite = <path> }"#
)]
pub enum StoreSpec {
Named(String),
File(SqliteStore),
}
impl SchemaBridge for StoreSpec {
fn to_ts() -> String {
r#""mem" | { sqlite: string }"#.to_string()
}
fn to_schema() -> Schema {
Schema::Union(vec![
Schema::Enum(vec![MEM_STORE.to_string()]),
SqliteStore::to_schema(),
])
}
}
pub const MEM_STORE: &str = "mem";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(deny_unknown_fields)]
pub struct SqliteStore {
pub sqlite: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BudgetOpt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub desc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_parent: Option<i64>,
}
impl SchemaBridge for BudgetOpt {
fn to_ts() -> String {
format!("{} | {}", BudgetGrant::to_ts(), BudgetAllocation::to_ts())
}
fn to_schema() -> Schema {
Schema::Union(vec![
BudgetGrant::to_schema(),
BudgetAllocation::to_schema(),
])
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct BudgetGrant {
pub amount: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub desc: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct BudgetAllocation {
pub from_parent: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(deny_unknown_fields)]
pub struct OpenOpts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget: Option<BudgetOpt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<StoreSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<Json>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(deny_unknown_fields)]
pub struct ResumeOpts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store: Option<StoreSpec>,
pub session: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget: Option<BudgetOpt>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct AppendEvent {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub beat: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Json>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct EventRow {
pub kind: String,
pub seq: u64,
pub epoch_ms: u64,
pub _schema_version: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub beat: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
pub data: Json,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(transparent)]
pub struct EventRows(pub Vec<EventRow>);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct EventsResult(pub EventRows, pub bool);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
untagged,
expecting = "the values a statement binds: a list for `?`, or a table of names"
)]
pub enum QueryParams {
Positional(Vec<Value>),
Named(Map<String, Value>),
}
impl SchemaBridge for QueryParams {
fn to_ts() -> String {
"unknown[] | Record<string, unknown>".to_string()
}
fn to_schema() -> Schema {
Schema::Union(vec![
Schema::Array(Box::new(Schema::Any)),
Schema::Record {
key: Box::new(Schema::String),
value: Box::new(Schema::Any),
},
])
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, SchemaBridge)]
#[serde(deny_unknown_fields)]
pub struct QueryOpts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sessions: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct QueryResult(pub Vec<Json>, pub bool);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct ErrorTable {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub retryable: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct ApiEntry {
pub name: String,
pub doc: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ApiColumn {
pub name: String,
#[serde(rename = "type")]
pub declared_type: String,
pub pk: bool,
}
impl SchemaBridge for ApiColumn {
fn to_ts() -> String {
"{ name: string; type: string; pk: boolean; }".to_string()
}
fn to_schema() -> Schema {
Schema::Object(vec![
Field::new("name", Schema::String),
Field::new("type", Schema::String),
Field::new("pk", Schema::Boolean),
])
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct ApiSchema {
pub table: String,
pub columns: Vec<ApiColumn>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct ApiFields {
pub amount: String,
pub tag: String,
pub desc: String,
pub remaining: String,
pub scope_id: String,
pub owner: String,
pub parent: String,
pub child: String,
pub reason: String,
pub detail: String,
pub open_children: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, SchemaBridge)]
pub struct ApiReport {
pub session: Vec<ApiEntry>,
pub module: Vec<ApiEntry>,
pub errors: Vec<String>,
pub schema: ApiSchema,
pub fields: ApiFields,
pub types: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strict {
Open,
Closed,
}
pub fn declared() -> Vec<(&'static str, Schema, Strict)> {
vec![
("SessionId", SessionId::to_schema(), Strict::Open),
("ScopeId", ScopeId::to_schema(), Strict::Open),
("Owner", Owner::to_schema(), Strict::Open),
("BeatId", BeatId::to_schema(), Strict::Open),
("Seq", Seq::to_schema(), Strict::Open),
("Count", Count::to_schema(), Strict::Open),
("Amount", Amount::to_schema(), Strict::Open),
("Remaining", Remaining::to_schema(), Strict::Open),
("Exhausted", Exhausted::to_schema(), Strict::Open),
("Sql", Sql::to_schema(), Strict::Open),
("CloseReason", CloseReason::to_schema(), Strict::Open),
("CloseDetail", CloseDetail::to_schema(), Strict::Open),
("Raised", Raised::to_schema(), Strict::Open),
("ViewName", ViewName::to_schema(), Strict::Open),
("ViewOpts", ViewOpts::to_schema(), Strict::Closed),
("OpenOpts", OpenOpts::to_schema(), Strict::Open),
("ResumeOpts", ResumeOpts::to_schema(), Strict::Open),
("AppendEvent", AppendEvent::to_schema(), Strict::Open),
("EventsResult", EventsResult::to_schema(), Strict::Open),
("QueryParams", QueryParams::to_schema(), Strict::Open),
("QueryOpts", QueryOpts::to_schema(), Strict::Closed),
("QueryResult", QueryResult::to_schema(), Strict::Open),
("ErrorTable", ErrorTable::to_schema(), Strict::Open),
("ApiReport", ApiReport::to_schema(), Strict::Open),
]
}
}
pub fn lshape_module_source() -> String {
static SOURCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
SOURCE.get_or_init(build_lshape_module_source).clone()
}
fn build_lshape_module_source() -> String {
#[cfg(test)]
TYPES_BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut out = String::from(
"-- Generated at host start by agent-block-core from the argument and\n\
-- return types of `bridge/knl.rs` (schema-bridge -> lshape). Not a file\n\
-- in the tree: there is nothing here to edit, and so nothing to drift.\n\
local T = require(\"lshape\").t\n\nlocal M = {}\n\n",
);
for (name, schema, strict) in types::declared() {
let body = schema_bridge_lshape::schema_to_lshape(&schema)
.unwrap_or_else(|e| unreachable!("knl_types {name} does not map to lshape: {e}"));
let body = match strict {
types::Strict::Open => body,
types::Strict::Closed => close_shape(name, body),
};
out.push_str(&format!("M.{name} = {}\n\n", named_scalar(name, body)));
}
out.push_str("return M\n");
out
}
fn named_scalar(name: &str, body: String) -> String {
let bare = body
.strip_prefix("T.")
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_lowercase()));
if bare {
format!("{body}:describe({name:?})")
} else {
body
}
}
fn close_shape(name: &str, body: String) -> String {
let Some(fields) = body.strip_suffix("})") else {
unreachable!("knl_types {name} is marked strict but did not render as a T.shape: {body}");
};
format!("{fields}}}, {{ open = false }})")
}
fn from_lua<T: serde::de::DeserializeOwned>(
method: &str,
noun: &str,
value: LuaValue,
) -> LuaResult<T> {
let de = mlua::serde::Deserializer::new(value);
serde_path_to_error::deserialize(de).map_err(|error| {
let path = error.path().to_string();
let at = if path.is_empty() || path == "." {
noun.to_string()
} else {
format!("{noun}.{path}")
};
let reason = error.into_inner().to_string();
let reason = reason
.strip_prefix("deserialize error: ")
.unwrap_or(&reason)
.to_string();
err(method, format!("{at}: {reason}"))
})
}
struct Session {
state: Mutex<knl::Session>,
identity: Identity,
}
struct Identity {
id: String,
scope_id: String,
owner: String,
}
impl Session {
fn from_state(state: knl::Session) -> Self {
let identity = Identity {
id: state.id().to_string(),
scope_id: state.scope_id().to_string(),
owner: state.owner().to_string(),
};
Self {
state: Mutex::new(state),
identity,
}
}
async fn new(
owner: String,
grant: Option<knl::BudgetGrant>,
drivers: &knl::IsleDrivers,
) -> LuaResult<Self> {
let state = knl::Session::new(owner, grant, drivers)
.await
.map_err(|e| knl_err("open", &e))?;
Ok(Self::from_state(state))
}
}
impl Drop for Session {
fn drop(&mut self) {
let state = self.state.get_mut();
if state.is_closed() {
return;
}
state.close_detached(knl::CLOSE_REASON_DROPPED);
}
}
const DETAIL_MAX_CHARS: usize = 200;
fn truncated(text: &str) -> String {
if text.chars().count() <= DETAIL_MAX_CHARS {
return text.to_string();
}
text.chars().take(DETAIL_MAX_CHARS).collect::<String>() + "..."
}
fn error_detail(error: &LuaValue) -> String {
let text = match error {
LuaValue::String(s) => s.to_string_lossy(),
LuaValue::Error(e) => e.to_string(),
LuaValue::Integer(i) => i.to_string(),
LuaValue::Number(n) => n.to_string(),
other => format!("<{}>", other.type_name()),
};
truncated(&text)
}
fn attributed(method: &str, kind: &str, reason: impl std::fmt::Display) -> String {
format!("knl: {method}: {kind}: {reason}")
}
fn err_of(method: &str, kind: &str, reason: impl std::fmt::Display) -> LuaError {
LuaError::external(attributed(method, kind, reason))
}
fn err(method: &str, reason: impl std::fmt::Display) -> LuaError {
err_of(method, knl::KnlError::VALIDATION, reason)
}
fn knl_err(method: &str, error: &knl::KnlError) -> LuaError {
err_of(method, error.kind(), error.reason())
}
fn table_to_object(
lua: &Lua,
method: &str,
noun: &str,
value: LuaValue,
) -> LuaResult<Map<String, Value>> {
if !matches!(value, LuaValue::Table(_)) {
return Err(err(
method,
format!("{noun} must be a table, got {}", value.type_name()),
));
}
match lua_to_json(lua, value).map_err(|e| err(method, e))? {
Value::Object(obj) => Ok(obj),
_ => Err(err(
method,
format!("{noun} must be a table with string keys"),
)),
}
}
impl LuaUserData for Session {
fn add_methods<M: LuaUserDataMethods<Self>>(methods: &mut M) {
methods.add_method("id", |_, this, ()| Ok(this.identity.id.clone()));
methods.add_method("scope_id", |_, this, ()| Ok(this.identity.scope_id.clone()));
methods.add_method("owner", |_, this, ()| Ok(this.identity.owner.clone()));
methods.add_async_method("append", |lua, this, event: LuaValue| async move {
let _: types::AppendEvent = from_lua("append", "event", event.clone())?;
let obj = table_to_object(&lua, "append", "event", event)?;
this.state
.lock()
.await
.append(obj)
.await
.map_err(|e| knl_err("append", &e))
});
methods.add_async_method("events", |lua, this, from: Option<u64>| async move {
let selected = {
let state = this.state.lock().await;
state
.events(from.unwrap_or(0), knl::DEFAULT_LIMIT.saturating_add(1))
.await
.map_err(|e| knl_err("events", &e))?
};
let truncated = selected.len() > knl::DEFAULT_LIMIT;
let selected: Vec<Value> = selected
.into_iter()
.take(knl::DEFAULT_LIMIT)
.map(|event| Value::Object(event.into_inner()))
.collect();
let rows = json_to_lua(&lua, Value::Array(selected))?;
Ok((rows, truncated))
});
methods.add_async_method("len", |_, this, ()| async move {
let n = this
.state
.lock()
.await
.len()
.await
.map_err(|e| knl_err("len", &e))?;
Ok(n as u64)
});
methods.add_async_method(
"view",
|lua, this, (name, opts): (LuaValue, LuaValue)| async move {
let types::ViewName(name) = from_lua("view", "name", name)?;
let opts = view_opts(from_lua("view", "opts", opts)?);
let value = {
let mut state = this.state.lock().await;
state
.view(&name, opts.as_ref())
.await
.map_err(|e| knl_err("view", &e))?
};
json_to_lua(&lua, value)
},
);
methods.add_async_method(
"query",
|lua, this, (sql, params, opts): (LuaValue, LuaValue, LuaValue)| async move {
let types::Sql(sql) = from_lua("query", "sql", sql)?;
let params = query_params(from_lua("query", "params", params)?);
let opts = query_opts(from_lua("query", "opts", opts)?);
let found = {
let state = this.state.lock().await;
state
.query(&sql, params, &opts)
.await
.map_err(|e| knl_err("query", &e))?
};
let rows: Vec<Value> = found.rows.into_iter().map(Value::Object).collect();
let rows = json_to_lua(&lua, Value::Array(rows))?;
Ok((rows, found.truncated))
},
);
methods.add_async_method("reserve", |_, this, amount: LuaValue| async move {
let types::Amount(amount) = from_lua("reserve", "amount", amount)?;
let mut state = this.state.lock().await;
let granted = state
.reserve(amount)
.await
.map_err(|e| knl_err("reserve", &e))?;
let tag = if granted {
None
} else {
state.grant().and_then(|grant| grant.tag.clone())
};
Ok((granted, tag))
});
methods.add_async_method("spend", |_, this, amount: LuaValue| async move {
let types::Amount(amount) = from_lua("spend", "amount", amount)?;
this.state
.lock()
.await
.spend(amount)
.await
.map_err(|e| knl_err("spend", &e))
});
methods.add_async_method("remaining", |_, this, ()| async move {
this.state
.lock()
.await
.remaining()
.await
.map_err(|e| knl_err("remaining", &e))
});
methods.add_async_method("exhausted", |_, this, ()| async move {
this.state
.lock()
.await
.exhausted()
.await
.map_err(|e| knl_err("exhausted", &e))
});
methods.add_async_method(
"close",
|_, this, (reason, detail): (LuaValue, LuaValue)| async move {
let reason: Option<types::CloseReason> = from_lua("close", "reason", reason)?;
let reason = reason.map(|types::CloseReason(text)| text);
let detail: Option<types::CloseDetail> = from_lua("close", "detail", detail)?;
let detail = detail.map(|types::CloseDetail(text)| truncated(&text));
this.state
.lock()
.await
.close_with(reason.as_deref(), detail.as_deref())
.await
.map_err(|e| knl_err("close", &e))?;
Ok(())
},
);
methods.add_async_meta_method(
LuaMetaMethod::Close,
|_, this, error: LuaValue| async move {
let unwinding = !matches!(error, LuaValue::Nil);
let (reason, detail) = match error {
LuaValue::Nil => (knl::CLOSE_REASON_SCOPE_EXIT, None),
error => (knl::CLOSE_REASON_ERROR, Some(error_detail(&error))),
};
let mut state = this.state.lock().await;
if state.is_closed() {
return Ok(());
}
let outcome = state.close_with(Some(reason), detail.as_deref()).await;
match outcome {
Ok(()) => Ok(()),
Err(e) if unwinding => {
tracing::warn!(
session = %state.id(),
error = %e,
"knl: session_closed was not recorded; \
the block's own error is propagating instead"
);
Ok(())
}
Err(e) => Err(knl_err("close", &e)),
}
},
);
}
}
enum BudgetSource {
Grant(knl::BudgetGrant),
FromParent(knl::Allocation),
}
fn budget_source(
method: &str,
budget: Option<types::BudgetOpt>,
) -> LuaResult<Option<BudgetSource>> {
let Some(budget) = budget else {
return Ok(None);
};
let types::BudgetOpt {
amount,
tag,
desc,
from_parent,
} = budget;
if let Some(from_parent) = from_parent {
if amount.is_some() {
return Err(err(
method,
"budget names both amount and from_parent: an owner's grant and an allocation \
out of a parent's balance are different claims about where the quota came from",
));
}
if desc.is_some() {
return Err(err(
method,
"budget.desc belongs to an owner's grant; an allocation records the parent it \
came from instead",
));
}
if from_parent < 0 {
return Err(err(
method,
format!(
"budget.from_parent must be a non-negative whole number, got {from_parent}"
),
));
}
return Ok(Some(BudgetSource::FromParent(knl::Allocation {
amount: from_parent,
tag,
})));
}
let Some(amount) = amount else {
return Err(err(
method,
"budget.amount is required (non-negative whole number), or budget.from_parent to \
allocate out of a parent's balance",
));
};
if amount < 0 {
return Err(err(
method,
format!("budget.amount must be a non-negative whole number, got {amount}"),
));
}
Ok(Some(BudgetSource::Grant(knl::BudgetGrant {
amount,
tag,
desc,
})))
}
fn grant_only(
method: &str,
budget: Option<types::BudgetOpt>,
) -> LuaResult<Option<knl::BudgetGrant>> {
match budget_source(method, budget)? {
None => Ok(None),
Some(BudgetSource::Grant(grant)) => Ok(Some(grant)),
Some(BudgetSource::FromParent(_)) => Err(err(
method,
"budget.from_parent allocates from a parent's balance, which is what \
open{ parent = … } does; this call takes an owner's grant (amount)",
)),
}
}
fn owner_of(owner: Option<String>) -> LuaResult<String> {
let Some(owner) = owner else {
return Ok(knl::ANON.to_string());
};
if owner == knl::ANON || owner == knl::SYSTEM {
return Err(err("open", format!("owner {owner:?} is reserved")));
}
Ok(owner)
}
fn query_params(params: Option<types::QueryParams>) -> knl::QueryParams {
match params {
None => knl::QueryParams::None,
Some(types::QueryParams::Positional(values)) if values.is_empty() => knl::QueryParams::None,
Some(types::QueryParams::Positional(values)) => knl::QueryParams::Positional(values),
Some(types::QueryParams::Named(named)) if named.is_empty() => knl::QueryParams::None,
Some(types::QueryParams::Named(named)) => knl::QueryParams::Named(named),
}
}
fn query_opts(opts: Option<types::QueryOpts>) -> knl::QueryOpts {
let Some(opts) = opts else {
return knl::QueryOpts::default();
};
knl::QueryOpts {
sessions: opts.sessions,
timeout_ms: opts.timeout_ms.unwrap_or(knl::DEFAULT_TIMEOUT_MS),
limit: opts.limit.map_or(knl::DEFAULT_LIMIT, |n| n as usize),
}
}
fn view_opts(opts: Option<types::ViewOpts>) -> Option<Map<String, Value>> {
let opts = opts?;
let mut out = Map::new();
if let Some(n) = opts.n {
out.insert("n".to_string(), Value::from(n));
}
Some(out)
}
enum StoreTarget {
Mem,
Sqlite(String),
}
fn store_target(method: &str, spec: types::StoreSpec) -> LuaResult<StoreTarget> {
match spec {
types::StoreSpec::Named(name) if name == types::MEM_STORE => Ok(StoreTarget::Mem),
types::StoreSpec::Named(name) => Err(err(
method,
format!(r#"unknown store {name:?} (expected "mem" or {{ sqlite = <path> }})"#),
)),
types::StoreSpec::File(file) => Ok(StoreTarget::Sqlite(file.sqlite)),
}
}
fn without_parent(lua: &Lua, opts: &LuaValue) -> LuaResult<LuaValue> {
let LuaValue::Table(table) = opts else {
return Ok(opts.clone());
};
let rest = lua.create_table()?;
for pair in table.clone().pairs::<LuaValue, LuaValue>() {
let (key, value) = pair?;
if let LuaValue::String(name) = &key {
if name.to_str()? == "parent" {
continue;
}
}
rest.set(key, value)?;
}
Ok(LuaValue::Table(rest))
}
fn parse_parent(opts: &LuaValue) -> LuaResult<Option<LuaAnyUserData>> {
let LuaValue::Table(opts) = opts else {
return Ok(None);
};
match opts.get::<LuaValue>("parent")? {
LuaValue::Nil => Ok(None),
LuaValue::UserData(parent) => Ok(Some(parent)),
other => Err(err(
"open",
format!(
"parent must be a session (the userdata knl.open returns), got {}",
other.type_name()
),
)),
}
}
async fn open_sqlite(
owner: String,
grant: Option<knl::BudgetGrant>,
path: &std::path::Path,
drivers: &knl::IsleDrivers,
) -> LuaResult<Session> {
let stream = uuid::Uuid::new_v4().to_string();
let store = knl::SqliteEventStore::open(path, stream.clone(), drivers)
.await
.map_err(|e| knl_err("open", &e))?;
let mut state = knl::Session::open_on(owner, grant, Box::new(store))
.await
.map_err(|e| knl_err("open", &e))?;
state.adopt_id(stream);
Ok(Session::from_state(state))
}
async fn resume_on(
grant: Option<knl::BudgetGrant>,
store: knl::SqliteEventStore,
session_id: String,
) -> LuaResult<Session> {
let mut state = knl::Session::resume(None, Box::new(store))
.await
.map_err(|e| knl_err("resume", &e))?;
if state.owner() == knl::SYSTEM {
return Err(err(
"resume",
format!("stream owner {:?} is reserved", knl::SYSTEM),
));
}
if let Some(grant) = grant {
state
.grant_more(grant)
.await
.map_err(|e| knl_err("resume", &e))?;
}
state.adopt_id(session_id);
Ok(Session::from_state(state))
}
async fn open_child_store(
named: Option<StoreTarget>,
parent_db: &str,
stream: &str,
drivers: &knl::IsleDrivers,
) -> LuaResult<Box<dyn knl::EventStore>> {
let store = match named {
None => knl::SqliteEventStore::open(std::path::Path::new(parent_db), stream, drivers).await,
Some(StoreTarget::Sqlite(path)) => {
knl::SqliteEventStore::open(std::path::Path::new(&path), stream, drivers).await
}
Some(StoreTarget::Mem) => knl::SqliteEventStore::open_memory(stream, drivers).await,
};
Ok(Box::new(store.map_err(|e| knl_err("open", &e))?))
}
async fn open_child_session(
lua: Lua,
parent: LuaAnyUserData,
owner: String,
allocation: knl::Allocation,
named_store: Option<StoreTarget>,
drivers: knl::IsleDrivers,
) -> LuaResult<LuaAnyUserData> {
let handle = parent.borrow::<Session>().map_err(|_| {
err(
"open",
"parent must be a session returned by knl.open / knl.resume",
)
})?;
let child = {
let mut state = handle.state.lock().await;
let parent_db = state
.database()
.ok_or_else(|| {
err(
"open",
"the parent's store keeps a single stream, so there is no database to open a \
child on",
)
})?
.to_string();
if knl::is_memory_database(&parent_db) {
return Err(err(
"open",
"a session tree needs a file store: the parent is on the in-memory database \
(store = \"mem\"), whose shared cache locks per table, and the kernel does not \
wait on that lock. Open the parent without a store (the host's database) or on \
{ sqlite = <path> }",
));
}
let stream = uuid::Uuid::new_v4().to_string();
let store = open_child_store(named_store, &parent_db, &stream, &drivers).await?;
state
.open_child(stream, owner, allocation, store)
.await
.map_err(|e| knl_err("open", &e))?
};
drop(handle);
lua.create_userdata(Session::from_state(child))
}
async fn open_session(
lua: Lua,
opts: LuaValue,
drivers: knl::IsleDrivers,
default_store: std::path::PathBuf,
) -> LuaResult<LuaAnyUserData> {
let parent = parse_parent(&opts)?;
let opts: types::OpenOpts = match opts {
LuaValue::Nil => types::OpenOpts::default(),
value => from_lua("open", "opts", without_parent(&lua, &value)?)?,
};
let owner = owner_of(opts.owner)?;
let budget = budget_source("open", opts.budget)?;
let named_store = opts
.store
.map(|spec| store_target("open", spec))
.transpose()?;
let Some(parent) = parent else {
let grant = match budget {
None => None,
Some(BudgetSource::Grant(grant)) => Some(grant),
Some(BudgetSource::FromParent(_)) => {
return Err(err(
"open",
"budget.from_parent allocates out of a parent's balance, so it needs \
opts.parent: the session to open this one from",
));
}
};
let session = match named_store {
None => open_sqlite(owner, grant, &default_store, &drivers).await?,
Some(StoreTarget::Mem) => Session::new(owner, grant, &drivers).await?,
Some(StoreTarget::Sqlite(path)) => {
open_sqlite(owner, grant, std::path::Path::new(&path), &drivers).await?
}
};
return lua.create_userdata(session);
};
let allocation = match budget {
Some(BudgetSource::FromParent(allocation)) => allocation,
_ => {
return Err(err(
"open",
"a child's quota comes out of its parent's: opts.parent needs \
budget = { from_parent = n, tag? }",
));
}
};
open_child_session(lua, parent, owner, allocation, named_store, drivers).await
}
async fn resume_session(
lua: Lua,
opts: LuaValue,
drivers: knl::IsleDrivers,
default_store: std::path::PathBuf,
) -> LuaResult<LuaAnyUserData> {
if matches!(opts, LuaValue::Nil) {
return Err(err("resume", "opts must be a table with store and session"));
}
let opts: types::ResumeOpts = from_lua("resume", "opts", opts)?;
let grant = grant_only("resume", opts.budget)?;
let store = opts
.store
.map(|spec| store_target("resume", spec))
.transpose()?;
let session_id = opts.session;
let store = match store {
None => knl::SqliteEventStore::open(&default_store, session_id.clone(), &drivers).await,
Some(StoreTarget::Sqlite(path)) => {
knl::SqliteEventStore::open(std::path::Path::new(&path), session_id.clone(), &drivers)
.await
}
Some(StoreTarget::Mem) => {
knl::SqliteEventStore::open_memory(session_id.clone(), &drivers).await
}
}
.map_err(|e| knl_err("resume", &e))?;
let state = resume_on(grant, store, session_id).await?;
lua.create_userdata(state)
}
fn error_table(lua: &Lua, raised: LuaValue) -> LuaResult<LuaTable> {
let text = match &raised {
LuaValue::String(text) => text.to_str()?.to_string(),
other => other.to_string()?,
};
let mut read = types::ErrorTable {
kind: None,
method: None,
retryable: false,
message: text.clone(),
};
let attributed = text
.lines()
.find_map(|line| line.split_once("knl: ").map(|(_, rest)| rest));
if let Some((method, rest)) = attributed.and_then(|rest| rest.split_once(": ")) {
if let Some((kind, message)) = rest.split_once(": ") {
if knl::KnlError::KINDS.contains(&kind) {
read.method = Some(method.to_string());
read.kind = Some(kind.to_string());
read.retryable = knl::KnlError::kind_is_retryable(kind);
read.message = message.to_string();
}
}
}
let out = as_table(lua, "error", &read)?;
let meta = lua.create_table()?;
meta.set(
"__tostring",
lua.create_function(move |_, _: LuaValue| Ok(text.clone()))?,
)?;
out.set_metatable(Some(meta))?;
Ok(out)
}
fn new_beat_id(_: &Lua, _: ()) -> LuaResult<String> {
Ok(uuid::Uuid::now_v7().to_string())
}
fn as_table<T: serde::Serialize>(lua: &Lua, method: &str, value: &T) -> LuaResult<LuaTable> {
match lua.to_value(value).map_err(|e| err(method, e))? {
LuaValue::Table(table) => Ok(table),
other => Err(err(
method,
format!(
"the answer did not serialize as a table, got {}",
other.type_name()
),
)),
}
}
fn api(lua: &Lua, _: ()) -> LuaResult<LuaTable> {
let report = match API_REPORT.get() {
Some(report) => report,
None => {
let built = build_api_report()?;
API_REPORT.get_or_init(|| built)
}
};
as_table(lua, "api", report)
}
static API_REPORT: std::sync::OnceLock<types::ApiReport> = std::sync::OnceLock::new();
#[cfg(test)]
static API_BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
static TYPES_BUILDS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn build_api_report() -> LuaResult<types::ApiReport> {
#[cfg(test)]
API_BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
fn listed(entries: &[(&str, &str)]) -> Vec<types::ApiEntry> {
entries
.iter()
.map(|(name, doc)| types::ApiEntry {
name: (*name).to_string(),
doc: (*doc).to_string(),
})
.collect()
}
let schema = types::ApiSchema {
table: knl::EVENTS_TABLE.to_string(),
columns: knl::events_schema()
.map_err(|e| knl_err("api", &e))?
.into_iter()
.map(|column| types::ApiColumn {
name: column.name,
declared_type: column.declared_type,
pk: column.pk,
})
.collect(),
};
let fields = types::ApiFields {
amount: knl::FIELD_AMOUNT.to_string(),
tag: knl::FIELD_TAG.to_string(),
desc: knl::FIELD_DESC.to_string(),
remaining: knl::FIELD_REMAINING.to_string(),
scope_id: knl::FIELD_SCOPE_ID.to_string(),
owner: knl::FIELD_OWNER.to_string(),
parent: knl::FIELD_PARENT.to_string(),
child: knl::FIELD_CHILD.to_string(),
reason: knl::FIELD_REASON.to_string(),
detail: knl::FIELD_DETAIL.to_string(),
open_children: knl::FIELD_OPEN_CHILDREN.to_string(),
};
Ok(types::ApiReport {
session: listed(SESSION_API),
module: listed(MODULE_API),
errors: knl::KnlError::KINDS.iter().map(|k| k.to_string()).collect(),
schema,
fields,
types: lshape_module_source(),
})
}
pub fn register(
lua: &Lua,
drivers: knl::IsleDrivers,
default_store: std::path::PathBuf,
) -> LuaResult<()> {
let knl_tbl = lua.create_table()?;
{
let drivers = drivers.clone();
let default_store = default_store.clone();
knl_tbl.set(
"open",
lua.create_async_function(move |lua, opts: LuaValue| {
let drivers = drivers.clone();
let default_store = default_store.clone();
open_session(lua, opts, drivers, default_store)
})?,
)?;
}
knl_tbl.set(
"resume",
lua.create_async_function(move |lua, opts: LuaValue| {
let drivers = drivers.clone();
let default_store = default_store.clone();
resume_session(lua, opts, drivers, default_store)
})?,
)?;
knl_tbl.set("new_beat_id", lua.create_function(new_beat_id)?)?;
knl_tbl.set("error", lua.create_function(error_table)?)?;
knl_tbl.set("api", lua.create_function(api)?)?;
lua.globals().set("knl", knl_tbl)?;
Ok(())
}
#[cfg(test)]
mod generated_types {
use super::types::*;
use super::*;
use serde_json::json;
const LSHAPE_PARTS: [(&str, &str); 4] = [
("lshape.t", include_str!("../../blocks/lib/lshape/t.lua")),
(
"lshape.reflect",
include_str!("../../blocks/lib/lshape/reflect.lua"),
),
(
"lshape.check",
include_str!("../../blocks/lib/lshape/check.lua"),
),
(
"lshape.luacats",
include_str!("../../blocks/lib/lshape/luacats.lua"),
),
];
const LSHAPE_ROOT: &str = include_str!("../../blocks/lib/lshape/init.lua");
fn types_vm() -> (Lua, LuaTable, LuaFunction) {
let lua = Lua::new();
let package: LuaTable = lua.globals().get("package").expect("package");
let loaded: LuaTable = package.get("loaded").expect("package.loaded");
for (name, source) in LSHAPE_PARTS {
let module: LuaValue = lua
.load(source)
.set_name(name)
.eval()
.unwrap_or_else(|e| panic!("{name}: {e}"));
loaded.set(name, module).expect("preload");
}
let root: LuaValue = lua
.load(LSHAPE_ROOT)
.set_name("lshape")
.eval()
.expect("lshape");
loaded.set("lshape", root.clone()).expect("preload lshape");
let module: LuaTable = lua
.load(lshape_module_source())
.set_name("knl_types")
.eval()
.expect("the generated module must load under the vendored lshape");
let check: LuaFunction = lua
.load(r#"return require("lshape").check.check"#)
.eval()
.expect("lshape.check.check");
(lua, module, check)
}
fn samples(lua: &Lua) -> Vec<(&'static str, LuaValue)> {
fn to(lua: &Lua, value: impl serde::Serialize) -> LuaValue {
lua.to_value(&value).expect("a declared type serializes")
}
vec![
("SessionId", to(lua, SessionId("s-1".into()))),
("ScopeId", to(lua, ScopeId("scope-1".into()))),
("Owner", to(lua, Owner("user-42".into()))),
("BeatId", to(lua, BeatId("beat-1".into()))),
("Seq", to(lua, Seq(7))),
("Count", to(lua, Count(3))),
("Amount", to(lua, Amount(10))),
("Remaining", to(lua, Remaining(Some(90)))),
("Exhausted", to(lua, Exhausted(false))),
("Sql", to(lua, Sql("SELECT 1".into()))),
("CloseReason", to(lua, CloseReason("done".into()))),
(
"CloseDetail",
to(lua, CloseDetail("the block raised".into())),
),
("Raised", to(lua, json!({ "anything": [1, "at", true] }))),
(
"ViewName",
to(lua, ViewName(crate::knl::projection::VIEW_TAIL.into())),
),
("ViewOpts", to(lua, ViewOpts { n: Some(5) })),
(
"OpenOpts",
to(
lua,
OpenOpts {
owner: Some("user-42".into()),
budget: Some(BudgetOpt {
amount: Some(1000),
tag: Some("tokens".into()),
desc: Some("one nightly run".into()),
from_parent: None,
}),
store: Some(StoreSpec::File(SqliteStore {
sqlite: "/tmp/knl.db".into(),
})),
parent: None,
},
),
),
(
"ResumeOpts",
to(
lua,
ResumeOpts {
store: Some(StoreSpec::Named(MEM_STORE.into())),
session: "s-1".into(),
budget: Some(BudgetOpt {
from_parent: Some(25),
tag: Some("tokens".into()),
amount: None,
desc: None,
}),
},
),
),
(
"AppendEvent",
to(
lua,
AppendEvent {
kind: "msg_user".into(),
beat: Some("beat-1".into()),
meta: Some(Meta::from([
("label".to_string(), MetaValue::Text("seed".into())),
("n".to_string(), MetaValue::Number(1.0)),
("on".to_string(), MetaValue::Flag(true)),
])),
data: Some(Json(json!({ "content": "hi" }))),
},
),
),
(
"EventsResult",
to(
lua,
EventsResult(
EventRows(vec![EventRow {
kind: "msg_user".into(),
seq: 2,
epoch_ms: 1_700_000_000_000,
_schema_version: 1,
beat: None,
meta: None,
data: Json(json!({ "content": "hi" })),
}]),
false,
),
),
),
(
"QueryParams",
to(lua, QueryParams::Positional(vec![json!("note")])),
),
(
"QueryOpts",
to(
lua,
QueryOpts {
sessions: Some(vec!["s-1".into(), "s-2".into()]),
timeout_ms: Some(250),
limit: Some(10),
},
),
),
(
"QueryResult",
to(
lua,
QueryResult(vec![Json(json!({ "kind": "msg_user" }))], true),
),
),
(
"ErrorTable",
to(
lua,
ErrorTable {
kind: Some("closed".into()),
method: Some("append".into()),
retryable: false,
message: "the session is closed".into(),
},
),
),
(
"ApiReport",
to(
lua,
ApiReport {
session: vec![ApiEntry {
name: "append".into(),
doc: "append(event) -> seq".into(),
}],
module: vec![ApiEntry {
name: "open".into(),
doc: "open(opts?) -> session".into(),
}],
errors: vec!["busy".into()],
schema: ApiSchema {
table: "events".into(),
columns: vec![ApiColumn {
name: "seq".into(),
declared_type: "INTEGER".into(),
pk: true,
}],
},
fields: ApiFields {
amount: "amount".into(),
tag: "tag".into(),
desc: "desc".into(),
remaining: "remaining".into(),
scope_id: "scope_id".into(),
owner: "owner".into(),
parent: "parent".into(),
child: "child".into(),
reason: "reason".into(),
detail: "detail".into(),
open_children: "open_children".into(),
},
types: "-- generated".into(),
},
),
),
]
}
#[test]
fn the_generated_module_exports_exactly_the_declared_types() {
let (_lua, module, _check) = types_vm();
let mut exported: Vec<String> = module
.pairs::<String, LuaValue>()
.map(|pair| pair.expect("a module entry").0)
.collect();
exported.sort();
let mut expected: Vec<String> = declared()
.into_iter()
.map(|(name, _, _)| name.to_string())
.collect();
expected.sort();
assert_eq!(exported, expected, "the generated module drifted");
}
#[test]
fn every_declared_type_accepts_a_value_built_from_its_rust_type() {
let (lua, module, check) = types_vm();
let samples = samples(&lua);
let mut sampled: Vec<&str> = samples.iter().map(|(name, _)| *name).collect();
sampled.sort_unstable();
let mut expected: Vec<&str> = declared().into_iter().map(|(name, _, _)| name).collect();
expected.sort_unstable();
assert_eq!(
sampled, expected,
"every declared type needs a sample built from it"
);
for (name, value) in samples {
let shape: LuaValue = module.get(name).expect("the shape of a declared type");
let (ok, why): (bool, Option<String>) = check
.call((value, shape))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(ok, "{name}: {}", why.unwrap_or_default());
}
}
#[test]
fn the_generated_shapes_refuse_what_the_rust_types_refuse() {
let (lua, module, check) = types_vm();
let refused: [(&str, LuaValue); 3] = [
(
"QueryOpts",
lua.to_value(&json!({ "rows": 10 })).expect("value"),
),
(
"ViewOpts",
lua.to_value(&json!({ "count": 2 })).expect("value"),
),
(
"AppendEvent",
lua.to_value(&json!({ "kind": "note", "meta": { "deep": { "no": 1 } } }))
.expect("value"),
),
];
for (name, value) in refused {
let shape: LuaValue = module.get(name).expect("the shape of a declared type");
let (ok, _why): (bool, Option<String>) = check
.call((value, shape))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(!ok, "{name} accepted a value its Rust type refuses");
}
}
#[test]
fn the_api_publishes_the_module_it_generated() {
let lua = Lua::new();
let dir = tempfile::tempdir().expect("tempdir");
register(&lua, knl::IsleDrivers::new(), dir.path().join("knl.sqlite"))
.expect("register knl");
let published: String = lua
.load(r#"return knl.api().types"#)
.eval()
.expect("knl.api().types");
assert_eq!(published, lshape_module_source());
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURES: &str = r#"
-- The recorded kinds in order, as one comparable string.
function kinds_of(s)
local names = {}
for _, e in ipairs(s:events()) do
table.insert(names, e.kind)
end
return table.concat(names, ",")
end
-- The classified failure of a call that is supposed to fail, plus
-- the raised value itself for the tests that check how it reads.
function failure(fn, ...)
local ok, raised = pcall(fn, ...)
assert(not ok, "the call was supposed to fail")
return knl.error(raised), raised
end
"#;
struct Vm {
lua: Lua,
drivers: knl::IsleDrivers,
rt: tokio::runtime::Runtime,
dir: tempfile::TempDir,
}
impl Vm {
fn new() -> Self {
let lua = Lua::new();
let drivers = knl::IsleDrivers::new();
let dir = tempfile::tempdir().expect("tempdir");
register(&lua, drivers.clone(), dir.path().join("knl.sqlite")).expect("register knl");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime for the VM to yield into");
rt.block_on(async { lua.load(FIXTURES).exec_async().await })
.expect("fixtures");
Self {
lua,
drivers,
rt,
dir,
}
}
fn default_store(&self) -> std::path::PathBuf {
self.dir.path().join("knl.sqlite")
}
fn exec(&self, chunk: &str) -> LuaResult<()> {
self.rt
.block_on(async { self.lua.load(chunk).exec_async().await })
}
fn eval<R: mlua::FromLuaMulti>(&self, chunk: &str) -> LuaResult<R> {
self.rt
.block_on(async { self.lua.load(chunk).eval_async::<R>().await })
}
fn expect_err(&self, chunk: &str) -> String {
self.exec(chunk)
.expect_err("chunk was expected to fail")
.to_string()
}
fn block_on<F: std::future::Future>(&self, f: F) -> F::Output {
self.rt.block_on(f)
}
fn finish(self) {
drop(self.finish_keeping_the_store());
}
fn finish_keeping_the_store(self) -> tempfile::TempDir {
let Self {
lua,
drivers,
rt,
dir,
} = self;
drop(lua);
let failures = rt.block_on(drivers.shutdown());
assert!(
failures.is_empty(),
"the connection threads did not shut down cleanly: {failures:?}"
);
dir
}
}
fn vm() -> Vm {
Vm::new()
}
#[test]
fn append_assigns_monotonic_seq_and_len_tracks() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
assert(s:len() == 1, "a fresh session holds session_opened")
local a = s:append({ kind = "user_msg", data = { text = "hi" } })
local b = s:append({ kind = "note", data = { name = "sh" } })
assert(a == 2, "first caller seq: " .. tostring(a))
assert(b == 3, "second caller seq: " .. tostring(b))
assert(s:len() == 3, "len: " .. tostring(s:len()))
local evs = s:events()
assert(#evs == 3, "events len: " .. tostring(#evs))
assert(evs[1].kind == "session_opened")
assert(evs[2].kind == "user_msg")
assert(evs[2].data.text == "hi")
assert(evs[2].seq == 2)
assert(type(evs[2].epoch_ms) == "number", "epoch_ms must be a number")
assert(evs[3].kind == "note")
assert(evs[3].seq == 3)
-- The envelope is closed: a kind's own field at the top level is
-- refused, with the place it belongs in the message.
local err = failure(function() s:append({ kind = "note", text = "hi" }) end)
assert(err.kind == "validation", "kind: " .. tostring(err.kind))
assert(err.message:find("under data"), "message: " .. err.message)
"#,
)
.expect("happy path chunk");
}
#[test]
fn session_exposes_no_mutation_api() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
s:append({ kind = "user_msg" })
for _, name in ipairs({ "update", "delete", "replace", "set", "insert",
"remove", "clear", "truncate", "pop" }) do
local ok, v = pcall(function() return s[name] end)
assert(not ok or v == nil, "mutation API must not exist: " .. name)
end
"#,
)
.expect("mutation-surface chunk");
}
#[test]
fn events_returns_a_deep_copy() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
s:append({ kind = "user_msg", meta = { tag = "a" },
data = { text = "hi", blocks = { { type = "text" } } } })
local evs = s:events()
evs[2].kind = "TAMPERED"
evs[2].data.text = nil
evs[2].data.extra = "injected"
evs[2].meta.tag = "b"
evs[2].data.blocks[1].type = "tampered"
table.insert(evs, { kind = "ghost" })
local again = s:events()
assert(#again == 2, "history length changed: " .. tostring(#again))
assert(again[2].kind == "user_msg", "kind changed: " .. tostring(again[2].kind))
assert(again[2].data.text == "hi", "data changed")
assert(again[2].data.extra == nil, "field injected into history")
assert(again[2].meta.tag == "a", "meta changed")
assert(again[2].data.blocks[1].type == "text", "nested table changed")
"#,
)
.expect("deep copy chunk");
}
#[test]
fn kernel_owned_fields_override_caller_values() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
local seq = s:append({ kind = "user_msg", seq = 999, epoch_ms = 1 })
assert(seq == 2, "returned seq: " .. tostring(seq))
local e = s:events(2)[1]
assert(e.seq == 2, "stored seq: " .. tostring(e.seq))
assert(e.epoch_ms ~= 1, "epoch_ms must be kernel-assigned")
assert(e.author == nil, "there is no per-event author anymore")
"#,
)
.expect("kernel-owned field chunk");
}
#[test]
fn events_from_filters_by_seq() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
for i = 1, 3 do s:append({ kind = "e" .. i }) end
local tail = s:events(3)
assert(#tail == 2, "tail len: " .. tostring(#tail))
assert(tail[1].seq == 3 and tail[2].seq == 4)
assert(#s:events(5) == 0, "past-the-end filter must be empty")
assert(#s:events(0) == 4, "from=0 must return everything")
-- A read that reached the end of the stream says so.
local rows, truncated = s:events()
assert(#rows == 4 and truncated == false, "nothing was cut off")
"#,
)
.expect("events(from) chunk");
}
#[test]
fn events_stops_at_the_row_cap_and_says_it_cut() {
let vm = vm();
let chunk = format!(
r#"
local cap = {cap}
-- An in-memory database on purpose: this seeds a thousand events
-- and the default store is a file, whose every commit is an fsync.
local s = knl.open({{ store = "mem" }})
-- The opening is already an event, so this is one past the cap.
for i = 1, cap do s:append({{ kind = "e" .. i }}) end
assert(s:len() == cap + 1, "seeded: " .. tostring(s:len()))
local rows, truncated = s:events()
assert(#rows == cap, "the read is capped: " .. tostring(#rows))
assert(truncated == true, "and it says the cap cut the read short")
assert(rows[1].seq == 1 and rows[cap].seq == cap, "the page is the front of the log")
-- The rest is read by paging on `from`, and that page is whole.
local rest, more = s:events(cap + 1)
assert(#rest == 1, "the remainder: " .. tostring(#rest))
assert(more == false, "and nothing is left after it")
assert(rest[1].seq == cap + 1, "seq: " .. tostring(rest[1].seq))
"#,
cap = knl::DEFAULT_LIMIT,
);
vm.exec(&chunk).expect("events cap chunk");
}
#[test]
fn append_validates_event_shape_with_attributed_errors() {
let vm = vm();
let msg = vm.expect_err(r#"knl.open():append({ text = "no kind" })"#);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("missing field `kind`"), "{msg}");
let msg = vm.expect_err(r#"knl.open():append({ kind = 42 })"#);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("event.kind"), "{msg}");
assert!(msg.contains("expected a string"), "{msg}");
let msg = vm.expect_err(r#"knl.open():append("not a table")"#);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("event:"), "{msg}");
assert!(msg.contains("expected table"), "{msg}");
vm.exec(
r#"
local s = knl.open()
pcall(function() s:append({ text = "no kind" }) end)
assert(s:len() == 1, "rejected append was recorded")
assert(s:append({ kind = "ok" }) == 2, "seq must not be consumed by a failure")
"#,
)
.expect("rejected-append chunk");
}
#[test]
fn spend_rejects_negative_amounts() {
let vm = vm();
let msg = vm.expect_err(
r#"
local s = knl.open({ budget = { amount = 100, tag = "beats" } })
s:spend(-1)
"#,
);
assert!(msg.contains("knl: spend:"), "missing attribution: {msg}");
assert!(msg.contains("non-negative"), "{msg}");
vm.exec(
r#"
local s = knl.open({ budget = { amount = 100, tag = "beats" } })
pcall(function() s:spend(-1) end)
assert(s:remaining() == 100, "balance changed: " .. tostring(s:remaining()))
-- A negative spend is rejected even without a budget.
local ok = pcall(function() knl.open():spend(-1) end)
assert(not ok, "negative spend must be rejected without a budget too")
-- So is a non-numeric amount.
local ok2 = pcall(function() knl.open():spend("many") end)
assert(not ok2, "a non-numeric amount must be rejected")
"#,
)
.expect("negative-spend chunk");
}
#[test]
fn spend_is_monotonic_and_flips_exhausted() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ budget = { amount = 1000, tag = "beats" } })
assert(s:remaining() == 1000)
assert(s:exhausted() == false)
local prev = s:remaining()
for _, n in ipairs({ 120, 0, 300, 80 }) do
assert(s:spend(n) == nil, "spend answers with the write, not a number")
local r = s:remaining()
assert(r <= prev, "remaining rose: " .. tostring(prev) .. " -> " .. tostring(r))
prev = r
end
assert(s:remaining() == 500, "remaining: " .. tostring(s:remaining()))
assert(s:exhausted() == false)
-- Overspending floors at zero and never goes negative.
s:spend(9999)
assert(s:remaining() == 0, "floor: " .. tostring(s:remaining()))
assert(s:exhausted() == true, "exhausted must flip after overspending")
s:spend(1)
assert(s:remaining() == 0, "spending past zero stays at zero")
"#,
)
.expect("budget monotonicity chunk");
}
#[test]
fn session_without_budget_reports_nil() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
assert(s:remaining() == nil, "remaining must be nil without a budget")
assert(s:spend(50) == nil, "spend answers nothing")
assert(s:len() == 1, "a settlement without a budget records nothing")
assert(s:exhausted() == false, "no budget can never be exhausted")
-- An empty opts table behaves the same way.
local s2 = knl.open({})
assert(s2:remaining() == nil)
"#,
)
.expect("no-budget chunk");
}
#[test]
fn session_validates_budget_options() {
let vm = vm();
let msg = vm.expect_err(r#"knl.open({ budget = { amount = -1 } })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("budget.amount"), "{msg}");
let msg = vm.expect_err(r#"knl.open({ budget = {} })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("required"), "{msg}");
let msg = vm.expect_err(r#"knl.open({ budget = { tokens = 100 } })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("unknown field `tokens`"), "{msg}");
assert!(msg.contains("`amount`"), "{msg}");
let msg = vm.expect_err(r#"knl.open({ budget = { amount = 10, tag = 7 } })"#);
assert!(msg.contains("opts.budget.tag"), "{msg}");
assert!(msg.contains("expected a string"), "{msg}");
let msg = vm.expect_err(r#"knl.open({ budget = { amount = 1.5 } })"#);
assert!(msg.contains("opts.budget.amount"), "{msg}");
vm.exec(
r#"
local s = knl.open({ budget = { amount = 42, tag = "tokens",
desc = "one nightly run" } })
assert(s:remaining() == 42, "remaining: " .. tostring(s:remaining()))
local granted = s:events()[2]
assert(granted.kind == "budget_granted", "kind: " .. tostring(granted.kind))
assert(granted.data.amount == 42 and granted.data.tag == "tokens")
assert(granted.data.desc == "one nightly run",
"desc: " .. tostring(granted.data.desc))
local bare = knl.open({ budget = { amount = 7 } })
local g2 = bare:events()[2].data
assert(g2.amount == 7 and g2.tag == nil and g2.desc == nil,
"a grant with no words must invent none")
"#,
)
.expect("grant options chunk");
let msg = vm.expect_err(r#"knl.open({ budget = 100 })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("opts.budget"), "{msg}");
assert!(msg.contains("expected table"), "{msg}");
let msg = vm.expect_err(r#"knl.open("nope")"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("opts:"), "{msg}");
assert!(msg.contains("expected table"), "{msg}");
}
#[test]
fn two_sessions_are_independent() {
let vm = vm();
vm.exec(
r#"
local a = knl.open({ budget = { amount = 100, tag = "beats" } })
local b = knl.open({ budget = { amount = 100, tag = "beats" } })
assert(type(a:id()) == "string" and #a:id() > 0, "id must be a non-empty string")
assert(a:id() ~= b:id(), "session ids must be unique")
a:append({ kind = "only_in_a" })
a:spend(60)
-- a: session_opened, budget_granted, only_in_a, budget_spent.
-- b: session_opened, budget_granted.
assert(a:len() == 4 and b:len() == 2, "history leaked between sessions")
assert(#b:events(3) == 0, "b holds only its own opening")
assert(a:remaining() == 40 and b:remaining() == 100, "budget leaked between sessions")
-- Closing one leaves the other usable.
a:close()
assert(b:append({ kind = "still_open" }) == 3)
"#,
)
.expect("session independence chunk");
}
#[test]
fn closed_session_rejects_append_and_spend() {
let vm = vm();
let msg = vm.expect_err(
r#"
local s = knl.open()
s:close()
s:append({ kind = "after_close" })
"#,
);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("session is closed"), "{msg}");
let msg = vm.expect_err(
r#"
local s = knl.open({ budget = { amount = 10, tag = "beats" } })
s:close()
s:spend(1)
"#,
);
assert!(msg.contains("knl: spend:"), "missing attribution: {msg}");
assert!(msg.contains("session is closed"), "{msg}");
let msg = vm.expect_err(
r#"
local s = knl.open({ budget = { amount = 10, tag = "beats" } })
s:close()
s:reserve(1)
"#,
);
assert!(msg.contains("knl: reserve:"), "missing attribution: {msg}");
assert!(msg.contains("session is closed"), "{msg}");
vm.exec(
r#"
local s = knl.open({ budget = { amount = 10, tag = "beats" } })
s:append({ kind = "before_close" })
s:spend(4)
s:close()
s:close() -- idempotent
-- Reads still work after the session ends.
assert(s:len() == 5,
"session_opened + budget_granted + before_close + budget_spent + session_closed")
assert(s:events()[3].kind == "before_close")
assert(s:remaining() == 6)
assert(s:exhausted() == false)
assert(type(s:id()) == "string")
"#,
)
.expect("closed-session read chunk");
}
#[test]
fn state_lives_in_the_userdata_not_in_globals() {
let vm_a = vm();
vm_a.exec(
r#"
local s = knl.open()
s:append({ kind = "in_vm_a" })
assert(s:len() == 2)
-- `knl` itself carries no session state.
assert(knl.events == nil and knl.append == nil and knl.spend == nil)
"#,
)
.expect("vm a chunk");
let vm_b = vm();
vm_b.exec(
r#"
local s = knl.open()
assert(s:len() == 1, "a second VM starts with only its own session_opened")
assert(s:events()[1].kind == "session_opened")
"#,
)
.expect("vm b chunk");
}
#[test]
fn session_boundaries_are_recorded_by_the_kernel() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
local opened = s:events()[1]
assert(opened.kind == "session_opened", "kind: " .. tostring(opened.kind))
assert(opened.seq == 1)
s:close("budget_exhausted")
local evs = s:events()
assert(#evs == 2, "close must record session_closed")
assert(evs[2].kind == "session_closed")
assert(evs[2].data.reason == "budget_exhausted")
s:close("ignored")
assert(s:len() == 2, "close is idempotent")
-- Without a reason the kernel records its default.
local d = knl.open()
d:close()
assert(d:events()[2].data.reason == "closed", "default reason")
"#,
)
.expect("session boundary chunk");
let msg = vm.expect_err(r#"knl.open():close({ not_a = "string" })"#);
assert!(msg.contains("knl: close:"), "missing attribution: {msg}");
assert!(msg.contains("reason:"), "{msg}");
assert!(msg.contains("expected a string"), "{msg}");
}
#[test]
fn the_envelope_is_validated_and_a_kinds_own_data_is_not() {
let vm = vm();
let msg = vm.expect_err(r#"knl.open():append({ kind = "msg_user", content = "hi" })"#);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("content"), "{msg}");
assert!(msg.contains("under data"), "{msg}");
let msg =
vm.expect_err(r#"knl.open():append({ kind = "note", meta = { deep = { a = 1 } } })"#);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("meta is shallow"), "{msg}");
let msg = vm.expect_err(r#"knl.open():append({ kind = "note", data = 7 })"#);
assert!(msg.contains("data must be a table"), "{msg}");
vm.exec(
r#"
local s = knl.open()
pcall(function() s:append({ kind = "note", text = "hi" }) end)
assert(s:len() == 1, "a rejected event was recorded")
-- The kinds of a turn are the Lua kernel's, shape and all: the
-- Rust side takes whatever `data` says, at any depth.
local beat = knl.new_beat_id()
s:append({ kind = "msg_user", data = { content = "hi" } })
s:append({ kind = "tool_call", beat = beat,
data = { call_id = "c1", name = "sh", args = { cmd = "ls" } } })
s:append({ kind = "tool_result", beat = beat,
data = { call_id = "c1", ok = false, result = "boom" } })
-- …including an empty one.
s:append({ kind = "tool_call" })
assert(s:len() == 5)
assert(s:events()[3].beat == beat, "the declared beat is recorded")
assert(s:events()[5].beat == nil, "an undeclared beat stays absent")
assert(s:events()[3].data.args.cmd == "ls", "data comes back at any depth")
assert(next(s:events()[5].data) == nil, "an absent data reads as empty")
-- meta takes scalars, and comes back as it was written.
s:append({ kind = "note", meta = { label = "a", attempt = 2, retried = true } })
local m = s:events()[6].meta
assert(m.label == "a" and m.attempt == 2 and m.retried == true,
"meta round-trips")
-- A numbered beat is refused, on any kind.
local ok = pcall(function() s:append({ kind = "note", beat = 1 }) end)
assert(not ok, "a numeric beat was accepted")
"#,
)
.expect("envelope chunk");
}
#[test]
fn lua_cannot_append_the_budget_kinds_by_hand() {
let vm = vm();
let msg = vm.expect_err(
r#"
local s = knl.open({ budget = { amount = 10, tag = "beats" } })
s:append({ kind = "budget_reserved", data = { amount = 5 } })
"#,
);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("kernel only"), "{msg}");
assert!(msg.contains("budget_reserved"), "{msg}");
vm.exec(
r#"
local s = knl.open({ budget = { amount = 10, tag = "beats" } })
for _, ev in ipairs({
{ kind = "budget_granted", data = { amount = 1000000 } },
{ kind = "budget_reserved", data = { amount = 5 } },
{ kind = "budget_refused", data = { amount = 5, remaining = 0 } },
{ kind = "budget_spent", data = { amount = 5 } },
}) do
local ok = pcall(function() s:append(ev) end)
assert(not ok, "a caller wrote " .. ev.kind)
end
assert(s:len() == 2, "a rejected budget event was recorded: " .. tostring(s:len()))
assert(s:remaining() == 10, "a forged event moved the balance")
-- Reading them is fine: the kernel's own writes are in the log
-- like everything else.
s:reserve(4)
local evs = s:events()
assert(evs[3].kind == "budget_reserved" and evs[3].data.amount == 4,
"the kernel's own reservation is readable")
"#,
)
.expect("kernel-only kind chunk");
}
#[test]
fn reserve_grants_refuses_and_records_both() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ budget = { amount = 100, tag = "beats" } })
local ok, tag = s:reserve(30)
assert(ok == true, "a covered reservation must be granted")
assert(tag == nil, "a granted reservation names no budget")
assert(s:remaining() == 70, "remaining: " .. tostring(s:remaining()))
local ok2, tag2 = s:reserve(1000)
assert(ok2 == false, "an uncovered reservation must be refused")
assert(tag2 == "beats", "a refusal must name the budget: " .. tostring(tag2))
assert(s:remaining() == 70, "a refusal must not deduct")
assert(s:exhausted() == false, "a refusal must not exhaust")
-- Both answers are in the log, with what was asked for.
local evs = s:events()
assert(evs[3].kind == "budget_reserved" and evs[3].data.amount == 30)
assert(evs[3].data.tag == "beats")
assert(evs[4].kind == "budget_refused" and evs[4].data.amount == 1000)
assert(evs[4].data.remaining == 70, "the refusal records what there was")
-- Exactly the balance is coverable, and zero always is.
assert(s:reserve(70) == true)
assert(s:remaining() == 0 and s:exhausted() == true)
assert(s:reserve(0) == true, "zero fits even at zero")
assert(s:reserve(1) == false, "nothing fits past zero")
"#,
)
.expect("reserve chunk");
vm.exec(
r#"
local s = knl.open()
local ok, tag = s:reserve(999999)
assert(ok == true and tag == nil, "no budget must grant everything")
assert(s:len() == 1, "a session with no quota recorded a ledger event")
"#,
)
.expect("no-budget reserve chunk");
let msg = vm.expect_err(r#"knl.open({ budget = { amount = 10 } }):reserve(-1)"#);
assert!(msg.contains("knl: reserve:"), "missing attribution: {msg}");
assert!(msg.contains("non-negative"), "{msg}");
let msg = vm.expect_err(r#"knl.open():reserve("many")"#);
assert!(msg.contains("knl: reserve:"), "missing attribution: {msg}");
}
#[test]
fn the_balance_lua_reads_is_the_fold_of_the_ledger() {
let vm = vm();
vm.exec(
r#"
local function folded(s)
local balance = nil
for _, ev in ipairs(s:events()) do
if ev.kind == "budget_granted" then
balance = (balance or 0) + ev.data.amount
elseif ev.kind == "budget_reserved" or ev.kind == "budget_spent" then
balance = math.max(0, balance - ev.data.amount)
end
end
return balance
end
local s = knl.open({ budget = { amount = 500, tag = "beats" } })
assert(folded(s) == s:remaining())
s:reserve(120)
s:append({ kind = "llm_response",
data = { content = { { type = "text", text = "hi" } },
usage = { input_tokens = 100, output_tokens = 50 } } })
s:spend(30) -- the call overran its estimate
s:reserve(10000) -- refused, and moves nothing
s:spend(0)
assert(s:remaining() == 350, "remaining: " .. tostring(s:remaining()))
assert(folded(s) == s:remaining(), "the fold and the counter disagree")
-- What the call consumed is the other, independent reading, and
-- it is in the log rather than in the ledger: the counts sit on
-- the response, for a query view to sum.
local r = s:events()[4] -- opened, granted, reserved, the response
assert(r.kind == "llm_response", "kind: " .. tostring(r.kind))
assert(r.data.usage.input_tokens == 100 and r.data.usage.output_tokens == 50)
"#,
)
.expect("fold chunk");
}
#[test]
fn lua_cannot_append_the_session_boundary_kinds_by_hand() {
let vm = vm();
let msg = vm.expect_err(
r#"
local s = knl.open()
s:append({ kind = "session_closed", data = { reason = "carried over" } })
"#,
);
assert!(msg.contains("knl: append:"), "missing attribution: {msg}");
assert!(msg.contains("kernel only"), "{msg}");
assert!(msg.contains("session_closed"), "{msg}");
vm.exec(
r#"
local s = knl.open({ budget = { amount = 100, tag = "beats" } })
for _, ev in ipairs({
{ kind = "session_opened", data = { scope_id = "s", owner = "me" } },
{ kind = "session_closed", data = { reason = "carried over" } },
}) do
local ok = pcall(function() s:append(ev) end)
assert(not ok, "a caller wrote " .. ev.kind)
end
-- Still open, and nothing was recorded.
assert(s:len() == 2, "a rejected boundary was recorded: " .. tostring(s:len()))
assert(s:append({ kind = "note" }) == 3, "the refusal ended the session")
s:spend(10)
assert(s:remaining() == 90)
-- Only close writes the boundary, and it writes exactly one.
s:close("done")
assert(kinds_of(s) ==
"session_opened,budget_granted,note,budget_spent,session_closed",
"recorded: " .. kinds_of(s))
local evs = s:events()
assert(evs[5].data.reason == "done",
"reason: " .. tostring(evs[5].data.reason))
local ok = pcall(function() s:append({ kind = "note" }) end)
assert(not ok, "a closed session took a write")
"#,
)
.expect("session boundary kind chunk");
}
#[test]
fn new_beat_id_mints_distinct_time_ordered_ids() {
let vm = vm();
vm.exec(
r#"
local a = knl.new_beat_id()
local b = knl.new_beat_id()
assert(type(a) == "string" and #a > 0, "a beat id must be a non-empty string")
assert(a ~= b, "two beats must be two ids")
assert(a < b, "beat ids must sort in the order they were minted: " .. a .. " " .. b)
-- Version 7: the 13th hex digit of a UUID is the version nibble.
assert(a:sub(15, 15) == "7", "not a v7 uuid: " .. a)
-- It is a module function, not a session method: no session is
-- needed to name a beat.
local s = knl.open()
assert(s.new_beat_id == nil, "the beat id is not the session's to mint")
-- And it is what the kernel accepts as a beat.
s:append({ kind = "llm_response", beat = a,
data = { content = { { type = "text", text = "ok" } },
usage = { input_tokens = 1 } } })
assert(s:events()[2].beat == a, "the minted beat is recorded verbatim")
"#,
)
.expect("new_beat_id chunk");
}
#[test]
fn view_tail_returns_the_last_events() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
for i = 1, 5 do s:append({ kind = "e" .. i }) end
local t = s:view("tail", { n = 2 })
assert(#t == 2, "tail len: " .. tostring(#t))
assert(t[1].kind == "e4" and t[2].kind == "e5")
assert(t[2].seq == 6, "tail keeps the envelope")
assert(#s:view("tail", { n = 99 }) == 6, "n larger than the history")
assert(#s:view("tail", { n = 0 }) == 0)
assert(#s:view("tail") == 6, "n defaults to 20")
"#,
)
.expect("tail view chunk");
let msg = vm.expect_err(r#"knl.open():view("tail", { n = -1 })"#);
assert!(msg.contains("knl: view:"), "missing attribution: {msg}");
assert!(msg.contains("opts.n"), "{msg}");
let msg = vm.expect_err(r#"knl.open():view("tail", { count = 2 })"#);
assert!(msg.contains("knl: view:"), "missing attribution: {msg}");
assert!(msg.contains("unknown field `count`"), "{msg}");
}
#[test]
fn view_rejects_unknown_names_and_bad_arguments() {
let vm = vm();
let msg = vm.expect_err(r#"knl.open():view("dialog")"#);
assert!(msg.contains("knl: view:"), "missing attribution: {msg}");
assert!(msg.contains(r#"unknown view "dialog""#), "{msg}");
let msg = vm.expect_err(r#"knl.open():view("usage")"#);
assert!(msg.contains("knl: view:"), "missing attribution: {msg}");
assert!(msg.contains(r#"unknown view "usage""#), "{msg}");
let msg = vm.expect_err(r#"knl.open():view(42)"#);
assert!(msg.contains("knl: view:"), "missing attribution: {msg}");
assert!(msg.contains("name:"), "{msg}");
assert!(msg.contains("expected a string"), "{msg}");
let msg = vm.expect_err(r#"knl.open():view("tail", "n=2")"#);
assert!(msg.contains("knl: view:"), "missing attribution: {msg}");
assert!(msg.contains("opts:"), "{msg}");
assert!(msg.contains("expected table"), "{msg}");
}
#[test]
fn view_returns_a_fresh_table_each_call() {
let vm = vm();
vm.exec(
r#"
local s = knl.open()
s:append({ kind = "msg_user", data = { content = "hi" } })
local t = s:view("tail", { n = 1 })
t[1].kind = "TAMPERED"
t[1].data.content = nil
table.insert(t, { kind = "ghost" })
local again = s:view("tail", { n = 1 })
assert(#again == 1, "tail length changed: " .. tostring(#again))
assert(again[1].kind == "msg_user", "kind changed: " .. tostring(again[1].kind))
assert(again[1].data.content == "hi", "content changed")
-- …and the history itself is untouched by any of it.
assert(s:len() == 2, "len: " .. tostring(s:len()))
assert(s:events()[2].kind == "msg_user", "the record was reachable")
"#,
)
.expect("view copy chunk");
}
#[test]
fn store_mem_is_asked_for_by_name_and_unknown_stores_are_rejected() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ store = "mem", owner = "x", budget = { amount = 10, tag = "beats" } })
assert(s:len() == 2, "a mem session opens like any other: session_opened + the grant")
assert(s:owner() == "x")
assert(s:append({ kind = "note" }) == 3)
"#,
)
.expect("mem store chunk");
let msg = vm.expect_err(r#"knl.open({ store = "postgres" })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("unknown store"), "{msg}");
let msg = vm.expect_err(r#"knl.open({ store = { redis = "x" } })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("opts.store"), "{msg}");
assert!(msg.contains("sqlite"), "{msg}");
}
#[test]
fn the_parent_is_the_only_value_read_as_a_handle() {
let vm = vm();
vm.exec(
r#"
local p = knl.open({ owner = "p", budget = { amount = 10, tag = "beats" } })
local c = knl.open({ owner = "c", parent = p, budget = { from_parent = 4 } })
assert(c:remaining() == 4, "the child's balance: " .. tostring(c:remaining()))
assert(p:remaining() == 6, "the parent paid: " .. tostring(p:remaining()))
"#,
)
.expect("parent chunk");
let msg = vm.expect_err(r#"knl.open({ owner = function() end })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("opts.owner"), "{msg}");
let msg = vm.expect_err(r#"knl.open({ parent = "s-1", budget = { from_parent = 1 } })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("must be a session"), "{msg}");
}
#[test]
fn open_rejects_reserved_owner_ids_from_the_caller() {
let vm = vm();
let msg = vm.expect_err(r#"knl.open({ owner = "system" })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("reserved"), "{msg}");
assert!(msg.contains("system"), "{msg}");
let msg = vm.expect_err(r#"knl.open({ owner = "anon" })"#);
assert!(msg.contains("knl: open:"), "missing attribution: {msg}");
assert!(msg.contains("reserved"), "{msg}");
assert!(msg.contains("anon"), "{msg}");
vm.exec(
r#"
-- Unspecified owner is the kernel-assigned reserved anon.
assert(knl.open():owner() == "anon", "default owner must be anon")
assert(knl.open({}):owner() == "anon", "empty opts default owner must be anon")
-- A real principal id is accepted verbatim.
assert(knl.open({ owner = "alice" }):owner() == "alice", "owner not carried")
"#,
)
.expect("reserved-owner chunk");
}
#[test]
fn a_session_reports_its_scope_id_and_records_it_on_the_log() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "alice", budget = { amount = 100, tag = "beats" } })
local scope = s:scope_id()
assert(type(scope) == "string" and #scope > 0, "scope_id must be a non-empty string")
assert(scope ~= s:id(), "the scope names the authority, the id names the stream")
s:reserve(30) -- budget_reserved
s:spend(10) -- budget_spent
s:reserve(10000) -- budget_refused
local seen = 0
for _, e in ipairs(s:events()) do
if e.kind == "session_opened" then
assert(e.data.scope_id == scope,
"session_opened scope_id: " .. tostring(e.data.scope_id))
assert(e.data.owner == "alice", "the owner rides beside it")
seen = seen + 1
elseif e.kind:sub(1, 7) == "budget_" then
assert(e.data.scope_id == scope,
e.kind .. " scope_id: " .. tostring(e.data.scope_id))
seen = seen + 1
end
end
assert(seen == 5,
"session_opened + granted + reserved + spent + refused: " .. tostring(seen))
-- A caller's own event carries no scope id: the field is on the
-- kinds only the kernel writes.
s:append({ kind = "note" })
local evs = s:events()
assert(evs[#evs].data.scope_id == nil,
"a caller's event must not carry a scope id")
assert(knl.open({ owner = "bob" }):scope_id() ~= scope,
"two runs must be two scopes")
"#,
)
.expect("scope id chunk");
}
#[test]
fn a_resumed_session_keeps_the_scope_id_the_log_recorded() {
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path = path.to_str().expect("utf-8 path");
vm.exec(&format!(
r#"
local path = "{path}"
local s = knl.open({{ store = {{ sqlite = path }}, owner = "scoped-user",
budget = {{ amount = 100, tag = "beats" }} }})
local id, scope = s:id(), s:scope_id()
assert(scope ~= id, "the scope id is not the stream id")
s:reserve(20)
-- Resumed while the stream is still open: a session is disposable,
-- so a closed one is never reopened.
local r = knl.resume({{ store = {{ sqlite = path }}, session = id }})
assert(r:id() == id, "resumed id is the stream it reopened")
assert(r:scope_id() == scope, "resumed scope: " .. tostring(r:scope_id()))
assert(r:owner() == "scoped-user", "resumed owner: " .. tostring(r:owner()))
r:reserve(5)
local evs = r:events()
local last = evs[#evs]
assert(last.kind == "budget_reserved", "last kind: " .. tostring(last.kind))
assert(last.data.scope_id == scope,
"continued scope_id: " .. tostring(last.data.scope_id))
"#
))
.expect("durable scope chunk");
}
#[test]
fn open_and_resume_a_durable_sqlite_session() {
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path = path.to_str().expect("utf-8 path");
vm.exec(&format!(
r#"
local path = "{path}"
-- The responses in a stream, for the counts a query view sums.
local function responses(s)
local out = {{}}
for _, ev in ipairs(s:events()) do
if ev.kind == "llm_response" then out[#out + 1] = ev end
end
return out
end
local s = knl.open({{ store = {{ sqlite = path }}, owner = "durable-user",
budget = {{ amount = 100, tag = "beats" }} }})
s:reserve(30)
s:append({{ kind = "llm_response",
data = {{ content = {{ {{ type = "text", text = "a" }} }},
usage = {{ input_tokens = 30 }} }} }})
s:append({{ kind = "msg_user", data = {{ content = "more" }} }})
s:reserve(15)
s:append({{ kind = "llm_response",
data = {{ content = {{ {{ type = "text", text = "b" }} }},
usage = {{ input_tokens = 20 }} }} }})
s:spend(5) -- the second call overran its estimate
assert(s:remaining() == 50, "open remaining: " .. tostring(s:remaining()))
local id = s:id()
-- Reopen the same stream and continue where it left off. No new
-- grant: the balance is what the ledger says was left. The stream
-- is still open: a session is disposable, so a closed one is not
-- reopened.
local r = knl.resume({{ store = {{ sqlite = path }}, session = id }})
assert(r:owner() == "durable-user", "resumed owner: " .. tostring(r:owner()))
assert(r:remaining() == 50, "resumed remaining: " .. tostring(r:remaining()))
assert(r:id() == id, "resumed id is the stream it reopened")
-- The record came back whole: the counts are on the responses,
-- where a query view reads them.
local rs = responses(r)
assert(#rs == 2, "resumed responses: " .. tostring(#rs))
assert(rs[1].data.usage.input_tokens == 30
and rs[2].data.usage.input_tokens == 20,
"the counts came back with the record")
-- The grant's words came back too: a refusal still names it.
local ok, tag = r:reserve(1000)
assert(ok == false and tag == "beats", "refused tag: " .. tostring(tag))
-- The record and the ledger continue on the resumed session.
r:reserve(5)
r:append({{ kind = "llm_response", beat = knl.new_beat_id(),
data = {{ content = {{ {{ type = "text", text = "c" }} }},
usage = {{ input_tokens = 5 }} }} }})
assert(#responses(r) == 3, "continued responses: " .. tostring(#responses(r)))
assert(r:remaining() == 45, "continued remaining: " .. tostring(r:remaining()))
-- Granting again on resume adds to what is left, and is recorded.
local g = knl.resume({{ store = {{ sqlite = path }}, session = id,
budget = {{ amount = 100, tag = "beats",
desc = "a second grant" }} }})
assert(g:remaining() == 145, "re-granted remaining: " .. tostring(g:remaining()))
local evs = g:events()
local last = evs[#evs]
assert(last.kind == "budget_granted", "last event: " .. tostring(last.kind))
assert(last.data.amount == 100 and last.data.desc == "a second grant")
"#
))
.expect("durable open/resume chunk");
}
#[test]
fn resume_rejects_a_reserved_system_owned_stream() {
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let stream = "system-stream".to_string();
let drivers = vm.drivers.clone();
vm.block_on(async {
let store = crate::knl::SqliteEventStore::open(&path, stream.clone(), &drivers)
.await
.expect("open store");
let state =
crate::knl::Session::open_on(crate::knl::SYSTEM.to_string(), None, Box::new(store))
.await
.expect("open system session");
drop(state);
});
let msg = vm.expect_err(&format!(
r#"knl.resume({{ store = {{ sqlite = "{path_str}" }}, session = "{stream}" }})"#
));
assert!(
msg.contains("reserved"),
"must name the reserved owner: {msg}"
);
}
#[test]
fn resume_requires_a_session_id_and_a_stream_that_holds_a_session() {
let vm = vm();
let msg = vm.expect_err(r#"knl.resume()"#);
assert!(msg.contains("knl: resume:"), "missing attribution: {msg}");
let msg = vm.expect_err(r#"knl.resume({ store = { sqlite = "/tmp/x.db" } })"#);
assert!(msg.contains("knl: resume:"), "missing attribution: {msg}");
assert!(msg.contains("missing field `session`"), "{msg}");
let msg = vm.expect_err(r#"knl.resume({ session = "never-opened" })"#);
assert!(msg.contains("knl: resume:"), "missing attribution: {msg}");
assert!(msg.contains("no session to resume"), "{msg}");
}
#[test]
fn an_in_memory_stream_is_resumable_while_it_is_open() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ store = "mem", owner = "mem-user",
budget = { amount = 100, tag = "beats" } })
local id = s:id()
s:reserve(30)
s:append({ kind = "note", data = { text = "in memory" } })
-- The writer is still alive, so the database is still there.
local r = knl.resume({ store = "mem", session = id })
assert(r:id() == id, "resumed id: " .. tostring(r:id()))
assert(r:owner() == "mem-user", "resumed owner: " .. tostring(r:owner()))
assert(r:remaining() == 70, "resumed remaining: " .. tostring(r:remaining()))
assert(r:len() == 4, "session_opened + granted + reserved + note")
-- And the resumed handle writes into the same log.
r:spend(20)
assert(s:remaining() == 50, "the writer sees it: " .. tostring(s:remaining()))
-- An absent store means the same thing on resume as it does on
-- open — the host's database — so it does not find this stream:
-- nothing about a `mem` session is in that file.
local missed = failure(knl.resume, { session = id })
assert(missed.kind == "validation", missed.kind)
assert(missed.message:find("no session to resume", 1, true), missed.message)
"#,
)
.expect("in-memory resume chunk");
}
#[test]
fn a_session_with_no_store_lands_in_the_hosts_database() {
let vm = vm();
let store = vm.default_store();
assert!(
!store.exists(),
"the file is SQLite's to create, on the first session that needs it"
);
let (first, second): (String, String) = vm
.eval(
r#"
local a = knl.open({ owner = "u" })
local b = knl.open({ owner = "u" })
a:append({ kind = "note", data = { text = "a" } })
b:append({ kind = "note", data = { text = "b" } })
a:close("done")
b:close("done")
return a:id(), b:id()
"#,
)
.expect("two default sessions");
assert_ne!(first, second, "two opens are two streams");
let _dir = vm.finish_keeping_the_store();
assert!(
store.exists(),
"the default store was never created: {}",
store.display()
);
for stream in [&first, &second] {
let log = persisted(&store, stream);
let kinds: Vec<&str> = log
.iter()
.map(|e| e["kind"].as_str().expect("a kind"))
.collect();
assert_eq!(
kinds,
["session_opened", "note", "session_closed"],
"stream {stream}"
);
}
}
#[test]
fn a_default_session_resumes_by_id_alone() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "u", budget = { amount = 100, tag = "beats" } })
local id = s:id()
s:reserve(30)
s:append({ kind = "note", data = { text = "recorded" } })
local r = knl.resume({ session = id })
assert(r:id() == id, "resumed id: " .. tostring(r:id()))
assert(r:owner() == "u", "resumed owner: " .. tostring(r:owner()))
assert(r:remaining() == 70, "the ledger came back: " .. tostring(r:remaining()))
assert(r:len() == 4, "session_opened + granted + reserved + note")
"#,
)
.expect("resume by id alone");
vm.finish();
}
#[test]
fn a_child_of_a_mem_parent_is_refused() {
let vm = vm();
vm.exec(
r#"
local parent = knl.open({ store = "mem", owner = "u",
budget = { amount = 100, tag = "tokens" } })
local refused = failure(knl.open, {
owner = "w", parent = parent, budget = { from_parent = 10 },
})
assert(refused.kind == "validation", refused.kind)
assert(refused.message:find("a session tree needs a file store", 1, true),
refused.message)
assert(refused.message:find("mem", 1, true), refused.message)
-- Nothing was opened and nothing moved.
assert(parent:remaining() == 100, tostring(parent:remaining()))
-- The same parent on the host's database takes a child.
local ok_parent = knl.open({ owner = "u", budget = { amount = 100, tag = "tokens" } })
local child = knl.open({ owner = "w", parent = ok_parent,
budget = { from_parent = 10 } })
assert(ok_parent:remaining() == 90, tostring(ok_parent:remaining()))
child:close("done")
ok_parent:close("done")
parent:close("done")
"#,
)
.expect("the refusal");
vm.finish();
}
fn persisted(path: &std::path::Path, stream: &str) -> Vec<Value> {
use crate::knl::EventStore;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime to read on");
rt.block_on(async {
let drivers = knl::IsleDrivers::new();
let store = crate::knl::SqliteEventStore::open(path, stream, &drivers)
.await
.expect("reopen the stream");
let log = store.read(0, usize::MAX).await.expect("read the stream");
drop(store);
assert!(drivers.shutdown().await.is_empty(), "the reader joined");
log
})
}
fn stream_id_from(chunk: String) -> String {
let vm = vm();
let id = vm.eval::<String>(&chunk).expect("close scope chunk");
vm.finish();
id
}
#[test]
fn a_close_scope_records_the_boundary_on_the_way_out() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let id = stream_id_from(format!(
r#"
local id
do
local s <close> = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "t" }})
id = s:id()
s:append({{ kind = "note" }})
assert(s:len() == 2, "inside the scope: session_opened + note")
end
return id
"#
));
let log = persisted(&path, &id);
let last = log.last().expect("the stream is not empty");
assert_eq!(last["kind"], Value::from("session_closed"), "{last}");
assert_eq!(last["data"]["reason"], Value::from("scope_exit"), "{last}");
assert_eq!(
last["data"].get("detail"),
None,
"a clean exit has nothing to say"
);
assert_eq!(log.len(), 3, "session_opened + note + session_closed");
}
#[test]
fn a_close_scope_that_raises_records_the_error_and_its_message() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let id = stream_id_from(format!(
r#"
local id
local ok, msg = pcall(function()
local s <close> = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "t" }})
id = s:id()
error("boom")
end)
assert(not ok, "the block was supposed to fail")
assert(tostring(msg):find("boom"), "the error is still the caller's: " .. tostring(msg))
return id
"#
));
let log = persisted(&path, &id);
let last = log.last().expect("the stream is not empty");
assert_eq!(last["kind"], Value::from("session_closed"), "{last}");
assert_eq!(last["data"]["reason"], Value::from("error"), "{last}");
let detail = last["data"]["detail"].as_str().expect("detail text");
assert!(detail.contains("boom"), "detail: {detail}");
}
type SharedLog = std::sync::Arc<Mutex<knl::MemEventStore>>;
struct FlakyStore {
inner: SharedLog,
fails_on: usize,
attempts: std::sync::atomic::AtomicUsize,
}
impl FlakyStore {
fn new(fails_on: usize) -> (Self, SharedLog) {
let inner: SharedLog = std::sync::Arc::default();
let store = Self {
inner: std::sync::Arc::clone(&inner),
fails_on,
attempts: std::sync::atomic::AtomicUsize::new(0),
};
(store, inner)
}
fn fails_now(&self) -> bool {
let attempt = self
.attempts
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
attempt == self.fails_on
}
async fn log(&self) -> tokio::sync::MutexGuard<'_, knl::MemEventStore> {
self.inner.lock().await
}
}
#[async_trait::async_trait]
impl knl::EventStore for FlakyStore {
async fn append(&mut self, event: Map<String, Value>) -> knl::KnlResult<knl::Committed> {
if self.fails_now() {
return Err(knl::KnlError::Storage("the store is down".to_string()));
}
self.log().await.append(event).await
}
async fn append_many(
&mut self,
events: Vec<Map<String, Value>>,
) -> knl::KnlResult<Vec<knl::Committed>> {
if self.fails_now() {
return Err(knl::KnlError::Storage("the store is down".to_string()));
}
let mut log = self.log().await;
let mut committed = Vec::with_capacity(events.len());
for event in events {
committed.push(log.append(event).await?);
}
Ok(committed)
}
async fn append_if(
&mut self,
kinds: Option<&[&str]>,
decide: knl::Decision,
) -> knl::KnlResult<Option<knl::Committed>> {
if self.fails_now() {
return Err(knl::KnlError::Storage("the store is down".to_string()));
}
self.log().await.append_if(kinds, decide).await
}
async fn read_kinds(
&self,
kinds: Option<&[&str]>,
from_seq: u64,
limit: usize,
) -> knl::KnlResult<Vec<Value>> {
self.log().await.read_kinds(kinds, from_seq, limit).await
}
async fn head(&self) -> knl::KnlResult<Option<u64>> {
self.log().await.head().await
}
async fn len(&self) -> knl::KnlResult<usize> {
self.log().await.len().await
}
}
fn kinds_in(log: &SharedLog) -> Vec<String> {
use crate::knl::EventStore;
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.expect("a runtime to read on");
rt.block_on(async { log.lock().await.read(0, usize::MAX).await })
.expect("read the log")
.iter()
.map(|e| e["kind"].as_str().unwrap_or("").to_string())
.collect()
}
fn vm_with_a_failing_store(fails_on: usize) -> (Vm, SharedLog) {
let vm = vm();
let (store, log) = FlakyStore::new(fails_on);
let store = std::sync::Arc::new(Mutex::new(Some(store)));
let open_failing =
vm.lua
.create_async_function(move |lua, ()| {
let store = std::sync::Arc::clone(&store);
async move {
let store = store.lock().await.take().ok_or_else(|| {
err("open", "the failing store can only be opened once")
})?;
let state = knl::Session::open_on("t".to_string(), None, Box::new(store))
.await
.map_err(|e| knl_err("open", &e))?;
lua.create_userdata(Session::from_state(state))
}
})
.expect("create open_failing");
vm.lua
.globals()
.set("open_failing", open_failing)
.expect("register open_failing");
(vm, log)
}
#[test]
fn a_failed_close_does_not_replace_the_error_the_block_raised() {
let (vm, log) = vm_with_a_failing_store(2);
vm.exec(
r#"
local kept
local ok, msg = pcall(function()
local s <close> = open_failing()
kept = s
error("boom")
end)
assert(not ok, "the block was supposed to fail")
assert(tostring(msg):find("boom"),
"the close replaced the block's error: " .. tostring(msg))
assert(not tostring(msg):find("the store is down"),
"the close's own failure surfaced instead: " .. tostring(msg))
-- The session stayed open: the boundary was not recorded, and the
-- handle says so rather than pretending otherwise.
assert(kept:len() == 1, "len after the failed close: " .. tostring(kept:len()))
"#,
)
.expect("failing close chunk");
assert_eq!(
kinds_in(&log),
["session_opened"],
"the boundary really was not recorded"
);
}
#[test]
fn a_failed_close_on_a_clean_scope_exit_still_raises() {
let (vm, log) = vm_with_a_failing_store(2);
let msg = vm.expect_err(
r#"
do
local s <close> = open_failing()
end
"#,
);
assert!(msg.contains("knl: close:"), "missing attribution: {msg}");
assert!(msg.contains("the store is down"), "{msg}");
assert_eq!(kinds_in(&log), ["session_opened"]);
}
#[tokio::test]
async fn an_open_that_cannot_be_recorded_leaves_the_stream_empty() {
use crate::knl::EventStore;
let (store, log) = FlakyStore::new(1);
let err = knl::Session::open_on(
"t".to_string(),
Some(knl::BudgetGrant::new(100)),
Box::new(store),
)
.await
.expect_err("the open must fail");
assert_eq!(err.reason(), "the store is down");
let recorded = log.lock().await.read(0, usize::MAX).await.expect("read");
assert!(
recorded.is_empty(),
"a failed open records nothing at all: {recorded:?}"
);
}
#[tokio::test]
async fn an_open_records_its_boundary_and_its_grant_together() {
use crate::knl::{event::kind_of, EventStore};
let (store, log) = FlakyStore::new(0);
let session = knl::Session::open_on(
"t".to_string(),
Some(knl::BudgetGrant::new(100)),
Box::new(store),
)
.await
.expect("the open lands");
let recorded = log.lock().await.read(0, usize::MAX).await.expect("read");
let kinds: Vec<&str> = recorded.iter().map(kind_of).collect();
assert_eq!(kinds, ["session_opened", "budget_granted"]);
assert_eq!(session.remaining().await, Ok(Some(100)));
}
#[test]
fn close_records_an_optional_detail_beside_the_reason() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let id = stream_id_from(format!(
r#"
local id
do
local s = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "t" }})
id = s:id()
s:close("error", "the body raised: boom")
end
return id
"#
));
let log = persisted(&path, &id);
let last = log.last().expect("the stream is not empty");
assert_eq!(last["kind"], Value::from("session_closed"), "{last}");
assert_eq!(last["data"]["reason"], Value::from("error"), "{last}");
assert_eq!(
last["data"]["detail"],
Value::from("the body raised: boom"),
"{last}"
);
let vm = vm();
vm.exec(
r#"
local a = knl.open({ owner = "t" })
a:close("done")
local last = a:events()[a:len()].data
assert(last.reason == "done", "reason: " .. tostring(last.reason))
assert(last.detail == nil, "a close with no detail must record none")
local b = knl.open({ owner = "t" })
b:close()
local closed = b:events()[b:len()].data
assert(closed.reason == "closed", "default reason: " .. tostring(closed.reason))
assert(closed.detail == nil)
"#,
)
.expect("close forms chunk");
let msg = vm.expect_err(r#"knl.open({ owner = "t" }):close("error", 7)"#);
assert!(msg.contains("knl: close:"), "missing attribution: {msg}");
assert!(msg.contains("detail:"), "{msg}");
assert!(msg.contains("expected a string"), "{msg}");
}
#[test]
fn a_long_close_detail_is_truncated() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "t" })
s:close("error", string.rep("x", 500))
local last = s:events()[s:len()].data
assert(#last.detail == 203, "detail length: " .. tostring(#last.detail))
assert(last.detail:sub(-3) == "...", "a cut detail says it was cut")
"#,
)
.expect("long detail chunk");
}
#[test]
fn resume_refuses_a_closed_stream() {
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let msg = vm.expect_err(&format!(
r#"
local s = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "t" }})
local id = s:id()
s:close("done")
knl.resume({{ store = {{ sqlite = "{path_str}" }}, session = id }})
"#
));
assert!(msg.contains("knl: resume:"), "missing attribution: {msg}");
assert!(msg.contains("session is closed"), "{msg}");
assert!(msg.contains("disposable"), "{msg}");
}
#[test]
fn a_refused_resume_records_no_grant() {
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let stream = "system-grant-stream".to_string();
let drivers = vm.drivers.clone();
vm.block_on(async {
let store = crate::knl::SqliteEventStore::open(&path, stream.clone(), &drivers)
.await
.expect("open store");
let state =
crate::knl::Session::open_on(crate::knl::SYSTEM.to_string(), None, Box::new(store))
.await
.expect("open system session");
drop(state);
});
let before = persisted(&path, &stream).len();
let msg = vm.expect_err(&format!(
r#"knl.resume({{ store = {{ sqlite = "{path_str}" }}, session = "{stream}",
budget = {{ amount = 100, tag = "beats" }} }})"#
));
assert!(msg.contains("reserved"), "{msg}");
let log = persisted(&path, &stream);
assert!(
!log.iter().any(|e| e["kind"] == "budget_granted"),
"a refused resume wrote its grant anyway: {log:?}"
);
assert_eq!(log.len(), before, "a refused resume wrote nothing at all");
}
#[test]
fn the_lua_surface_is_exactly_what_is_declared() {
let vm = vm();
let mut declared: Vec<&str> = SESSION_API
.iter()
.map(|(name, _)| *name)
.filter(|name| *name != "__close")
.collect();
declared.sort_unstable();
let session: LuaAnyUserData = vm
.eval(r#"return knl.open({ owner = "t" })"#)
.expect("open a session to reflect over");
let meta = session.metatable().expect("the session's metatable");
let index: LuaTable = meta.get("__index").expect("the methods table");
let mut reflected: Vec<String> = index
.pairs::<String, LuaValue>()
.map(|pair| pair.expect("a method entry").0)
.collect();
reflected.sort();
assert_eq!(reflected, declared, "the session surface is SESSION_API");
assert!(
SESSION_API.iter().any(|(name, _)| *name == "__close"),
"the scope boundary belongs to the declared surface"
);
assert!(
!matches!(
meta.get::<LuaValue>("__close").expect("read __close"),
LuaValue::Nil
),
"a session must carry the <close> metamethod"
);
let mut module: Vec<&str> = MODULE_API.iter().map(|(name, _)| *name).collect();
module.sort_unstable();
let mut bound: Vec<String> = vm
.eval::<Vec<String>>(
r#"
local names = {}
for name, value in pairs(knl) do
if type(value) == "function" then table.insert(names, name) end
end
return names
"#,
)
.expect("reflect over the knl global");
bound.sort();
assert_eq!(bound, module, "the module surface is MODULE_API");
vm.exec(
r#"
local api = knl.api()
assert(#api.session > 0 and #api.module > 0, "api() must list both halves")
for _, half in ipairs({ api.session, api.module }) do
for _, entry in ipairs(half) do
assert(type(entry.name) == "string" and #entry.name > 0, "an entry needs a name")
assert(type(entry.doc) == "string" and #entry.doc > 0, "an entry needs a doc")
end
end
assert(api.session[1].name == "id", "first: " .. tostring(api.session[1].name))
"#,
)
.expect("api() chunk");
let counted: usize = vm
.eval(r#"local a = knl.api() return #a.session + #a.module"#)
.expect("count the api entries");
assert_eq!(counted, SESSION_API.len() + MODULE_API.len());
}
#[test]
fn a_raised_failure_reports_its_class_through_knl_error() {
let vm = vm();
vm.exec(
r#"
-- A closed handle refusing its own write. The session is over,
-- and asking again is not what fixes that.
local s = knl.open({ owner = "t" })
s:close()
local e = failure(function() s:append({ kind = "note" }) end)
assert(e.kind == "closed", "kind: " .. tostring(e.kind))
assert(e.method == "append", "method: " .. tostring(e.method))
assert(e.retryable == false, "a closed session is not a retry")
assert(e.message == "session is closed", "message: " .. tostring(e.message))
local t = knl.open({ owner = "t", budget = { amount = 10 } })
-- A kernel-only kind: the caller asked for something the kernel
-- will not record from it.
local k = failure(function() t:append({ kind = "budget_granted", amount = 1 }) end)
assert(k.kind == "validation", "kind: " .. tostring(k.kind))
assert(k.method == "append", "method: " .. tostring(k.method))
assert(k.retryable == false)
-- A negative reserve, refused before anything moves.
local n = failure(function() t:reserve(-1) end)
assert(n.kind == "validation", "kind: " .. tostring(n.kind))
assert(n.method == "reserve", "method: " .. tostring(n.method))
-- An unknown view: the kernel's own validator, same class.
local v = failure(function() t:view("nope") end)
assert(v.kind == "validation", "kind: " .. tostring(v.kind))
assert(v.method == "view", "method: " .. tostring(v.method))
-- A refusal raised on the bridge side, before the kernel is
-- reached, is the same class: one vocabulary either way.
local b = failure(function() t:append(7) end)
assert(b.kind == "validation", "kind: " .. tostring(b.kind))
assert(b.method == "append", "method: " .. tostring(b.method))
"#,
)
.expect("classified failures chunk");
}
#[test]
fn a_classified_failure_still_reads_as_a_message() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "t" })
s:close()
local e, raised = failure(function() s:append({ kind = "note" }) end)
local text = tostring(raised)
assert(text:find("knl: append:", 1, true), "attribution: " .. text)
assert(text:find("session is closed", 1, true), "reason: " .. text)
assert(tostring(e) == text, "the table must render as its message")
-- A raise that did not come from the kernel is reported whole
-- rather than raising a second failure inside the handler.
local other = knl.error("something else entirely")
assert(other.kind == nil, "kind: " .. tostring(other.kind))
assert(other.method == nil, "method: " .. tostring(other.method))
assert(other.retryable == false)
assert(other.message == "something else entirely",
"message: " .. tostring(other.message))
-- …including one that merely looks like the shape. Only a class
-- the kernel publishes is read as one.
local fake = knl.error("knl: append: nonsense: hello")
assert(fake.kind == nil, "kind: " .. tostring(fake.kind))
assert(fake.message == "knl: append: nonsense: hello")
"#,
)
.expect("message compatibility chunk");
}
#[test]
fn api_publishes_the_error_vocabulary() {
let vm = vm();
let published: Vec<String> = vm
.eval(r#"return knl.api().errors"#)
.expect("read knl.api().errors");
let declared: Vec<String> = knl::KnlError::KINDS
.iter()
.map(|kind| (*kind).to_string())
.collect();
assert_eq!(published, declared);
for (name, doc) in SESSION_API.iter().chain(MODULE_API.iter()) {
let Some((_, raises)) = doc.split_once("[raises: ") else {
continue;
};
let raises = raises.split(']').next().unwrap_or("");
for kind in raises.split(',').map(str::trim).filter(|k| !k.is_empty()) {
let kind = kind.split_whitespace().next().unwrap_or("");
assert!(
knl::KnlError::KINDS.contains(&kind),
"{name} names a class the kernel does not publish: {kind:?}"
);
}
}
}
#[test]
fn the_declared_surface_is_built_once_and_the_table_is_fresh() {
use std::sync::atomic::Ordering;
let vm = vm();
let first: Vec<String> = vm
.eval(r#"local a = knl.api() return { a.types, a.schema.table }"#)
.expect("the first api() call");
let (api_builds, type_builds) = (
API_BUILDS.load(Ordering::Relaxed),
TYPES_BUILDS.load(Ordering::Relaxed),
);
let second: Vec<String> = vm
.eval(r#"local a = knl.api() return { a.types, a.schema.table }"#)
.expect("the second api() call");
assert_eq!(first, second, "two calls answer the same surface");
assert_eq!(
API_BUILDS.load(Ordering::Relaxed),
api_builds,
"the report was rebuilt on the second call"
);
assert_eq!(
TYPES_BUILDS.load(Ordering::Relaxed),
type_builds,
"the types module was re-rendered on the second call"
);
assert!(
!first[0].is_empty() && first[1] == knl::EVENTS_TABLE,
"and the answer is the real one: {first:?}"
);
vm.exec(
r#"
local a = knl.api()
a.schema.table = "scribbled"
assert(knl.api().schema.table ~= "scribbled", "api() handed out a shared table")
"#,
)
.expect("api table freshness chunk");
}
#[test]
fn a_store_that_is_down_surfaces_as_storage() {
let (vm, _log) = vm_with_a_failing_store(2);
vm.exec(
r#"
local e = failure(function()
do local s <close> = open_failing() end
end)
assert(e.kind == "storage", "kind: " .. tostring(e.kind))
assert(e.method == "close", "method: " .. tostring(e.method))
assert(e.retryable == false, "a store that is down is not a retry")
assert(e.message == "the store is down", "message: " .. tostring(e.message))
"#,
)
.expect("failing store chunk");
}
#[test]
fn an_explicit_close_wins_over_the_scope_exit() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let id = stream_id_from(format!(
r#"
local id
do
local s <close> = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "t" }})
id = s:id()
s:close("done")
end
return id
"#
));
let log = persisted(&path, &id);
let finished: Vec<&Value> = log
.iter()
.filter(|e| e["kind"] == "session_closed")
.collect();
assert_eq!(finished.len(), 1, "exactly one boundary: {log:?}");
assert_eq!(finished[0]["data"]["reason"], Value::from("done"));
}
#[test]
fn a_collected_handle_records_the_boundary_as_dropped() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path");
let id = stream_id_from(format!(
r#"
-- Opened inside a function so the handle is unreachable the
-- moment it returns: nothing holds the userdata but the
-- collector.
local function run()
local s = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "t" }})
s:append({{ kind = "note" }})
return s:id()
end
local id = run()
collectgarbage("collect")
collectgarbage("collect")
return id
"#
));
let log = persisted(&path, &id);
let last = log.last().expect("the stream is not empty");
assert_eq!(
last["kind"],
Value::from("session_closed"),
"the collector left the session open: {log:?}"
);
assert_eq!(last["data"]["reason"], Value::from("dropped"), "{last}");
}
#[test]
fn query_reads_the_log_with_sql() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "q" })
s:append({ kind = "msg_user", beat = "b1", data = { content = "hi" } })
s:append({ kind = "note", meta = { label = "a" }, data = { text = "a note" } })
local rows, truncated = s:query(
"SELECT seq, kind FROM events WHERE stream = $stream ORDER BY seq")
assert(#rows == 3, "rows: " .. tostring(#rows))
assert(truncated == false, "nothing was cut off")
assert(rows[1].kind == "session_opened", "first: " .. tostring(rows[1].kind))
assert(rows[2].kind == "msg_user" and rows[2].seq == 2)
assert(rows[3].kind == "note")
-- A fold the kernel does not name is a query, not a view it had
-- to be taught.
local counted = s:query([[
SELECT kind, COUNT(*) AS n FROM events
WHERE stream = $stream GROUP BY kind ORDER BY kind]])
assert(#counted == 3, "kinds: " .. tostring(#counted))
-- The envelope is columns, so grouping a run by beat is a
-- GROUP BY rather than a json path…
local beats = s:query([[
SELECT beat, COUNT(*) AS n FROM events
WHERE stream = $stream AND beat IS NOT NULL GROUP BY beat]])
assert(#beats == 1 and beats[1].beat == "b1" and beats[1].n == 1,
"beat is a column of its own")
-- …while a kind's own shape is read out of `data`, and `meta`
-- can be read without knowing the kind at all.
local read = s:query([[
SELECT json_extract(data, '$.content') AS content,
json_extract(meta, '$.label') AS label
FROM events WHERE stream = $stream AND kind = 'msg_user']])
assert(read[1].content == "hi", "data path: " .. tostring(read[1].content))
assert(read[1].label == nil, "this one carried no meta")
-- Values are bound: positionally…
local one = s:query("SELECT kind FROM events WHERE kind = ?", { "note" })
assert(#one == 1 and one[1].kind == "note", "positional bind")
-- …and by name, with the prefix character left to SQLite.
local named = s:query("SELECT kind FROM events WHERE kind = :kind",
{ kind = "msg_user" })
assert(#named == 1 and named[1].kind == "msg_user", "named bind")
-- A quote in a value is a character, not the end of a string, and
-- a value that would be SQL if it were pasted in matches nothing.
s:append({ kind = "it's odd" })
local quoted = s:query("SELECT kind FROM events WHERE kind = ?", { "it's odd" })
assert(#quoted == 1, "a quote in a bound value: " .. tostring(#quoted))
local injected = s:query("SELECT kind FROM events WHERE kind = ?",
{ "x' OR 1=1 --" })
assert(#injected == 0, "a bound value is never SQL: " .. tostring(#injected))
-- The SQLite types come back as themselves, and a NULL column is
-- absent rather than present-and-null, so it reads as nil.
local typed = s:query(
"SELECT 1 AS whole, 1.5 AS fraction, 'text' AS words, NULL AS absent")
assert(typed[1].whole == 1 and typed[1].fraction == 1.5)
assert(typed[1].words == "text")
assert(typed[1].absent == nil, "a NULL column reads as nil")
-- Reads keep working after the handle closed.
s:close()
assert(#s:query("SELECT 1 AS one") == 1, "a closed handle still reads")
"#,
)
.expect("query chunk");
}
#[test]
fn query_reads_across_the_session_set() {
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path = path.to_str().expect("utf-8 path");
vm.exec(&format!(
r#"
local path = "{path}"
local a = knl.open({{ store = {{ sqlite = path }}, owner = "a" }})
local b = knl.open({{ store = {{ sqlite = path }}, owner = "b" }})
a:append({{ kind = "from_a" }})
b:append({{ kind = "from_b" }})
local sql = "SELECT stream, kind FROM events WHERE stream IN $sessions \
AND kind LIKE 'from_%' ORDER BY kind"
-- Both streams, one statement.
local both = a:query(sql, nil, {{ sessions = {{ a:id(), b:id() }} }})
assert(#both == 2, "both streams: " .. tostring(#both))
assert(both[1].kind == "from_a" and both[2].kind == "from_b")
-- Left out, the set is the asking session's own stream.
local mine = a:query(sql)
assert(#mine == 1 and mine[1].kind == "from_a", "own stream only")
-- An empty set is a mistake, not "all of them".
local e = failure(function() a:query(sql, nil, {{ sessions = {{}} }}) end)
assert(e.kind == "validation", "kind: " .. tostring(e.kind))
assert(e.method == "query", "method: " .. tostring(e.method))
"#
))
.expect("session set chunk");
}
#[test]
fn query_refuses_everything_that_is_not_one_read() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "q" })
s:append({ kind = "note" })
for _, sql in ipairs({
"INSERT INTO events (stream) VALUES ('x')",
"UPDATE events SET kind = 'x'",
"DELETE FROM events",
"DROP TABLE events",
"PRAGMA table_info(events)",
"ATTACH DATABASE '/tmp/other.db' AS other",
"SELECT 1; DROP TABLE events",
}) do
local e = failure(function() s:query(sql) end)
assert(e.kind == "validation", sql .. " -> " .. tostring(e.kind))
assert(e.method == "query", sql .. " -> " .. tostring(e.method))
end
-- The log is exactly as it was.
assert(s:len() == 2, "len after the refusals: " .. tostring(s:len()))
-- And the arguments are checked too: a misspelt option is an
-- error rather than a limit nobody applied.
local e = failure(function() s:query("SELECT 1", nil, { rows = 10 }) end)
assert(e.kind == "validation", "kind: " .. tostring(e.kind))
local m = failure(function() s:query(42) end)
assert(m.message:find("sql:", 1, true), m.message)
assert(m.message:find("expected a string", 1, true), m.message)
"#,
)
.expect("refusal chunk");
}
#[test]
fn query_caps_the_rows_and_says_when_it_cut() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "q" })
for i = 1, 5 do s:append({ kind = "e" .. i }) end
local rows, truncated = s:query(
"SELECT kind FROM events ORDER BY seq", nil, { limit = 2 })
assert(#rows == 2, "capped rows: " .. tostring(#rows))
assert(truncated == true, "the cap cut rows off")
local all, whole = s:query("SELECT kind FROM events ORDER BY seq", nil, { limit = 6 })
assert(#all == 6 and whole == false, "nothing was cut off")
"#,
)
.expect("limit chunk");
}
#[test]
fn query_that_runs_too_long_reports_a_timeout() {
let vm = vm();
vm.exec(
r#"
local s = knl.open({ owner = "q" })
local e = failure(function()
s:query([[WITH RECURSIVE forever(x) AS (
SELECT 1 UNION ALL SELECT x + 1 FROM forever)
SELECT COUNT(*) FROM forever]], nil, { timeout_ms = 50 })
end)
assert(e.kind == "timeout", "kind: " .. tostring(e.kind))
assert(e.method == "query", "method: " .. tostring(e.method))
assert(e.retryable == false, "a slow query is not a retry")
-- The session is fine afterwards: a statement ended, not the
-- reader.
assert(#s:query("SELECT 1 AS one") == 1)
"#,
)
.expect("timeout chunk");
}
#[test]
fn api_publishes_the_events_schema() {
let vm = vm();
vm.exec(
r#"
local schema = knl.api().schema
assert(schema.table == "events", "table: " .. tostring(schema.table))
local names, keyed = {}, {}
for _, column in ipairs(schema.columns) do
assert(type(column.name) == "string" and #column.name > 0)
assert(type(column.type) == "string" and #column.type > 0)
table.insert(names, column.name)
if column.pk then table.insert(keyed, column.name) end
end
assert(table.concat(names, ",")
== "stream,seq,epoch_ms,kind,schema_version,beat,meta,data",
"columns: " .. table.concat(names, ","))
assert(table.concat(keyed, ",") == "stream,seq",
"primary key: " .. table.concat(keyed, ","))
-- Every published column is one a query may actually name.
local s = knl.open({ owner = "q" })
local rows = s:query("SELECT " .. table.concat(names, ", ") ..
" FROM " .. schema.table .. " WHERE stream = $stream")
assert(#rows == 1, "the opening event: " .. tostring(#rows))
assert(rows[1].kind == "session_opened")
assert(rows[1].schema_version == 1, "the stored version is a column")
assert(rows[1].beat == nil, "an undeclared beat is NULL")
assert(type(rows[1].meta) == "string", "meta stays the stored text")
assert(type(rows[1].data) == "string", "and so does data")
"#,
)
.expect("schema chunk");
}
#[test]
fn a_slow_write_does_not_block_another_coroutine_on_the_same_vm() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
const HELD: Duration = Duration::from_millis(300);
const TICK: Duration = Duration::from_millis(5);
const AT_LEAST: usize = 5;
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path").to_string();
let ticks = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&ticks);
let tick = vm
.lua
.create_async_function(move |_, ()| {
let counter = Arc::clone(&counter);
async move {
tokio::time::sleep(TICK).await;
counter.fetch_add(1, Ordering::Relaxed);
Ok(())
}
})
.expect("create tick");
vm.lua.globals().set("tick", tick).expect("set tick");
let counter = Arc::clone(&ticks);
let read_ticks = vm
.lua
.create_function(move |_, ()| Ok(counter.load(Ordering::Relaxed)))
.expect("create ticks");
vm.lua
.globals()
.set("ticks", read_ticks)
.expect("set ticks");
vm.exec(&format!(
r#"session = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "t" }})"#
))
.expect("open the durable session");
let (locked_tx, locked_rx) = std::sync::mpsc::channel();
let blocker_path = path.clone();
let blocker = std::thread::spawn(move || {
let conn = rusqlite::Connection::open(&blocker_path).expect("open the blocker");
conn.busy_timeout(HELD).expect("busy timeout");
conn.execute_batch("BEGIN EXCLUSIVE")
.expect("take the write lock");
locked_tx.send(()).expect("announce the lock");
std::thread::sleep(HELD);
conn.execute_batch("ROLLBACK").expect("release the lock");
});
locked_rx.recv().expect("the lock was taken");
let during: usize = vm.block_on(async {
let writer = vm
.lua
.load(
r#"
local before = ticks()
session:append({ kind = "slow" })
return ticks() - before
"#,
)
.eval_async::<usize>();
let ticker = vm.lua.load(r#"for _ = 1, 200 do tick() end"#).exec_async();
let (written, _ticked) = tokio::join!(writer, ticker);
written.expect("the append eventually lands")
});
blocker.join().expect("the blocker thread");
assert!(
during >= AT_LEAST,
"the VM stopped while the write was waiting: only {during} tick(s) ran"
);
vm.exec(r#"assert(kinds_of(session) == "session_opened,slow", kinds_of(session))"#)
.expect("the slow append landed");
}
#[test]
fn an_identity_read_answers_while_another_coroutine_holds_the_session() {
use std::time::Duration;
const HELD: Duration = Duration::from_millis(300);
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("knl.db");
let path_str = path.to_str().expect("utf-8 path").to_string();
let pause = vm
.lua
.create_async_function(|_, ()| async move {
tokio::time::sleep(Duration::from_millis(20)).await;
Ok(())
})
.expect("create pause");
vm.lua.globals().set("pause", pause).expect("set pause");
vm.exec(&format!(
r#"
session = knl.open({{ store = {{ sqlite = "{path_str}" }}, owner = "u-7" }})
-- What the reads must still answer while the session is held.
expected_id, expected_scope, expected_owner =
session:id(), session:scope_id(), session:owner()
"#
))
.expect("open the durable session");
let (locked_tx, locked_rx) = std::sync::mpsc::channel();
let blocker_path = path.clone();
let blocker = std::thread::spawn(move || {
let conn = rusqlite::Connection::open(&blocker_path).expect("open the blocker");
conn.busy_timeout(HELD).expect("busy timeout");
conn.execute_batch("BEGIN EXCLUSIVE")
.expect("take the write lock");
locked_tx.send(()).expect("announce the lock");
std::thread::sleep(HELD);
conn.execute_batch("ROLLBACK").expect("release the lock");
});
locked_rx.recv().expect("the lock was taken");
let read: String = vm.block_on(async {
let writer = vm
.lua
.load(r#"session:append({ kind = "slow" })"#)
.exec_async();
let reader = vm
.lua
.load(
r#"
pause()
local id, scope, owner = session:id(), session:scope_id(), session:owner()
assert(id == expected_id, "id: " .. tostring(id))
assert(scope == expected_scope, "scope_id: " .. tostring(scope))
assert(owner == expected_owner, "owner: " .. tostring(owner))
assert(owner == "u-7", "owner: " .. tostring(owner))
return id
"#,
)
.eval_async::<String>();
let (written, read) = tokio::join!(writer, reader);
written.expect("the append eventually lands");
read.expect("an identity read must answer while the session is held")
});
blocker.join().expect("the blocker thread");
assert!(!read.is_empty(), "the read answered the stream's own id");
}
#[test]
fn open_with_a_parent_allocates_out_of_the_parents_balance() {
let vm = vm();
vm.exec(
r#"
local parent = knl.open({ owner = "u", budget = { amount = 100, tag = "tokens" } })
local child = knl.open({
owner = "worker",
parent = parent,
budget = { from_parent = 40 },
})
assert(child:id() ~= parent:id(), "a child is its own stream")
assert(child:owner() == "worker", child:owner())
assert(parent:remaining() == 60, "parent: " .. tostring(parent:remaining()))
assert(child:remaining() == 40, "child: " .. tostring(child:remaining()))
-- the child's own log: opened, and opened with the grant
assert(kinds_of(child) == "session_opened,budget_granted", kinds_of(child))
local opened = child:events()[1]
assert(opened.data.parent == parent:id(), tostring(opened.data.parent))
local granted = child:events()[2]
assert(granted.data.parent == parent:id(), tostring(granted.data.parent))
assert(granted.data.amount == 40, tostring(granted.data.amount))
assert(granted.data.tag == "tokens", "the parent's unit by default")
-- the parent's side: a reservation naming where the units went
local ledger = parent:events()
local reserved = ledger[#ledger]
assert(reserved.kind == "budget_reserved", reserved.kind)
assert(reserved.data.child == child:id(), tostring(reserved.data.child))
-- and closing the child gives nothing back
child:close("done")
assert(parent:remaining() == 60, "an allocation is a spend")
parent:close("done")
"#,
)
.expect("the allocation");
vm.finish();
}
#[test]
fn a_child_the_parent_cannot_pay_for_is_refused() {
let vm = vm();
vm.exec(
r#"
local parent = knl.open({ owner = "u", budget = { amount = 10, tag = "tokens" } })
local read, raised = failure(knl.open, {
owner = "worker", parent = parent, budget = { from_parent = 40 },
})
assert(read.kind == "refused", "kind: " .. tostring(read.kind))
assert(read.method == "open", "method: " .. tostring(read.method))
assert(read.retryable == false, "the same balance answers the same")
assert(tostring(raised):find("40", 1, true), tostring(raised))
-- recorded on the parent, and the balance did not move
assert(parent:remaining() == 10, tostring(parent:remaining()))
local ledger = parent:events()
local refused = ledger[#ledger]
assert(refused.kind == "budget_refused", refused.kind)
assert(refused.data.remaining == 10, tostring(refused.data.remaining))
assert(type(refused.data.child) == "string", "the refusal names the child")
parent:close("done")
"#,
)
.expect("the refusal");
vm.finish();
}
#[test]
fn a_parent_and_a_grant_are_not_mixed() {
let vm = vm();
vm.exec(
r#"
local parent = knl.open({ owner = "u", budget = { amount = 100, tag = "tokens" } })
-- from_parent with nobody to take it from
local orphan = failure(knl.open, { owner = "w", budget = { from_parent = 5 } })
assert(orphan.kind == "validation", orphan.kind)
assert(orphan.message:find("opts.parent", 1, true), orphan.message)
-- a parent, and an owner's grant instead of an allocation
local granted = failure(knl.open, {
owner = "w", parent = parent, budget = { amount = 5 },
})
assert(granted.kind == "validation", granted.kind)
assert(granted.message:find("from_parent", 1, true), granted.message)
-- both at once says neither
local both = failure(knl.open, {
owner = "w", parent = parent, budget = { amount = 5, from_parent = 5 },
})
assert(both.kind == "validation", both.kind)
-- a parent that is not a session
local nonsense = failure(knl.open, {
owner = "w", parent = "s-1", budget = { from_parent = 5 },
})
assert(nonsense.kind == "validation", nonsense.kind)
assert(nonsense.message:find("must be a session", 1, true), nonsense.message)
-- a child on a store of its own is a second log, and a tree is one
local split = failure(knl.open, {
owner = "w", parent = parent, budget = { from_parent = 5 }, store = "mem",
})
assert(split.kind == "validation", split.kind)
assert(split.message:find("one log", 1, true), split.message)
-- none of it moved the balance
assert(parent:remaining() == 100, tostring(parent:remaining()))
parent:close("done")
"#,
)
.expect("the refusals");
vm.finish();
}
#[test]
fn a_close_records_the_children_that_were_still_open() {
let vm = vm();
vm.exec(
r#"
local parent = knl.open({ owner = "u", budget = { amount = 100, tag = "tokens" } })
local running = knl.open({ owner = "w", parent = parent, budget = { from_parent = 10 } })
local done = knl.open({ owner = "w", parent = parent, budget = { from_parent = 10 } })
done:close("done")
parent:close("done")
local events = parent:events()
local boundary = events[#events]
assert(boundary.kind == "session_closed", boundary.kind)
local open_children = boundary.data.open_children
assert(type(open_children) == "table", type(open_children))
assert(#open_children == 1, "one child was still open, got " .. #open_children)
assert(open_children[1] == running:id(), tostring(open_children[1]))
running:close("done")
"#,
)
.expect("the close");
vm.finish();
}
#[test]
fn a_childs_stream_lands_in_the_parents_database() {
let vm = vm();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("tree.db");
let path_str = path.to_str().expect("utf-8 path").to_string();
let rows: usize = vm
.eval(&format!(
r#"
local parent = knl.open({{
owner = "u",
budget = {{ amount = 100, tag = "tokens" }},
store = {{ sqlite = "{path_str}" }},
}})
local child = knl.open({{
owner = "w", parent = parent, budget = {{ from_parent = 25 }},
}})
-- The child was never told where the log is, and it is in it:
-- one statement over the parent's own store reaches both.
local found = parent:query(
"SELECT stream FROM events WHERE kind = 'session_opened' ORDER BY stream"
)
child:close("done")
parent:close("done")
return #found
"#
))
.expect("the durable tree");
assert_eq!(rows, 2, "the parent and its child are in one database");
vm.finish();
}
}