use serde_json::{json, Value};
use ssp2::segment::{decode_rows_segment, encode_rows_segment};
use ssp2::{
decode_message, encode_message, parse_control, render_message, render_rows_segment,
ControlMessage,
};
use syncular_client::previous_version::{PreviousVersionContextConfig, PreviousVersionReadSpec};
use syncular_client::{
ClientDiagnosticsRequest, ClientError, ClientLimits, CommandEffects, CommitOutcomeQuery,
LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, QueryOwner, QueryReadFailure,
RealtimePolicy, ResolveCommitOutcomeInput, SubscriptionRequest, SyncClient, Transport,
TransportError, WindowBase, WindowCoverage, LOCAL_AUTHORITY_UNCONFIGURED_CODE,
};
pub fn bytes_to_hex(bytes: &[u8]) -> String {
syncular_client::values::bytes_to_hex(bytes)
}
pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
syncular_client::values::hex_to_bytes(hex)
}
pub fn bytes_value(bytes: &[u8]) -> Value {
Value::Object(serde_json::Map::from_iter([(
"$bytes".to_owned(),
Value::from(bytes_to_hex(bytes)),
)]))
}
pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
let hex = value
.and_then(|v| v.get("$bytes"))
.and_then(Value::as_str)
.ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
hex_to_bytes(hex)
}
#[derive(Debug, Clone)]
pub struct CommandError {
pub code: String,
pub message: String,
pub details: Option<Value>,
pub retryable: bool,
}
impl CommandError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: None,
retryable: false,
}
}
pub fn from_client(error: ClientError) -> Self {
Self {
code: error.code,
message: error.message,
details: error.details,
retryable: error.retryable,
}
}
}
impl From<(String, String)> for CommandError {
fn from((code, message): (String, String)) -> Self {
Self {
code,
message,
details: None,
retryable: false,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct CreateEffects {
pub signed_urls: bool,
pub security_preflight_pending: bool,
}
fn read_command_error(failure: QueryReadFailure) -> CommandError {
let details = failure.details();
let retryable = failure.retryable();
CommandError {
code: failure.code.unwrap_or("client.failed").to_owned(),
message: failure.message,
details,
retryable,
}
}
fn client_err(message: String) -> CommandError {
let code = message
.split_once(':')
.map(|(candidate, _)| candidate)
.filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
.unwrap_or("client.failed");
let retryable = code == "client.storage_busy";
CommandError {
code: code.to_owned(),
message,
details: None,
retryable,
}
}
fn transport_command_error(error: TransportError) -> CommandError {
CommandError {
code: error.code,
message: error.message,
details: error.details,
retryable: false,
}
}
fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
client
.as_mut()
.ok_or_else(|| client_err("no client instance created".to_owned()))
}
pub fn parse_previous_version_context(
value: Option<&Value>,
) -> Result<Option<PreviousVersionContextConfig>, CommandError> {
let Some(value) = value else {
return Ok(None);
};
let object = value.as_object().ok_or_else(|| {
client_err("sync.invalid_request: previousVersionContext must be an object".to_owned())
})?;
let enabled = match object.get("enabled") {
None => false,
Some(Value::Bool(enabled)) => *enabled,
Some(_) => {
return Err(client_err(
"sync.invalid_request: previousVersionContext.enabled must be a boolean".to_owned(),
))
}
};
let mut config = PreviousVersionContextConfig {
enabled,
..PreviousVersionContextConfig::default()
};
for (key, slot) in [
("maxBytes", &mut config.max_bytes),
("maxRows", &mut config.max_rows),
("maxTables", &mut config.max_tables),
("maxRowBytes", &mut config.max_row_bytes),
("maxAgeMs", &mut config.max_age_ms),
] {
if let Some(raw) = object.get(key) {
*slot = raw.as_i64().ok_or_else(|| {
client_err(format!(
"sync.invalid_request: previousVersionContext.{key} must be a positive safe integer"
))
})?;
}
}
config.validate().map_err(client_err)?;
Ok(Some(config))
}
fn parse_previous_version_snapshot_spec(
params: &Value,
) -> Result<PreviousVersionReadSpec, CommandError> {
let table = params.get("table").and_then(Value::as_str).ok_or_else(|| {
client_err("sync.invalid_request: previousVersionSnapshot missing table".to_owned())
})?;
let row_ids = match params.get("rowIds") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(ids)) => ids
.iter()
.map(|id| match id {
Value::String(id) => Ok(id.clone()),
Value::Number(number) => Ok(number.to_string()),
Value::Bool(boolean) => Ok(boolean.to_string()),
_ => Err(client_err(
"sync.invalid_request: previousVersionSnapshot rowIds must be strings"
.to_owned(),
)),
})
.collect::<Result<Vec<_>, _>>()?,
Some(_) => {
return Err(client_err(
"sync.invalid_request: previousVersionSnapshot rowIds must be an array".to_owned(),
))
}
};
let limit = match params.get("limit") {
None | Some(Value::Null) => None,
Some(value) => Some(value.as_i64().ok_or_else(|| {
client_err(
"sync.invalid_request: previousVersionSnapshot limit must be a positive number"
.to_owned(),
)
})?),
};
Ok(PreviousVersionReadSpec {
table: table.to_owned(),
row_ids,
limit,
})
}
pub fn parse_limits(value: Option<&Value>) -> Result<ClientLimits, CommandError> {
let mut limits = ClientLimits::default();
let Some(object) = value.and_then(Value::as_object) else {
return Ok(limits);
};
limits.limit_commits = object
.get("limitCommits")
.and_then(Value::as_i64)
.map(|v| v as i32);
limits.limit_snapshot_rows = object
.get("limitSnapshotRows")
.and_then(Value::as_i64)
.map(|v| v as i32);
limits.max_snapshot_pages = object
.get("maxSnapshotPages")
.and_then(Value::as_i64)
.map(|v| v as i32);
limits.accept = object
.get("accept")
.and_then(Value::as_u64)
.map(|v| v as u8);
limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
limits.outcome_retention_max_entries = object
.get("outcomeRetentionMaxEntries")
.and_then(Value::as_u64)
.map(|value| value as usize);
limits.max_push_commits_per_request = parse_push_limit(object, "maxPushCommitsPerRequest")?;
limits.max_push_operations_per_request =
parse_push_limit(object, "maxPushOperationsPerRequest")?;
limits.max_push_request_bytes = parse_push_limit(object, "maxPushRequestBytes")?;
Ok(limits)
}
fn parse_push_limit(
object: &serde_json::Map<String, Value>,
key: &str,
) -> Result<Option<usize>, CommandError> {
match object.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n >= 1 && *n <= u32::MAX as usize)
.map(Some)
.ok_or_else(|| CommandError {
code: "sync.invalid_request".to_owned(),
message: "push limit must be an integer in 1..=4294967295".to_owned(),
details: Some(serde_json::json!({ "limit": key })),
retryable: false,
}),
}
}
fn parse_realtime_policy(value: Option<&Value>) -> Result<RealtimePolicy, CommandError> {
match value {
None => Ok(RealtimePolicy::Optional),
Some(Value::String(text)) => match text.as_str() {
"required" => Ok(RealtimePolicy::Required),
"optional" => Ok(RealtimePolicy::Optional),
"off" => Ok(RealtimePolicy::Off),
other => Err(client_err(format!(
"sync.invalid_request: realtimePolicy must be 'required', 'optional', or 'off', got {other:?}"
))),
},
Some(_) => Err(client_err(
"sync.invalid_request: realtimePolicy must be a string".to_owned(),
)),
}
}
pub fn parse_encryption(
value: &Value,
) -> Result<syncular_client::values::EncryptionConfig, String> {
let mut config = syncular_client::values::EncryptionConfig::default();
let Some(keys) = value.get("keys").and_then(Value::as_object) else {
return Ok(config);
};
for (key_id, key_val) in keys {
let bytes =
value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
if bytes.len() != 32 {
return Err(format!(
"encryption key {key_id:?} must be 32 bytes, got {}",
bytes.len()
));
}
config.keys.insert(key_id.clone(), bytes);
}
if let Some(columns) = value.get("keyIdColumns") {
let columns = columns
.as_object()
.ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
for (table, column) in columns {
let column = column.as_str().ok_or_else(|| {
format!("encryption keyIdColumns entry for {table:?} must be a string")
})?;
if column.is_empty() {
return Err(format!(
"encryption keyIdColumns entry for {table:?} must not be empty"
));
}
config
.key_id_columns
.insert(table.clone(), column.to_owned());
}
}
Ok(config)
}
pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
let object = value.as_object().ok_or_else(|| {
"sync.invalid_request: headers must be an object of string values".to_owned()
})?;
let mut headers = Vec::with_capacity(object.len());
for (name, value) in object {
let value = value
.as_str()
.ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
headers.push((name.clone(), value.to_owned()));
}
Ok(headers)
}
pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
let list = value
.and_then(Value::as_array)
.ok_or_else(|| "mutations must be a list".to_owned())?;
let mut out = Vec::with_capacity(list.len());
for entry in list {
let op = entry
.get("op")
.and_then(Value::as_str)
.ok_or_else(|| "mutation missing op".to_owned())?;
let table = entry
.get("table")
.and_then(Value::as_str)
.ok_or_else(|| "mutation missing table".to_owned())?
.to_owned();
let base_version = entry.get("baseVersion").and_then(Value::as_i64);
match op {
"upsert" | "patch" => {
let mut values = entry
.get("values")
.and_then(Value::as_object)
.cloned()
.ok_or_else(|| "upsert missing values".to_owned())?;
decode_bigint_members(&mut values)?;
out.push(if op == "patch" {
Mutation::Patch {
table,
values,
base_version,
}
} else {
Mutation::Upsert {
table,
values,
base_version,
}
});
}
"delete" => {
let row_id = entry
.get("rowId")
.and_then(Value::as_str)
.ok_or_else(|| "delete missing rowId".to_owned())?
.to_owned();
out.push(Mutation::Delete {
table,
row_id,
base_version,
});
}
other => return Err(format!("unknown mutation op {other:?}")),
}
}
Ok(out)
}
fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
for value in values.values_mut() {
let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
continue;
};
let integer = decimal
.parse::<i64>()
.map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
*value = Value::from(integer);
}
Ok(())
}
fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
match value {
Some(v) => syncular_client::values::json_to_scope_map(v),
None => Ok(Vec::new()),
}
}
fn subscription_from_params(value: &Value) -> Result<SubscriptionRequest, String> {
let id = value
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| "subscribe missing id".to_owned())?
.to_owned();
let table = value
.get("table")
.and_then(Value::as_str)
.ok_or_else(|| "subscribe missing table".to_owned())?
.to_owned();
Ok(SubscriptionRequest {
id,
table,
scopes: scopes_from_params(value.get("scopes"))?,
params: value
.get("params")
.and_then(Value::as_str)
.map(str::to_owned),
})
}
fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
let object = value
.and_then(Value::as_object)
.ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
let table = object
.get("table")
.and_then(Value::as_str)
.ok_or_else(|| "window base missing table".to_owned())?
.to_owned();
let variable = object
.get("variable")
.and_then(Value::as_str)
.ok_or_else(|| "window base missing variable".to_owned())?
.to_owned();
let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
let params = object
.get("params")
.and_then(Value::as_str)
.map(str::to_owned);
Ok(WindowBase {
table,
variable,
fixed_scopes,
params,
})
}
fn parse_window_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
let entries = match value {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(entries)) => entries,
Some(_) => return Err("coverage must be a list".to_owned()),
};
let mut coverage = Vec::with_capacity(entries.len());
for entry in entries {
let units = match entry.get("units") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(values)) => values
.iter()
.map(|value| value.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()
.ok_or_else(|| "coverage units must be a list of strings".to_owned())?,
Some(_) => return Err("coverage units must be a list of strings".to_owned()),
};
coverage.push(WindowCoverage {
base: window_base_from_params(entry.get("base"))?,
units,
});
}
Ok(coverage)
}
fn parse_query_owner<'a>(
params: &'a Value,
method: &str,
) -> Result<Option<(&'a str, Vec<&'a str>)>, CommandError> {
match params.get("owner") {
None | Some(Value::Null) => Ok(None),
Some(value) => {
let id = value
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty());
let tables = value
.get("tables")
.and_then(Value::as_array)
.and_then(|list| {
list.iter()
.map(Value::as_str)
.collect::<Option<Vec<&str>>>()
});
match (id, tables) {
(Some(id), Some(tables)) => Ok(Some((id, tables))),
_ => Err(client_err(format!(
"sync.invalid_request: {method} owner must be {{id: non-empty string, tables: string[]}}"
))),
}
}
}
}
fn snapshot_string_list(value: Option<&Value>) -> Result<Vec<String>, CommandError> {
match value {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::Array(items)) => items
.iter()
.map(|item| item.as_str().map(str::to_owned))
.collect::<Option<Vec<String>>>()
.ok_or_else(|| {
client_err(
"sync.invalid_request: snapshotRead list must contain strings".to_owned(),
)
}),
Some(_) => Err(client_err(
"sync.invalid_request: snapshotRead list must be a list of strings".to_owned(),
)),
}
}
#[cfg(feature = "crdt-yjs")]
fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
let table = params
.get("table")
.and_then(Value::as_str)
.ok_or_else(|| client_err("crdt command missing table".to_owned()))?
.to_owned();
let row_id = params
.get("rowId")
.and_then(Value::as_str)
.ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
.to_owned();
let column = params
.get("column")
.and_then(Value::as_str)
.ok_or_else(|| client_err("crdt command missing column".to_owned()))?
.to_owned();
let name = params
.get("name")
.and_then(Value::as_str)
.unwrap_or("text")
.to_owned();
Ok((table, row_id, column, name))
}
pub fn dispatch<T: Transport>(
transport: &mut T,
client: &mut Option<SyncClient>,
effects: &mut CreateEffects,
method: &str,
params: &Value,
) -> Result<Value, CommandError> {
if method == "create" {
let requests_preflight = params
.get("securityPreflight")
.and_then(Value::as_bool)
.unwrap_or(false);
let preflight_engaged = client.as_ref().map_or(
effects.security_preflight_pending,
SyncClient::security_preflight,
);
if preflight_engaged && !requests_preflight {
return Err(CommandError::new(
syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
"the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
));
}
} else {
let allowed_during_preflight = matches!(
method,
"authoritySnapshot"
| "securityLifecycle"
| "beginSecurityPreflight"
| "activateSecurity"
| "setTransportEnabled"
| "purgeLocalData"
| "previousVersionDiscard"
| "localRevision"
| "statusSnapshot"
| "localAuthorityState"
| "shutdown"
);
if client
.as_ref()
.is_some_and(|running| running.security_preflight())
&& !allowed_during_preflight
{
return Err(CommandError::new(
syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
"the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data",
));
}
}
let mut result = match method {
"create" => {
let client_id = params
.get("clientId")
.and_then(Value::as_str)
.map(str::to_owned);
let schema = params
.get("schema")
.ok_or_else(|| client_err("create missing schema".to_owned()))?;
let limits = parse_limits(params.get("limits"))?;
let previous_version_context =
parse_previous_version_context(params.get("previousVersionContext"))?;
let mut limits = limits;
limits.previous_version_context = previous_version_context;
if let Some(reads) = params.get("authorityReads") {
limits.authority_reads = serde_json::from_value(reads.clone()).map_err(|_| {
client_err(
"client.authority_read_forbidden: invalid authority declaration".into(),
)
})?;
}
transport
.open_local_authority(schema, params.get("dbPath").and_then(Value::as_str))
.map_err(transport_command_error)?;
let mut instance = match params.get("dbPath").and_then(Value::as_str) {
Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
.map_err(client_err)?,
None => {
SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
}
};
instance
.set_retain_failed_commits(
params
.get("retainFailedCommits")
.and_then(Value::as_bool)
.unwrap_or(false),
)
.map_err(client_err)?;
let realtime_policy = parse_realtime_policy(params.get("realtimePolicy"))?;
instance
.set_realtime_policy(realtime_policy)
.map_err(client_err)?;
if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
instance.set_now_ms(now_ms);
}
effects.signed_urls = params
.get("signedUrls")
.and_then(Value::as_bool)
.unwrap_or(false);
let security_preflight = params
.get("securityPreflight")
.and_then(Value::as_bool)
.unwrap_or(false);
if instance.security_preflight() && !security_preflight {
return Err(CommandError::new(
syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
"the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
));
}
if security_preflight && params.get("encryption").is_some() {
return Err(client_err(
"sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
.to_owned(),
));
}
if let Some(enc) = params.get("encryption") {
let config = parse_encryption(enc).map_err(client_err)?;
instance.set_encryption(config);
}
if security_preflight {
instance.begin_security_preflight();
}
if let Some(enabled) = params.get("transportEnabled") {
let enabled = enabled.as_bool().ok_or_else(|| {
client_err("sync.invalid_request: transportEnabled must be a boolean".into())
})?;
instance.set_transport_enabled(transport, enabled);
}
effects.security_preflight_pending = security_preflight;
*client = Some(instance);
Ok(json!({}))
}
"securityLifecycle" => Ok(json!({
"state": need_client(client)?.security_lifecycle()
})),
"beginSecurityPreflight" => {
let running = need_client(client)?;
running.disconnect_realtime(transport);
running.begin_security_preflight();
effects.security_preflight_pending = true;
Ok(json!({}))
}
"authoritySnapshot" => {
if !params.as_object().is_some_and(|p| p.is_empty()) {
return Err(client_err(
"client.authority_read_forbidden: authoritySnapshot accepts no arguments"
.into(),
));
}
client
.as_ref()
.ok_or_else(|| client_err("client.closed: no authority client is open".into()))?
.authority_snapshot()
.map_err(client_err)
}
"activateSecurity" => {
let encryption = match params.get("encryption") {
Some(value) => parse_encryption(value).map_err(client_err)?,
None => syncular_client::values::EncryptionConfig::default(),
};
if let Some(headers) = params.get("headers") {
parse_headers(headers).map_err(client_err)?;
}
need_client(client)?
.activate_security(encryption)
.map_err(client_err)?;
effects.security_preflight_pending = false;
Ok(json!({}))
}
"setTransportEnabled" => {
let enabled = params
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| {
client_err(
"sync.invalid_request: setTransportEnabled.enabled must be a boolean"
.into(),
)
})?;
need_client(client)?.set_transport_enabled(transport, enabled);
Ok(json!({}))
}
"setHeaders" => {
let headers = params
.get("headers")
.ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
parse_headers(headers).map_err(client_err)?;
Ok(json!({}))
}
"shutdown" => {
if let Some(running) = client.as_mut() {
effects.security_preflight_pending = running.security_preflight();
running.disconnect_realtime(transport);
running.seal_security_on_teardown();
}
*client = None;
Ok(json!({}))
}
"subscribe" => {
need_client(client)?
.subscribe_many(vec![subscription_from_params(params).map_err(client_err)?])
.map_err(client_err)?;
Ok(json!({}))
}
"subscribeMany" => {
let requests = params
.get("subscriptions")
.and_then(Value::as_array)
.ok_or_else(|| client_err("subscribeMany missing subscriptions".to_owned()))?
.iter()
.map(subscription_from_params)
.collect::<Result<Vec<_>, _>>()
.map_err(client_err)?;
need_client(client)?
.subscribe_many(requests)
.map_err(client_err)?;
Ok(json!({}))
}
"unsubscribe" => {
let id = params
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
need_client(client)?.unsubscribe(id);
Ok(json!({}))
}
"setWindow" => {
let base = window_base_from_params(params.get("base")).map_err(client_err)?;
let units: Vec<String> = params
.get("units")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
let command_effects = need_client(client)?
.set_window(&base, &units)
.map_err(client_err)?;
Ok(json!({ "effects": command_effects }))
}
"windowState" => {
let base = window_base_from_params(params.get("base")).map_err(client_err)?;
let state = need_client(client)?.window_state(&base);
Ok(json!({ "units": state.units, "pending": state.pending }))
}
"timeWindowSugar" => {
let created_at_ms = params
.get("createdAtMs")
.and_then(Value::as_i64)
.ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
let count = params
.get("count")
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
let now_ms = params
.get("nowMs")
.and_then(Value::as_i64)
.ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
if params.get("unit").and_then(Value::as_str) != Some("month") {
return Err(client_err(
"sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
));
}
let unit = syncular_client::TimeBucketUnit::Month;
Ok(json!({
"bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
.map_err(client_err)?,
"units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
}))
}
"mutate" => {
let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
let id = need_client(client)?
.mutate(mutations)
.map_err(CommandError::from_client)?;
Ok(json!({
"clientCommitId": id,
"effects": CommandEffects::interactive()
}))
}
"patch" => {
let table = params
.get("table")
.and_then(Value::as_str)
.ok_or_else(|| client_err("patch missing table".to_owned()))?;
let row_id = params
.get("rowId")
.and_then(Value::as_str)
.ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
let mut partial = params
.get("partial")
.and_then(Value::as_object)
.cloned()
.ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
decode_bigint_members(&mut partial).map_err(client_err)?;
let base_version = params.get("baseVersion").and_then(Value::as_i64);
let id = need_client(client)?
.patch(table, row_id, partial, base_version)
.map_err(CommandError::from_client)?;
Ok(json!({
"clientCommitId": id,
"effects": CommandEffects::interactive()
}))
}
"purgeLocalData" => {
let input: LocalDataPurgeInput =
serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
})?)
.map_err(|error| {
client_err(format!(
"sync.invalid_request: invalid purgeLocalData input: {error}"
))
})?;
let result = need_client(client)?
.purge_local_data(&input)
.map_err(client_err)?;
serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
}
"rebootstrapLocalData" => {
let input: LocalDataRebootstrapInput =
serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
client_err(
"sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
)
})?)
.map_err(|error| {
client_err(format!(
"sync.invalid_request: invalid rebootstrapLocalData input: {error}"
))
})?;
let result = need_client(client)?
.rebootstrap_local_data(&input)
.map_err(client_err)?;
Ok(json!({
"alreadyApplied": result.already_applied,
"retainedCommits": result.retained_commits,
"resetSubscriptions": result.reset_subscriptions,
"effects": if result.already_applied {
CommandEffects::none()
} else {
CommandEffects::interactive()
}
}))
}
"sync" => {
let outcome = need_client(client)?.sync(transport);
Ok(outcome.to_json())
}
"resetClientId" | "reset_client_id" => {
let client_id = need_client(client)?.reset_client_id(transport);
Ok(json!({ "clientId": client_id }))
}
"syncUntilIdle" => {
let max_rounds = match params.get("maxRounds") {
None | Some(Value::Null) => None,
Some(value) => Some(
value
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| *n >= 1)
.ok_or_else(|| {
client_err(
"sync.invalid_request: maxRounds must be a positive integer"
.to_owned(),
)
})?,
),
};
let timeout_ms = match params.get("timeoutMs") {
None | Some(Value::Null) => None,
Some(value) => Some(value.as_u64().filter(|ms| *ms >= 1).ok_or_else(|| {
client_err(
"sync.invalid_request: timeoutMs must be a positive integer".to_owned(),
)
})?),
};
let client = need_client(client)?;
let outcome = match timeout_ms {
Some(timeout_ms) => {
client.sync_until_idle_with_deadline(transport, max_rounds, Some(timeout_ms))
}
None => client.sync_until_idle(transport, max_rounds),
};
Ok(outcome.to_json())
}
"proveCurrentBase" => {
let outcome = params
.get("outcome")
.and_then(Value::as_object)
.ok_or_else(|| client_err("proveCurrentBase missing outcome".to_owned()))?;
let id = outcome
.get("clientCommitId")
.and_then(Value::as_str)
.ok_or_else(|| client_err("proveCurrentBase missing clientCommitId".to_owned()))?;
let sequence = outcome
.get("sequence")
.and_then(Value::as_i64)
.ok_or_else(|| client_err("proveCurrentBase missing sequence".to_owned()))?;
let max_rounds = match params.get("maxRounds") {
None | Some(Value::Null) => None,
Some(value) => Some(
value
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| *n >= 1)
.ok_or_else(|| {
client_err(
"sync.invalid_request: maxRounds must be a positive integer"
.to_owned(),
)
})?,
),
};
Ok(json!(
need_client(client)?.prove_current_base(transport, id, sequence, max_rounds)
))
}
"readRows" => {
let table = params
.get("table")
.and_then(Value::as_str)
.ok_or_else(|| client_err("readRows missing table".to_owned()))?;
let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
Ok(json!({ "rows": rows }))
}
"query" => {
let sql = params
.get("sql")
.and_then(Value::as_str)
.ok_or_else(|| client_err("query missing sql".to_owned()))?;
let bind = match params.get("params") {
Some(Value::Array(list)) => list.as_slice(),
None | Some(Value::Null) => &[],
Some(_) => return Err(client_err("query params must be a list".to_owned())),
};
let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
Ok(Value::Object(serde_json::Map::from_iter([(
"rows".to_owned(),
Value::Array(rows.into_iter().map(Value::Object).collect()),
)])))
}
"querySnapshot" => {
let sql = params
.get("sql")
.and_then(Value::as_str)
.ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
let bind = match params.get("params") {
Some(Value::Array(list)) => list.as_slice(),
None | Some(Value::Null) => &[],
Some(_) => {
return Err(client_err("querySnapshot params must be a list".to_owned()))
}
};
let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
let owner = parse_query_owner(params, "querySnapshot")?;
let mut snapshot = need_client(client)?
.query_snapshot(
sql,
bind,
&coverage,
owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
)
.map_err(client_err)?;
let rows = std::mem::take(&mut snapshot.rows);
let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
Ok(result)
}
"localRevision" => {
let read = need_client(client)?
.snapshot_read(&syncular_client::SnapshotReadRequest::default(), None)
.map_err(read_command_error)?;
Ok(json!({ "revision": read.revision }))
}
"snapshotRead" => {
let mut statement_sql: Vec<String> = Vec::new();
let mut statement_params: Vec<Vec<Value>> = Vec::new();
match params.get("statements") {
None | Some(Value::Null) => {}
Some(Value::Array(items)) => {
for item in items {
let Some(sql) = item.get("sql").and_then(Value::as_str) else {
return Err(client_err(
"sync.invalid_request: snapshotRead statements need a sql string"
.to_owned(),
));
};
let bind = match item.get("params") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(values)) => values.clone(),
Some(_) => {
return Err(client_err(
"sync.invalid_request: snapshotRead statement params must be a list"
.to_owned(),
))
}
};
statement_sql.push(sql.to_owned());
statement_params.push(bind);
}
}
Some(_) => {
return Err(client_err(
"sync.invalid_request: snapshotRead statements must be a list".to_owned(),
))
}
}
let subscription_ids = snapshot_string_list(params.get("subscriptions"))?;
let commit_ids = snapshot_string_list(params.get("commitIds"))?;
let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
let owner = parse_query_owner(params, "snapshotRead")?;
let statements: Vec<syncular_client::SnapshotStatement<'_>> = statement_sql
.iter()
.zip(&statement_params)
.map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
.collect();
let read = need_client(client)?
.snapshot_read(
&syncular_client::SnapshotReadRequest {
statements,
coverage: &coverage,
subscriptions: subscription_ids,
commit_ids,
},
owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
)
.map_err(read_command_error)?;
serde_json::to_value(read).map_err(|error| client_err(error.to_string()))
}
"previousVersionSnapshot" | "previous_version_snapshot" => {
let spec = parse_previous_version_snapshot_spec(params)?;
let snapshot = need_client(client)?
.previous_version_snapshot(&spec)
.map_err(client_err)?;
serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
}
"previousVersionAudit" | "previous_version_audit" => {
Ok(match need_client(client)?.previous_version_audit() {
Some(audit) => {
serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
}
None => Value::Null,
})
}
"previousVersionDiscard" | "previous_version_discard" => {
let outcome = need_client(client)?.previous_version_discard();
Ok(json!({
"present": outcome.present,
"discarded": outcome.discarded,
}))
}
"statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
.expect("status serializes")),
"diagnosticsSnapshot" => {
let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
.map_err(|error| {
client_err(format!(
"sync.invalid_request: invalid diagnostics request: {error}"
))
})?;
let snapshot = need_client(client)?
.diagnostics_snapshot(&request)
.map_err(client_err)?;
Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
}
"progressSnapshot" => Ok(
serde_json::to_value(need_client(client)?.progress().snapshot())
.expect("progress JSON"),
),
"drainChangeBatches" => Ok(json!({
"batches": need_client(client)?.drain_change_batches()
})),
"drainSyncIntents" => Ok(json!({
"intents": need_client(client)?.drain_sync_intents()
})),
#[cfg(feature = "crdt-yjs")]
"crdtText" => {
let (table, row_id, column, name) = crdt_target(params)?;
let text = need_client(client)?
.crdt_text(&table, &row_id, &column, &name)
.map_err(client_err)?;
Ok(json!({ "text": text }))
}
#[cfg(feature = "crdt-yjs")]
"crdtInsertText" => {
let (table, row_id, column, name) = crdt_target(params)?;
let index = params
.get("index")
.and_then(Value::as_u64)
.ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
as u32;
let value = params
.get("value")
.and_then(Value::as_str)
.ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
let id = need_client(client)?
.crdt_insert_text(&table, &row_id, &column, &name, index, value)
.map_err(CommandError::from_client)?;
Ok(json!({ "clientCommitId": id }))
}
#[cfg(feature = "crdt-yjs")]
"crdtDeleteText" => {
let (table, row_id, column, name) = crdt_target(params)?;
let index = params
.get("index")
.and_then(Value::as_u64)
.ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
as u32;
let len = params
.get("len")
.and_then(Value::as_u64)
.ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
as u32;
let id = need_client(client)?
.crdt_delete_text(&table, &row_id, &column, &name, index, len)
.map_err(CommandError::from_client)?;
Ok(json!({ "clientCommitId": id }))
}
#[cfg(feature = "crdt-yjs")]
"crdtApplyUpdate" => {
let (table, row_id, column, _name) = crdt_target(params)?;
let update = value_bytes(params.get("update")).map_err(client_err)?;
let id = need_client(client)?
.crdt_apply_update(&table, &row_id, &column, &update)
.map_err(CommandError::from_client)?;
Ok(json!({ "clientCommitId": id }))
}
#[cfg(not(feature = "crdt-yjs"))]
"crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => {
Err(CommandError::new(
"client.crdt_unavailable",
"native CRDT support requires the `crdt-yjs` feature (§5.10.5)",
))
}
"uploadBlob" => {
let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
let media_type = params
.get("mediaType")
.and_then(Value::as_str)
.map(str::to_owned);
let name = params
.get("name")
.and_then(Value::as_str)
.map(str::to_owned);
let reference = need_client(client)?
.upload_blob(&bytes, media_type, name)
.map_err(client_err)?;
Ok(json!({ "ref": reference }))
}
"fetchBlob" => {
let blob = params
.get("blob")
.and_then(Value::as_str)
.ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
.to_owned();
let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
let mut value = serde_json::Map::new();
value.insert("blobId".to_owned(), Value::from(blob.blob_id));
value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
if let Some(media_type) = blob.media_type {
value.insert("mediaType".to_owned(), Value::from(media_type));
}
Ok(Value::Object(serde_json::Map::from_iter([(
"blob".to_owned(),
Value::Object(value),
)])))
}
"conflicts" => {
let conflicts = need_client(client)?.conflicts().to_vec();
Ok(json!({ "conflicts": conflicts }))
}
"rejections" => {
let rejections = need_client(client)?.rejections().to_vec();
Ok(json!({ "rejections": rejections }))
}
"commitOutcome" => {
let client_commit_id = params
.get("clientCommitId")
.and_then(Value::as_str)
.ok_or_else(|| {
client_err(
"sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
)
})?;
let outcome = need_client(client)?
.commit_outcome(client_commit_id)
.map_err(client_err)?;
Ok(json!({ "outcome": outcome }))
}
"commitOutcomes" => {
let query = serde_json::from_value::<CommitOutcomeQuery>(
params.get("query").cloned().unwrap_or_else(|| json!({})),
)
.map_err(|error| {
client_err(format!(
"sync.invalid_request: invalid commit outcome query: {error}"
))
})?;
let outcomes = need_client(client)?
.commit_outcomes(query)
.map_err(client_err)?;
Ok(json!({ "outcomes": outcomes }))
}
"resolveCommitOutcome" => {
let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
params.get("input").cloned().ok_or_else(|| {
client_err(
"sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
)
})?,
)
.map_err(|error| {
client_err(format!(
"sync.invalid_request: invalid outcome resolution: {error}"
))
})?;
let outcome = need_client(client)?
.resolve_commit_outcome(input)
.map_err(client_err)?;
Ok(json!({ "outcome": outcome }))
}
"pendingCommitIds" => {
let ids = need_client(client)?.pending_commit_ids();
Ok(json!({ "ids": ids }))
}
"pendingPayloads" => {
let payloads = need_client(client)?.pending_payloads();
Ok(json!({
"payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
}))
}
"subscriptionState" => {
let id = params
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
let state = need_client(client)?.subscription_state(id);
Ok(json!({ "state": state }))
}
"recreateWithSchema" => {
let schema = params
.get("schema")
.ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
need_client(client)?
.recreate_with_schema(schema)
.map_err(client_err)?;
Ok(json!({}))
}
"localAuthorityState" => Ok(json!({ "state": transport.local_authority_state() })),
"attachLocalAuthority" | "abandonLocalAuthorityAttach" => {
let running = need_client(client)?;
if transport.local_authority_state().is_none() {
return Err(CommandError::new(
LOCAL_AUTHORITY_UNCONFIGURED_CODE,
"the host configured no localAuthorityPath",
));
}
let enabled = running.transport_enabled();
running.set_transport_enabled(transport, false);
let outcome = if method == "attachLocalAuthority" {
transport.attach_local_authority()
} else {
transport.abandon_local_authority_attach()
};
running.set_transport_enabled(transport, enabled);
outcome.map_err(transport_command_error)?;
Ok(json!({ "effects": CommandEffects::interactive() }))
}
"connectRealtime" => {
need_client(client)?
.connect_realtime(transport)
.map_err(client_err)?;
Ok(json!({}))
}
"disconnectRealtime" => {
need_client(client)?.disconnect_realtime(transport);
Ok(json!({}))
}
"setPresence" => {
let scope_key = params
.get("scopeKey")
.and_then(Value::as_str)
.ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
.to_owned();
let doc = params.get("doc");
let doc_ref = match doc {
None | Some(Value::Null) => None,
Some(v) => Some(v),
};
need_client(client)?
.set_presence(transport, &scope_key, doc_ref)
.map_err(client_err)?;
Ok(json!({}))
}
"presence" => {
let scope_key = params
.get("scopeKey")
.and_then(Value::as_str)
.ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
let peers = need_client(client)?.presence(scope_key);
Ok(json!({ "peers": peers }))
}
"messageRoundtrip" => {
let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
match decode_message(&bytes) {
Ok(message) => Ok(json!({
"ok": true,
"bytes": bytes_value(&encode_message(&message)),
"renderedJson": render_message(&message).to_string(),
})),
Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
}
}
"segmentRoundtrip" => {
let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
match decode_rows_segment(&bytes) {
Ok(segment) => Ok(json!({
"ok": true,
"bytes": bytes_value(&encode_rows_segment(&segment)),
"renderedJson": render_rows_segment(&segment).to_string(),
})),
Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
}
}
"realtimeKnown" => {
let text = params
.get("text")
.and_then(Value::as_str)
.ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
let known = matches!(
parse_control(text),
Ok(ControlMessage::Hello { .. })
| Ok(ControlMessage::Wake { .. })
| Ok(ControlMessage::Heartbeat { .. })
| Ok(ControlMessage::Presence { .. })
);
Ok(json!({ "value": known }))
}
other => Err(client_err(format!("unknown method {other:?}"))),
};
if client
.as_ref()
.is_some_and(|running| !running.transport_enabled())
{
if let Ok(value) = &mut result {
if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
effects.remove("sync");
}
}
}
result
}
#[cfg(test)]
mod tests {
use serde_json::{json, Value};
use syncular_client::{
Mutation, SegmentRequest, SyncClient, Transport, TransportError,
SECURITY_PREFLIGHT_REQUIRED_CODE,
};
use super::{
client_err, dispatch, parse_encryption, parse_headers, parse_previous_version_context,
CreateEffects,
};
#[derive(Default)]
struct NoNetwork {
realtime_connects: usize,
realtime_closes: usize,
}
impl Transport for NoNetwork {
fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
Err(TransportError::new("sync.transport_failed", "offline"))
}
fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
Err(TransportError::new("sync.transport_failed", "offline"))
}
fn download_segment(
&mut self,
_request: &SegmentRequest,
_on_progress: &mut dyn FnMut(u64),
) -> Result<Vec<u8>, TransportError> {
Err(TransportError::new("sync.transport_failed", "offline"))
}
fn realtime_connect(&mut self) -> Result<(), TransportError> {
self.realtime_connects += 1;
Ok(())
}
fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
Ok(())
}
fn realtime_close(&mut self) -> Result<(), TransportError> {
self.realtime_closes += 1;
Ok(())
}
}
fn schema() -> Value {
json!({
"version": 1,
"tables": [{
"name": "todos",
"columns": [
{ "name": "id", "type": "string", "nullable": false },
{ "name": "list_id", "type": "string", "nullable": false }
],
"primaryKey": "id",
"scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
}]
})
}
#[test]
fn native_command_realtime_connection_is_idempotent() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create client");
for _ in 0..2 {
dispatch(
&mut transport,
&mut client,
&mut effects,
"connectRealtime",
&json!({}),
)
.expect("idempotent connect command");
}
assert_eq!(transport.realtime_connects, 1);
dispatch(
&mut transport,
&mut client,
&mut effects,
"disconnectRealtime",
&json!({}),
)
.expect("disconnect command");
dispatch(
&mut transport,
&mut client,
&mut effects,
"disconnectRealtime",
&json!({}),
)
.expect("idempotent disconnect command");
assert_eq!(transport.realtime_closes, 1);
dispatch(
&mut transport,
&mut client,
&mut effects,
"connectRealtime",
&json!({}),
)
.expect("deliberate reconnect command");
assert_eq!(transport.realtime_connects, 2);
}
#[test]
fn local_authority_commands_without_one_report_the_unconfigured_code() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
let state = dispatch(
&mut transport,
&mut client,
&mut effects,
"localAuthorityState",
&json!({}),
)
.expect("state without a client");
assert_eq!(state, json!({ "state": null }));
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create client");
for method in ["attachLocalAuthority", "abandonLocalAuthorityAttach"] {
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
method,
&json!({}),
)
.expect_err("no local authority");
assert_eq!(error.code, "client.local_authority_unconfigured");
}
let running = client.as_mut().expect("client");
assert!(running.transport_enabled());
assert!(
running.drain_sync_intents().is_empty(),
"the gate never toggled"
);
}
struct ScriptedAuthority {
opened: Vec<(Value, Option<String>)>,
open_error: Option<TransportError>,
attach_error: Option<TransportError>,
abandons: usize,
}
impl Transport for ScriptedAuthority {
fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
Err(TransportError::new("sync.transport_failed", "offline"))
}
fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
Err(TransportError::new("sync.transport_failed", "offline"))
}
fn download_segment(
&mut self,
_request: &SegmentRequest,
_on_progress: &mut dyn FnMut(u64),
) -> Result<Vec<u8>, TransportError> {
Err(TransportError::new("sync.transport_failed", "offline"))
}
fn realtime_connect(&mut self) -> Result<(), TransportError> {
Ok(())
}
fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
Ok(())
}
fn realtime_close(&mut self) -> Result<(), TransportError> {
Ok(())
}
fn open_local_authority(
&mut self,
schema: &Value,
replica_path: Option<&str>,
) -> Result<(), TransportError> {
self.opened
.push((schema.clone(), replica_path.map(str::to_owned)));
self.open_error.take().map_or(Ok(()), Err)
}
fn local_authority_state(&self) -> Option<&'static str> {
(!self.opened.is_empty()).then_some("local")
}
fn attach_local_authority(&mut self) -> Result<(), TransportError> {
self.attach_error.take().map_or(Ok(()), Err)
}
fn abandon_local_authority_attach(&mut self) -> Result<(), TransportError> {
self.abandons += 1;
Ok(())
}
}
#[test]
fn local_authority_opens_at_create_and_attach_errors_keep_code_and_details() {
let mut transport = ScriptedAuthority {
opened: Vec::new(),
open_error: Some(TransportError::new("sync.internal_error", "open failed")),
attach_error: Some(TransportError {
code: "sync.attach_rejected".to_owned(),
message: "the remote server refused an attach commit".to_owned(),
details: Some(json!({ "reason": "commit_rejected", "opIndex": 0 })),
}),
abandons: 0,
};
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect_err("an authority that fails to open fails create");
assert_eq!(error.code, "sync.internal_error");
assert!(client.is_none());
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create client");
assert_eq!(transport.opened.len(), 2);
assert_eq!(transport.opened[1], (schema(), None));
let state = dispatch(
&mut transport,
&mut client,
&mut effects,
"localAuthorityState",
&json!({}),
)
.expect("state");
assert_eq!(state, json!({ "state": "local" }));
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"attachLocalAuthority",
&json!({}),
)
.expect_err("scripted rejection");
assert_eq!(error.code, "sync.attach_rejected");
assert_eq!(
error.details,
Some(json!({ "reason": "commit_rejected", "opIndex": 0 }))
);
assert!(client.as_ref().expect("client").transport_enabled());
let abandoned = dispatch(
&mut transport,
&mut client,
&mut effects,
"abandonLocalAuthorityAttach",
&json!({}),
)
.expect("abandon");
assert_eq!(abandoned["effects"]["sync"]["kind"], "interactive");
assert_eq!(transport.abandons, 1);
dispatch(
&mut transport,
&mut client,
&mut effects,
"setTransportEnabled",
&json!({ "enabled": false }),
)
.expect("go offline");
let attached = dispatch(
&mut transport,
&mut client,
&mut effects,
"attachLocalAuthority",
&json!({}),
)
.expect("attach");
assert!(attached["effects"].get("sync").is_none());
assert!(!client.as_ref().expect("client").transport_enabled());
}
#[test]
fn schema_downgrade_has_structured_code_at_create_and_recreate_boundaries() {
let path = temp_db_path("schema-downgrade");
let mut newer = schema();
newer["version"] = json!(3);
let mut older = schema();
older["version"] = json!(2);
let mut transport = NoNetwork::default();
let mut client = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": newer, "dbPath": path }),
)
.expect("v3 create");
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"recreateWithSchema",
&json!({ "schema": older }),
)
.expect_err("refuse recreation");
assert_eq!(error.code, "client.schema_downgrade");
assert_eq!(
error.message,
"client.schema_downgrade: persisted local schema is newer than the requested schema"
);
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown");
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": older, "dbPath": path }),
)
.expect_err("refuse open");
assert_eq!(error.code, "client.schema_downgrade");
assert!(client.is_none());
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": newer, "dbPath": path }),
)
.expect("compatible reopen");
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown");
let _ = std::fs::remove_file(path);
}
#[test]
fn native_command_realtime_policy_rides_create_and_refuses_http() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "realtimePolicy": "required" }),
)
.expect("create client with a required realtime policy");
let result = dispatch(
&mut transport,
&mut client,
&mut effects,
"sync",
&json!({}),
)
.expect("sync command");
assert_eq!(result["ok"], json!(false));
assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
assert_eq!(result["realtimeState"], json!("disconnected"));
assert_eq!(result["retryDelayMs"], json!(250));
let snapshot = dispatch(
&mut transport,
&mut client,
&mut effects,
"diagnosticsSnapshot",
&json!({}),
)
.expect("diagnostics command");
assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
let mut second: Option<SyncClient> = None;
let error = dispatch(
&mut transport,
&mut second,
&mut effects,
"create",
&json!({ "schema": schema(), "realtimePolicy": "always" }),
)
.expect_err("an unknown policy is refused");
assert_eq!(error.code, "sync.invalid_request");
}
#[test]
fn parses_portable_encryption_keyring_and_key_id_columns() {
let key_hex = "2a".repeat(32);
let config = parse_encryption(&json!({
"keys": { "practice-key-v1": { "$bytes": key_hex } },
"keyIdColumns": { "patients": "encryption_key_id" }
}))
.expect("portable keyring parses");
assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
}
#[test]
fn rejects_non_string_key_id_columns() {
let error = parse_encryption(&json!({
"keys": {},
"keyIdColumns": { "patients": 7 }
}))
.expect_err("invalid selector must fail");
assert!(error.contains("must be a string"), "{error}");
}
#[test]
fn subscribe_many_registers_every_subscription() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create client");
dispatch(
&mut transport,
&mut client,
&mut effects,
"subscribeMany",
&json!({ "subscriptions": [
{ "id": "a", "table": "todos", "scopes": { "list_id": ["list-1"] } },
{ "id": "b", "table": "todos", "scopes": { "list_id": ["list-2"] } }
] }),
)
.expect("register subscriptions");
for id in ["a", "b"] {
let state = dispatch(
&mut transport,
&mut client,
&mut effects,
"subscriptionState",
&json!({ "id": id }),
)
.expect("subscription state");
assert_eq!(state["state"]["table"], "todos");
}
}
#[test]
fn native_command_hosts_reject_subscription_identity_rebinds() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create client");
let original = json!({
"id": "stable-subscription",
"table": "todos",
"scopes": { "list_id": ["list-2", "list-1"] },
"params": "{\"view\":\"v1\"}"
});
dispatch(
&mut transport,
&mut client,
&mut effects,
"subscribe",
&original,
)
.expect("register subscription");
dispatch(
&mut transport,
&mut client,
&mut effects,
"subscribe",
&json!({
"id": "stable-subscription",
"table": "todos",
"scopes": { "list_id": ["list-1", "list-2", "list-1"] },
"params": "{\"view\":\"v1\"}"
}),
)
.expect("canonical re-declaration is idempotent");
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"subscribe",
&json!({
"id": "stable-subscription",
"table": "todos",
"scopes": { "list_id": ["list-2"] },
"params": "{\"view\":\"v1\"}"
}),
)
.expect_err("changed native query identity must fail");
assert_eq!(error.code, "client.subscription_intent_mismatch");
let state = dispatch(
&mut transport,
&mut client,
&mut effects,
"subscriptionState",
&json!({ "id": "stable-subscription" }),
)
.expect("subscription state");
assert_eq!(state["state"]["cursor"], -1);
assert_eq!(state["state"]["table"], "todos");
}
#[test]
fn security_preflight_is_fail_closed_until_exact_activation() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true }),
)
.expect("preflight create");
let lifecycle = dispatch(
&mut transport,
&mut client,
&mut effects,
"securityLifecycle",
&json!({}),
)
.expect("lifecycle");
assert_eq!(lifecycle, json!({ "state": "preflight" }));
let query_error = dispatch(
&mut transport,
&mut client,
&mut effects,
"query",
&json!({ "sql": "SELECT id FROM todos", "params": [] }),
)
.expect_err("protected query must fail");
assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
let diagnostics_error = dispatch(
&mut transport,
&mut client,
&mut effects,
"diagnosticsSnapshot",
&json!({}),
)
.expect_err("diagnostics table/subscription evidence remains protected");
assert_eq!(diagnostics_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
dispatch(
&mut transport,
&mut client,
&mut effects,
"purgeLocalData",
&json!({
"input": {
"purgeId": "directive-1",
"targets": [{
"table": "todos",
"selectors": { "list_id": ["list-1"] }
}]
}
}),
)
.expect("authorized local purge remains available");
let repair_error = dispatch(
&mut transport,
&mut client,
&mut effects,
"rebootstrapLocalData",
&json!({ "input": { "rebootstrapId": "blocked-repair" } }),
)
.expect_err("projection repair must remain protected during preflight");
assert_eq!(repair_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
dispatch(
&mut transport,
&mut client,
&mut effects,
"activateSecurity",
&json!({}),
)
.expect("activation");
let rows = dispatch(
&mut transport,
&mut client,
&mut effects,
"query",
&json!({ "sql": "SELECT id FROM todos", "params": [] }),
)
.expect("active query");
assert_eq!(rows, json!({ "rows": [] }));
dispatch(
&mut transport,
&mut client,
&mut effects,
"beginSecurityPreflight",
&json!({}),
)
.expect("re-enter preflight");
let blocked = dispatch(
&mut transport,
&mut client,
&mut effects,
"mutate",
&json!({ "mutations": [] }),
)
.expect_err("mutation must be gated");
assert_eq!(blocked.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
}
#[test]
fn mutate_and_patch_forward_typed_storage_failures() {
let path = temp_db_path("busy-authoring");
let schema = json!({
"version": 1,
"tables": [{
"name": "todos",
"columns": [
{ "name": "id", "type": "string", "nullable": false },
{ "name": "list_id", "type": "string", "nullable": false },
{ "name": "title", "type": "string", "nullable": false }
],
"primaryKey": "id",
"scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
}]
});
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema, "dbPath": path }),
)
.expect("create file-backed client");
let upsert = |id: &str| {
json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
"id": id, "list_id": "l1", "title": "first" } }] })
};
dispatch(
&mut transport,
&mut client,
&mut effects,
"mutate",
&upsert("t1"),
)
.expect("seed row");
let locker = rusqlite::Connection::open(&path).expect("second connection");
locker.execute_batch("BEGIN IMMEDIATE").unwrap();
let busy = dispatch(
&mut transport,
&mut client,
&mut effects,
"mutate",
&upsert("t2"),
)
.expect_err("busy mutate");
assert_eq!(busy.code, "client.storage_busy");
assert!(busy.retryable);
assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
let busy_patch = dispatch(
&mut transport,
&mut client,
&mut effects,
"patch",
&json!({ "table": "todos", "rowId": "t1", "partial": { "title": "second" } }),
)
.expect_err("busy patch");
assert_eq!(busy_patch.code, "client.storage_busy");
assert!(busy_patch.retryable);
assert_eq!(busy_patch.details.as_ref().unwrap()["sqliteCode"], 5);
locker.execute_batch("ROLLBACK").unwrap();
let retried = dispatch(
&mut transport,
&mut client,
&mut effects,
"mutate",
&upsert("t2"),
)
.expect("retry after busy");
assert!(retried.get("clientCommitId").is_some());
let _ = std::fs::remove_file(&path);
}
#[test]
fn read_command_error_preserves_code_details_and_retryability() {
let corrupt = super::read_command_error(syncular_client::QueryReadFailure {
code: Some("sync.local_corrupt"),
sqlite_code: None,
sqlite_message: None,
rollback_failure: None,
message: "persisted local revision is invalid".to_owned(),
});
assert_eq!(corrupt.code, "sync.local_corrupt");
assert!(!corrupt.retryable);
assert!(corrupt.details.is_none());
let busy = super::read_command_error(syncular_client::QueryReadFailure {
code: Some("client.storage_busy"),
sqlite_code: Some(5),
sqlite_message: Some("database is locked".to_owned()),
rollback_failure: None,
message: "local SQLite storage is busy".to_owned(),
});
assert_eq!(busy.code, "client.storage_busy");
assert!(busy.retryable);
assert_eq!(
busy.details.expect("busy details")["sqliteCode"],
serde_json::json!(5)
);
}
#[test]
fn mutate_and_patch_report_caller_value_failures_as_invalid_request() {
let path = temp_db_path("caller-values");
let schema = json!({
"version": 1,
"tables": [{
"name": "todos",
"columns": [
{ "name": "id", "type": "string", "nullable": false },
{ "name": "list_id", "type": "string", "nullable": false },
{ "name": "title", "type": "string", "nullable": false },
{ "name": "priority", "type": "integer", "nullable": false }
],
"primaryKey": "id",
"scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
}]
});
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema, "dbPath": path }),
)
.expect("create file-backed client");
let seed = dispatch(
&mut transport,
&mut client,
&mut effects,
"mutate",
&json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
"id": "t1", "list_id": "l1", "title": "first", "priority": 1 } }] }),
)
.expect("seed row");
let seed_id = seed["clientCommitId"].clone();
let unknown = dispatch(
&mut transport,
&mut client,
&mut effects,
"mutate",
&json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
"id": "t2", "list_id": "l1", "title": "second", "priority": 1,
"missing_column": 1 } }] }),
)
.expect_err("unknown column");
assert_eq!(unknown.code, "sync.invalid_request");
assert_eq!(unknown.message, "the authoring request is invalid");
assert!(!unknown.retryable);
assert!(unknown.details.as_ref().unwrap()["legacyCause"]
.as_str()
.unwrap()
.contains("unknown column"));
let bad_patch = dispatch(
&mut transport,
&mut client,
&mut effects,
"patch",
&json!({ "table": "todos", "rowId": "t1", "partial": { "priority": "high" } }),
)
.expect_err("bad patch value");
assert_eq!(bad_patch.code, "sync.invalid_request");
assert_eq!(bad_patch.message, "the authoring request is invalid");
assert!(bad_patch.details.as_ref().unwrap()["legacyCause"]
.as_str()
.unwrap()
.contains("priority"));
let pending = dispatch(
&mut transport,
&mut client,
&mut effects,
"pendingCommitIds",
&json!({}),
)
.expect("pending commit ids");
assert_eq!(pending["ids"], json!([seed_id]));
let rows = dispatch(
&mut transport,
&mut client,
&mut effects,
"query",
&json!({ "sql": "SELECT id, priority FROM todos ORDER BY id" }),
)
.expect("visible rows");
assert_eq!(rows["rows"], json!([{ "id": "t1", "priority": 1 }]));
let accepted = dispatch(
&mut transport,
&mut client,
&mut effects,
"mutate",
&json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
"id": "t2", "list_id": "l1", "title": "second", "priority": 2 } }] }),
)
.expect("retry after rejection");
assert!(accepted.get("clientCommitId").is_some());
let _ = std::fs::remove_file(&path);
}
#[test]
fn client_err_preserves_retryable_for_storage_busy() {
let busy = client_err("client.storage_busy: local SQLite storage is busy".to_owned());
assert_eq!(busy.code, "client.storage_busy");
assert!(busy.retryable);
assert_eq!(
busy.message,
"client.storage_busy: local SQLite storage is busy"
);
let full = client_err("client.storage_full: local SQLite storage is full".to_owned());
assert_eq!(full.code, "client.storage_full");
assert!(!full.retryable);
}
#[test]
fn sync_until_idle_rejects_an_invalid_max_rounds_budget() {
for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create");
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"syncUntilIdle",
&json!({ "maxRounds": value }),
)
.expect_err("invalid budget");
assert_eq!(error.code, "sync.invalid_request", "{value}");
}
}
#[test]
fn sync_until_idle_rejects_an_invalid_timeout() {
for value in [json!(0), json!(-1), json!(1.5)] {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create");
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"syncUntilIdle",
&json!({ "timeoutMs": value }),
)
.expect_err("invalid timeout");
assert_eq!(error.code, "sync.invalid_request", "{value}");
}
}
#[test]
fn create_rejects_invalid_push_limits() {
for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({
"schema": schema(),
"limits": { "maxPushRequestBytes": value }
}),
)
.expect_err("invalid push limit");
assert_eq!(error.code, "sync.invalid_request", "{value}");
assert_eq!(
error.details.as_ref().unwrap()["limit"],
json!("maxPushRequestBytes")
);
}
}
#[test]
fn preflight_allows_only_the_authorized_previous_version_discard() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true }),
)
.expect("preflight create");
for method in ["previousVersionSnapshot", "previousVersionAudit"] {
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
method,
&json!({}),
)
.expect_err("previous-version reads stay gated during preflight");
assert_eq!(error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
}
let discarded = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionDiscard",
&json!({}),
)
.expect("authorized cleanup is allowed during preflight");
assert_eq!(discarded, json!({ "present": false, "discarded": false }));
assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
}
#[test]
fn preflight_refuses_a_replacement_create_without_the_flag() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true }),
)
.expect("preflight create");
let escape = dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect_err("plain create must be refused during preflight");
assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
let query_error = dispatch(
&mut transport,
&mut client,
&mut effects,
"query",
&json!({ "sql": "SELECT id FROM todos", "params": [] }),
)
.expect_err("protected query stays gated");
assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true }),
)
.expect("preflighted replacement create");
let still_gated = dispatch(
&mut transport,
&mut client,
&mut effects,
"query",
&json!({ "sql": "SELECT id FROM todos", "params": [] }),
)
.expect_err("replacement stays gated");
assert_eq!(still_gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
dispatch(
&mut transport,
&mut client,
&mut effects,
"activateSecurity",
&json!({}),
)
.expect("activation");
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("plain create after activation");
let rows = dispatch(
&mut transport,
&mut client,
&mut effects,
"query",
&json!({ "sql": "SELECT id FROM todos", "params": [] }),
)
.expect("active query");
assert_eq!(rows, json!({ "rows": [] }));
}
fn temp_db_path(tag: &str) -> String {
std::env::temp_dir()
.join(format!(
"syncular-command-{tag}-{}-{:?}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
))
.to_string_lossy()
.into_owned()
}
#[test]
fn an_activated_replica_reopens_plainly_after_shutdown() {
let path = temp_db_path("activated-reopen");
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
)
.expect("preflight create");
dispatch(
&mut transport,
&mut client,
&mut effects,
"activateSecurity",
&json!({}),
)
.expect("activation clears the gate");
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown after activation");
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "dbPath": path }),
)
.expect("an activated replica must reopen plainly after shutdown");
let state = dispatch(
&mut transport,
&mut client,
&mut effects,
"securityLifecycle",
&json!({}),
)
.expect("lifecycle");
assert_eq!(state, json!({ "state": "active" }));
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
let path = temp_db_path("quarantined-reopen");
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
)
.expect("preflight create");
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown during preflight");
let mut rebuilt = CreateEffects::default();
let escape = dispatch(
&mut transport,
&mut client,
&mut rebuilt,
"create",
&json!({ "schema": schema(), "dbPath": path }),
)
.expect_err("a quarantined replica must refuse a plain re-create");
assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
let _ = std::fs::remove_file(&path);
}
#[test]
fn preflight_gate_survives_shutdown_before_replacement_creates() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true }),
)
.expect("preflight create");
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown during preflight");
assert!(client.is_none());
let escape = dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect_err("shutdown + plain create must stay refused");
assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true }),
)
.expect("preflighted re-create");
dispatch(
&mut transport,
&mut client,
&mut effects,
"activateSecurity",
&json!({}),
)
.expect("activation");
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown after activation");
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("plain create after an activated shutdown");
}
#[test]
fn activate_security_validates_optional_headers_atomically() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "securityPreflight": true }),
)
.expect("preflight create");
let invalid = dispatch(
&mut transport,
&mut client,
&mut effects,
"activateSecurity",
&json!({ "headers": { "authorization": 7 } }),
)
.expect_err("non-string header must fail");
assert_eq!(invalid.code, "sync.invalid_request");
let lifecycle = dispatch(
&mut transport,
&mut client,
&mut effects,
"securityLifecycle",
&json!({}),
)
.expect("lifecycle");
assert_eq!(lifecycle, json!({ "state": "preflight" }));
dispatch(
&mut transport,
&mut client,
&mut effects,
"activateSecurity",
&json!({ "headers": { "authorization": "Bearer fresh" } }),
)
.expect("activation with fresh headers");
let lifecycle = dispatch(
&mut transport,
&mut client,
&mut effects,
"securityLifecycle",
&json!({}),
)
.expect("lifecycle");
assert_eq!(lifecycle, json!({ "state": "active" }));
}
#[test]
fn parse_headers_reads_the_full_replacement_set() {
let parsed = parse_headers(&json!({
"authorization": "Bearer fresh",
"x-tenant": "t1"
}))
.expect("valid headers parse");
assert_eq!(
parsed,
vec![
("authorization".to_owned(), "Bearer fresh".to_owned()),
("x-tenant".to_owned(), "t1".to_owned())
]
);
assert!(parse_headers(&json!(["authorization"])).is_err());
assert!(parse_headers(&json!({ "authorization": null })).is_err());
}
#[test]
fn set_headers_validates_replacement_and_respects_preflight() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create");
dispatch(
&mut transport,
&mut client,
&mut effects,
"setHeaders",
&json!({ "headers": { "authorization": "Bearer fresh" } }),
)
.expect("valid replacement");
let invalid = dispatch(
&mut transport,
&mut client,
&mut effects,
"setHeaders",
&json!({ "headers": { "authorization": 7 } }),
)
.expect_err("invalid replacement");
assert_eq!(invalid.code, "sync.invalid_request");
dispatch(
&mut transport,
&mut client,
&mut effects,
"beginSecurityPreflight",
&json!({}),
)
.expect("preflight");
let gated = dispatch(
&mut transport,
&mut client,
&mut effects,
"setHeaders",
&json!({ "headers": {} }),
)
.expect_err("direct rotation must respect preflight");
assert_eq!(gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
}
fn schema_v2() -> Value {
json!({
"version": 2,
"tables": [{
"name": "todos",
"columns": [
{ "name": "id", "type": "string", "nullable": false },
{ "name": "list_id", "type": "string", "nullable": false },
{ "name": "note", "type": "string", "nullable": true }
],
"primaryKey": "id",
"scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
}]
})
}
fn previous_version_enabled() -> Value {
json!({ "enabled": true })
}
fn container_path(db_path: &str) -> String {
format!("{db_path}.prev-context")
}
#[test]
fn previous_version_config_parser_matches_ts_keys_and_defaults() {
assert_eq!(parse_previous_version_context(None).expect("absent"), None);
let defaults = parse_previous_version_context(Some(&json!({})))
.expect("empty")
.expect("present");
assert!(!defaults.enabled);
assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
assert_eq!(defaults.max_rows, 20_000);
assert_eq!(defaults.max_tables, 32);
assert_eq!(defaults.max_row_bytes, 1024 * 1024);
assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
let configured = parse_previous_version_context(Some(&json!({
"enabled": true,
"maxBytes": 1024,
"maxRows": 3,
"maxTables": 2,
"maxRowBytes": 5,
"maxAgeMs": 7
})))
.expect("configured")
.expect("present");
assert!(configured.enabled);
assert_eq!(configured.max_bytes, 1024);
assert_eq!(configured.max_rows, 3);
assert_eq!(configured.max_tables, 2);
assert_eq!(configured.max_row_bytes, 5);
assert_eq!(configured.max_age_ms, 7);
for (value, expected) in [
(json!([]), "must be an object"),
(json!({ "enabled": "yes" }), "enabled must be a boolean"),
(
json!({ "maxBytes": 0 }),
"maxBytes must be a positive safe integer",
),
(
json!({ "maxRows": -1 }),
"maxRows must be a positive safe integer",
),
(
json!({ "maxTables": 1.5 }),
"maxTables must be a positive safe integer",
),
(
json!({ "maxRowBytes": "1" }),
"maxRowBytes must be a positive safe integer",
),
(
json!({ "maxAgeMs": 0 }),
"maxAgeMs must be a positive safe integer",
),
] {
let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
assert_eq!(error.code, "sync.invalid_request", "{value}");
assert!(
error.message.contains(expected),
"{value}: {}",
error.message
);
}
}
#[test]
fn invalid_previous_version_config_fails_create_loudly() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
let error = dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
)
.expect_err("invalid config must fail the create");
assert_eq!(error.code, "sync.invalid_request");
assert!(error.message.contains("maxRows"), "{}", error.message);
assert!(client.is_none(), "no client may be installed");
}
#[test]
fn previous_version_snapshot_reports_not_configured_by_default() {
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({ "schema": schema() }),
)
.expect("create");
let snapshot = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionSnapshot",
&json!({ "table": "todos" }),
)
.expect("snapshot");
assert_eq!(snapshot["state"], json!("previousVersion"));
assert_eq!(snapshot["available"], json!(false));
assert_eq!(snapshot["reason"], json!("not-configured"));
assert_eq!(snapshot["rows"], json!([]));
assert_eq!(snapshot["truncated"], json!(false));
assert_eq!(snapshot["currentVersion"], json!(1));
let discarded = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionDiscard",
&json!({}),
)
.expect("discard");
assert_eq!(discarded, json!({ "present": false, "discarded": false }));
assert_eq!(
dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionAudit",
&json!({}),
)
.expect("audit"),
Value::Null
);
}
#[test]
fn previous_version_commands_round_trip_through_the_dispatcher() {
let path = temp_db_path("previous-version");
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({
"schema": schema(),
"dbPath": path,
"previousVersionContext": previous_version_enabled()
}),
)
.expect("create v1");
client
.as_mut()
.expect("client")
.mutate(vec![Mutation::Upsert {
table: "todos".to_owned(),
values: serde_json::Map::from_iter([
("id".to_owned(), json!("t1")),
("list_id".to_owned(), json!("l1")),
]),
base_version: None,
}])
.expect("seed a local row");
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown");
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({
"schema": schema_v2(),
"dbPath": path,
"previousVersionContext": previous_version_enabled()
}),
)
.expect("create v2");
assert!(
std::path::Path::new(&container_path(&path)).exists(),
"the bump must leave a container file"
);
let status = dispatch(
&mut transport,
&mut client,
&mut effects,
"statusSnapshot",
&json!({}),
)
.expect("status");
assert_eq!(
status["previousVersionContext"]["present"],
json!(true),
"{status}"
);
assert!(
status["previousVersionContext"]["createdAtMs"].is_i64(),
"{status}"
);
let snapshot = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionSnapshot",
&json!({ "table": "todos" }),
)
.expect("snapshot");
assert_eq!(snapshot["state"], json!("previousVersion"));
assert_eq!(snapshot["available"], json!(true));
assert_eq!(snapshot["previousVersion"], json!(1));
assert_eq!(snapshot["currentVersion"], json!(2));
assert_eq!(snapshot["truncated"], json!(false));
assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
let unknown = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionSnapshot",
&json!({ "table": "absent" }),
)
.expect_err("unknown previous table");
assert_eq!(unknown.code, "sync.invalid_request");
let bad_limit = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionSnapshot",
&json!({ "table": "todos", "limit": 0 }),
)
.expect_err("bad limit");
assert_eq!(bad_limit.code, "sync.invalid_request");
let audit = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionAudit",
&json!({}),
)
.expect("audit");
assert_eq!(audit["v"], json!(1));
assert_eq!(audit["fromVersion"], json!(1));
assert_eq!(audit["toVersion"], json!(2));
assert_eq!(audit["pending"], json!(1));
assert_eq!(audit["encodable"], json!(1));
assert_eq!(audit["incompatible"], json!([]));
assert!(audit.get("operations").is_none());
let discarded = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionDiscard",
&json!({}),
)
.expect("discard");
assert_eq!(discarded, json!({ "present": true, "discarded": true }));
assert!(!std::path::Path::new(&container_path(&path)).exists());
let again = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionDiscard",
&json!({}),
)
.expect("idempotent discard");
assert_eq!(again, json!({ "present": false, "discarded": false }));
let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
let remaining: i64 = inspect
.query_row(
"SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
rusqlite::params!["previousVersionContext", "previousVersionAudit"],
|row| row.get(0),
)
.expect("count meta");
assert_eq!(remaining, 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
let path = temp_db_path("previous-version-ttl");
let mut transport = NoNetwork::default();
let mut client: Option<SyncClient> = None;
let mut effects = CreateEffects::default();
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({
"schema": schema(),
"dbPath": path,
"previousVersionContext": previous_version_enabled()
}),
)
.expect("create v1");
dispatch(
&mut transport,
&mut client,
&mut effects,
"shutdown",
&json!({}),
)
.expect("shutdown");
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_millis() as i64
+ 2_000;
dispatch(
&mut transport,
&mut client,
&mut effects,
"create",
&json!({
"schema": schema_v2(),
"dbPath": path,
"nowMs": now_ms,
"previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
}),
)
.expect("create v2");
assert!(std::path::Path::new(&container_path(&path)).exists());
let snapshot = dispatch(
&mut transport,
&mut client,
&mut effects,
"previousVersionSnapshot",
&json!({ "table": "todos" }),
)
.expect("snapshot");
assert_eq!(snapshot["available"], json!(false));
assert_eq!(snapshot["reason"], json!("expired"));
assert_eq!(snapshot["rows"], json!([]));
assert!(!std::path::Path::new(&container_path(&path)).exists());
let _ = std::fs::remove_file(&path);
}
}