Skip to main content

syncular_command/
lib.rs

1//! # syncular-command — one JSON command surface over the Rust client core
2//!
3//! The command router the conformance shim proved (JSON in, JSON out, bytes
4//! as `{"$bytes": hex}`) factored into a transport-agnostic module so BOTH
5//! the stdio conformance shim AND the FFI native core dispatch through the
6//! same code. That keeps a single command surface, conformance-locked via
7//! the shim: whatever the shim exercises, the FFI core inherits.
8//!
9//! The router is generic over the `Transport` seam. The shim binds it to a
10//! stdio host (transport inverted to the harness); the FFI crate binds it to
11//! a real native HTTP+WS transport. Everything host-specific — realtime
12//! notification draining, deferred requests, event queues — stays in each
13//! host; only the pure `method → result` dispatch (and its JSON parsing) is
14//! shared here.
15
16use serde_json::{json, Value};
17use ssp2::segment::{decode_rows_segment, encode_rows_segment};
18use ssp2::{
19    decode_message, encode_message, parse_control, render_message, render_rows_segment,
20    ControlMessage,
21};
22use syncular_client::previous_version::{PreviousVersionContextConfig, PreviousVersionReadSpec};
23use syncular_client::{
24    ClientDiagnosticsRequest, ClientError, ClientLimits, CommandEffects, CommitOutcomeQuery,
25    LocalDataPurgeInput, LocalDataRebootstrapInput, Mutation, QueryOwner, QueryReadFailure,
26    RealtimePolicy, ResolveCommitOutcomeInput, SubscriptionRequest, SyncClient, Transport,
27    WindowBase, WindowCoverage,
28};
29
30// -- bytes <-> {"$bytes": hex} (the driver-protocol byte envelope) ----------
31
32pub fn bytes_to_hex(bytes: &[u8]) -> String {
33    syncular_client::values::bytes_to_hex(bytes)
34}
35
36pub fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
37    syncular_client::values::hex_to_bytes(hex)
38}
39
40pub fn bytes_value(bytes: &[u8]) -> Value {
41    Value::Object(serde_json::Map::from_iter([(
42        "$bytes".to_owned(),
43        Value::from(bytes_to_hex(bytes)),
44    )]))
45}
46
47pub fn value_bytes(value: Option<&Value>) -> Result<Vec<u8>, String> {
48    let hex = value
49        .and_then(|v| v.get("$bytes"))
50        .and_then(Value::as_str)
51        .ok_or_else(|| "expected a {\"$bytes\": hex} value".to_owned())?;
52    hex_to_bytes(hex)
53}
54
55/// The structured failure the driver protocol carries in an `error`. `details`
56/// and `retryable` carry the typed mutation fields to the host; non-mutation
57/// failures leave both at their defaults.
58#[derive(Debug, Clone)]
59pub struct CommandError {
60    pub code: String,
61    pub message: String,
62    pub details: Option<Value>,
63    pub retryable: bool,
64}
65
66impl CommandError {
67    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
68        Self {
69            code: code.into(),
70            message: message.into(),
71            details: None,
72            retryable: false,
73        }
74    }
75
76    /// Consume a structured client authoring failure without re-parsing text.
77    pub fn from_client(error: ClientError) -> Self {
78        Self {
79            code: error.code,
80            message: error.message,
81            details: error.details,
82            retryable: error.retryable,
83        }
84    }
85}
86
87impl From<(String, String)> for CommandError {
88    fn from((code, message): (String, String)) -> Self {
89        Self {
90            code,
91            message,
92            details: None,
93            retryable: false,
94        }
95    }
96}
97
98/// Parsed side effects of a `create` command that the host must apply to its
99/// own transport/clock (the router stays transport-agnostic). The client is
100/// already installed into the `Option<SyncClient>` slot by `dispatch`.
101#[derive(Debug, Default, Clone)]
102pub struct CreateEffects {
103    /// §5.4 capability the harness/host announced for its endpoints — the
104    /// host sets its transport's `supports_url_fetch` accordingly.
105    pub signed_urls: bool,
106    /// True while a security preflight is pending: a preflighted client was
107    /// installed (or entered preflight, or was shut down mid-preflight) and
108    /// `activateSecurity` has yet to complete. `dispatch` maintains this so a
109    /// replacement `create` without the `securityPreflight` flag is refused
110    /// across `shutdown`, where the client slot is empty, on a host that reuses
111    /// one `CreateEffects` across creates (the Tauri plugin, the conformance
112    /// shim, the bench harness).
113    ///
114    /// A host that allocates a fresh `CreateEffects` per create — the React
115    /// Native native module rebuilds its FFI handle on every `create` — starts
116    /// each create with this flag clear. For those hosts the persisted marker
117    /// in the client core carries the gate: a file-backed replica reopens in
118    /// preflight, and the `create` path refuses a plain re-create against it.
119    /// This in-memory flag covers the same-handle case where no file persists
120    /// the marker.
121    pub security_preflight_pending: bool,
122}
123
124/// §7.5: a read failure keeps its structured code, static message, SQLite
125/// details, and retryability across the command boundary.
126fn read_command_error(failure: QueryReadFailure) -> CommandError {
127    let details = failure.details();
128    let retryable = failure.retryable();
129    CommandError {
130        code: failure.code.unwrap_or("client.failed").to_owned(),
131        message: failure.message,
132        details,
133        retryable,
134    }
135}
136
137fn client_err(message: String) -> CommandError {
138    let code = message
139        .split_once(':')
140        .map(|(candidate, _)| candidate)
141        .filter(|candidate| candidate.starts_with("client.") || candidate.starts_with("sync."))
142        .unwrap_or("client.failed");
143    let retryable = code == "client.storage_busy";
144    CommandError {
145        code: code.to_owned(),
146        message,
147        details: None,
148        retryable,
149    }
150}
151
152fn need_client(client: &mut Option<SyncClient>) -> Result<&mut SyncClient, CommandError> {
153    client
154        .as_mut()
155        .ok_or_else(|| client_err("no client instance created".to_owned()))
156}
157
158/// RFC 0005 D8: parse the host's `previousVersionContext` config with the
159/// TS keys and defaults: `enabled` (default false), `maxBytes` 8 MiB,
160/// `maxRows` 20000, `maxTables` 32, `maxRowBytes` 1 MiB, `maxAgeMs` 24h.
161/// Invalid values fail the request loudly rather than silently defaulting.
162pub fn parse_previous_version_context(
163    value: Option<&Value>,
164) -> Result<Option<PreviousVersionContextConfig>, CommandError> {
165    let Some(value) = value else {
166        return Ok(None);
167    };
168    let object = value.as_object().ok_or_else(|| {
169        client_err("sync.invalid_request: previousVersionContext must be an object".to_owned())
170    })?;
171    let enabled = match object.get("enabled") {
172        None => false,
173        Some(Value::Bool(enabled)) => *enabled,
174        Some(_) => {
175            return Err(client_err(
176                "sync.invalid_request: previousVersionContext.enabled must be a boolean".to_owned(),
177            ))
178        }
179    };
180    let mut config = PreviousVersionContextConfig {
181        enabled,
182        ..PreviousVersionContextConfig::default()
183    };
184    for (key, slot) in [
185        ("maxBytes", &mut config.max_bytes),
186        ("maxRows", &mut config.max_rows),
187        ("maxTables", &mut config.max_tables),
188        ("maxRowBytes", &mut config.max_row_bytes),
189        ("maxAgeMs", &mut config.max_age_ms),
190    ] {
191        if let Some(raw) = object.get(key) {
192            *slot = raw.as_i64().ok_or_else(|| {
193                client_err(format!(
194                    "sync.invalid_request: previousVersionContext.{key} must be a positive safe integer"
195                ))
196            })?;
197        }
198    }
199    config.validate().map_err(client_err)?;
200    Ok(Some(config))
201}
202
203/// RFC 0005 D7: `previousVersionSnapshot` params — `{table, rowIds?, limit?}`.
204fn parse_previous_version_snapshot_spec(
205    params: &Value,
206) -> Result<PreviousVersionReadSpec, CommandError> {
207    let table = params.get("table").and_then(Value::as_str).ok_or_else(|| {
208        client_err("sync.invalid_request: previousVersionSnapshot missing table".to_owned())
209    })?;
210    let row_ids = match params.get("rowIds") {
211        None | Some(Value::Null) => Vec::new(),
212        Some(Value::Array(ids)) => ids
213            .iter()
214            .map(|id| match id {
215                Value::String(id) => Ok(id.clone()),
216                Value::Number(number) => Ok(number.to_string()),
217                Value::Bool(boolean) => Ok(boolean.to_string()),
218                _ => Err(client_err(
219                    "sync.invalid_request: previousVersionSnapshot rowIds must be strings"
220                        .to_owned(),
221                )),
222            })
223            .collect::<Result<Vec<_>, _>>()?,
224        Some(_) => {
225            return Err(client_err(
226                "sync.invalid_request: previousVersionSnapshot rowIds must be an array".to_owned(),
227            ))
228        }
229    };
230    let limit = match params.get("limit") {
231        None | Some(Value::Null) => None,
232        Some(value) => Some(value.as_i64().ok_or_else(|| {
233            client_err(
234                "sync.invalid_request: previousVersionSnapshot limit must be a positive number"
235                    .to_owned(),
236            )
237        })?),
238    };
239    Ok(PreviousVersionReadSpec {
240        table: table.to_owned(),
241        row_ids,
242        limit,
243    })
244}
245
246pub fn parse_limits(value: Option<&Value>) -> Result<ClientLimits, CommandError> {
247    let mut limits = ClientLimits::default();
248    let Some(object) = value.and_then(Value::as_object) else {
249        return Ok(limits);
250    };
251    limits.limit_commits = object
252        .get("limitCommits")
253        .and_then(Value::as_i64)
254        .map(|v| v as i32);
255    limits.limit_snapshot_rows = object
256        .get("limitSnapshotRows")
257        .and_then(Value::as_i64)
258        .map(|v| v as i32);
259    limits.max_snapshot_pages = object
260        .get("maxSnapshotPages")
261        .and_then(Value::as_i64)
262        .map(|v| v as i32);
263    limits.accept = object
264        .get("accept")
265        .and_then(Value::as_u64)
266        .map(|v| v as u8);
267    limits.blob_cache_max_bytes = object.get("blobCacheMaxBytes").and_then(Value::as_i64);
268    limits.outcome_retention_max_entries = object
269        .get("outcomeRetentionMaxEntries")
270        .and_then(Value::as_u64)
271        .map(|value| value as usize);
272    limits.max_push_commits_per_request = parse_push_limit(object, "maxPushCommitsPerRequest")?;
273    limits.max_push_operations_per_request =
274        parse_push_limit(object, "maxPushOperationsPerRequest")?;
275    limits.max_push_request_bytes = parse_push_limit(object, "maxPushRequestBytes")?;
276    Ok(limits)
277}
278
279/// §7.1: a configured push limit is a positive integer. Null or absent uses
280/// the default for that limit; invalid values fail with a structured error.
281fn parse_push_limit(
282    object: &serde_json::Map<String, Value>,
283    key: &str,
284) -> Result<Option<usize>, CommandError> {
285    match object.get(key) {
286        None | Some(Value::Null) => Ok(None),
287        Some(value) => value
288            .as_u64()
289            .and_then(|n| usize::try_from(n).ok())
290            .filter(|n| *n >= 1 && *n <= u32::MAX as usize)
291            .map(Some)
292            .ok_or_else(|| CommandError {
293                code: "sync.invalid_request".to_owned(),
294                message: "push limit must be an integer in 1..=4294967295".to_owned(),
295                details: Some(serde_json::json!({ "limit": key })),
296                retryable: false,
297            }),
298    }
299}
300
301/// §8.8: parse the `realtimePolicy` create param. Absent ⇒ `optional`.
302fn parse_realtime_policy(value: Option<&Value>) -> Result<RealtimePolicy, CommandError> {
303    match value {
304        None => Ok(RealtimePolicy::Optional),
305        Some(Value::String(text)) => match text.as_str() {
306            "required" => Ok(RealtimePolicy::Required),
307            "optional" => Ok(RealtimePolicy::Optional),
308            "off" => Ok(RealtimePolicy::Off),
309            other => Err(client_err(format!(
310                "sync.invalid_request: realtimePolicy must be 'required', 'optional', or 'off', got {other:?}"
311            ))),
312        },
313        Some(_) => Err(client_err(
314            "sync.invalid_request: realtimePolicy must be a string".to_owned(),
315        )),
316    }
317}
318
319/// §5.11: parse the `encryption` config into the client's portable keyring.
320/// Shape: `{ keys: { "<keyId>": {"$bytes": "<hex>"} },
321/// keyIdColumns: { "<table>": "<column>" } }`. Keys are 32 bytes.
322pub fn parse_encryption(
323    value: &Value,
324) -> Result<syncular_client::values::EncryptionConfig, String> {
325    let mut config = syncular_client::values::EncryptionConfig::default();
326    let Some(keys) = value.get("keys").and_then(Value::as_object) else {
327        return Ok(config);
328    };
329    for (key_id, key_val) in keys {
330        let bytes =
331            value_bytes(Some(key_val)).map_err(|e| format!("encryption key {key_id:?}: {e}"))?;
332        if bytes.len() != 32 {
333            return Err(format!(
334                "encryption key {key_id:?} must be 32 bytes, got {}",
335                bytes.len()
336            ));
337        }
338        config.keys.insert(key_id.clone(), bytes);
339    }
340    if let Some(columns) = value.get("keyIdColumns") {
341        let columns = columns
342            .as_object()
343            .ok_or_else(|| "encryption keyIdColumns must be an object".to_owned())?;
344        for (table, column) in columns {
345            let column = column.as_str().ok_or_else(|| {
346                format!("encryption keyIdColumns entry for {table:?} must be a string")
347            })?;
348            if column.is_empty() {
349                return Err(format!(
350                    "encryption keyIdColumns entry for {table:?} must not be empty"
351                ));
352            }
353            config
354                .key_id_columns
355                .insert(table.clone(), column.to_owned());
356        }
357    }
358    Ok(config)
359}
360
361/// Parse an `activateSecurity` (or rotation) `headers` param: an object of
362/// string values, replacing the transport's FULL header set.
363/// The router validates the shape at the shared chokepoint; each host applies
364/// the parsed set to its own transport (the router stays transport-agnostic).
365pub fn parse_headers(value: &Value) -> Result<Vec<(String, String)>, String> {
366    let object = value.as_object().ok_or_else(|| {
367        "sync.invalid_request: headers must be an object of string values".to_owned()
368    })?;
369    let mut headers = Vec::with_capacity(object.len());
370    for (name, value) in object {
371        let value = value
372            .as_str()
373            .ok_or_else(|| format!("sync.invalid_request: header {name:?} must be a string"))?;
374        headers.push((name.clone(), value.to_owned()));
375    }
376    Ok(headers)
377}
378
379pub fn parse_mutations(value: Option<&Value>) -> Result<Vec<Mutation>, String> {
380    let list = value
381        .and_then(Value::as_array)
382        .ok_or_else(|| "mutations must be a list".to_owned())?;
383    let mut out = Vec::with_capacity(list.len());
384    for entry in list {
385        let op = entry
386            .get("op")
387            .and_then(Value::as_str)
388            .ok_or_else(|| "mutation missing op".to_owned())?;
389        let table = entry
390            .get("table")
391            .and_then(Value::as_str)
392            .ok_or_else(|| "mutation missing table".to_owned())?
393            .to_owned();
394        let base_version = entry.get("baseVersion").and_then(Value::as_i64);
395        match op {
396            "upsert" | "patch" => {
397                let mut values = entry
398                    .get("values")
399                    .and_then(Value::as_object)
400                    .cloned()
401                    .ok_or_else(|| "upsert missing values".to_owned())?;
402                decode_bigint_members(&mut values)?;
403                out.push(if op == "patch" {
404                    Mutation::Patch {
405                        table,
406                        values,
407                        base_version,
408                    }
409                } else {
410                    Mutation::Upsert {
411                        table,
412                        values,
413                        base_version,
414                    }
415                });
416            }
417            "delete" => {
418                let row_id = entry
419                    .get("rowId")
420                    .and_then(Value::as_str)
421                    .ok_or_else(|| "delete missing rowId".to_owned())?
422                    .to_owned();
423                out.push(Mutation::Delete {
424                    table,
425                    row_id,
426                    base_version,
427                });
428            }
429            other => return Err(format!("unknown mutation op {other:?}")),
430        }
431    }
432    Ok(out)
433}
434
435fn decode_bigint_members(values: &mut serde_json::Map<String, Value>) -> Result<(), String> {
436    for value in values.values_mut() {
437        let Some(decimal) = value.get("$bigint").and_then(Value::as_str) else {
438            continue;
439        };
440        let integer = decimal
441            .parse::<i64>()
442            .map_err(|_| format!("bigint value {decimal:?} is outside SQLite's i64 range"))?;
443        *value = Value::from(integer);
444    }
445    Ok(())
446}
447
448fn scopes_from_params(value: Option<&Value>) -> Result<Vec<(String, Vec<String>)>, String> {
449    match value {
450        Some(v) => syncular_client::values::json_to_scope_map(v),
451        None => Ok(Vec::new()),
452    }
453}
454
455/// Parse one `{ id, table, scopes?, params? }` registration.
456fn subscription_from_params(value: &Value) -> Result<SubscriptionRequest, String> {
457    let id = value
458        .get("id")
459        .and_then(Value::as_str)
460        .ok_or_else(|| "subscribe missing id".to_owned())?
461        .to_owned();
462    let table = value
463        .get("table")
464        .and_then(Value::as_str)
465        .ok_or_else(|| "subscribe missing table".to_owned())?
466        .to_owned();
467    Ok(SubscriptionRequest {
468        id,
469        table,
470        scopes: scopes_from_params(value.get("scopes"))?,
471        params: value
472            .get("params")
473            .and_then(Value::as_str)
474            .map(str::to_owned),
475    })
476}
477
478/// §4.8: parse a window base descriptor `{ table, variable, fixedScopes?,
479/// params? }` from a command's `base` param.
480fn window_base_from_params(value: Option<&Value>) -> Result<WindowBase, String> {
481    let object = value
482        .and_then(Value::as_object)
483        .ok_or_else(|| "setWindow/windowState missing base object".to_owned())?;
484    let table = object
485        .get("table")
486        .and_then(Value::as_str)
487        .ok_or_else(|| "window base missing table".to_owned())?
488        .to_owned();
489    let variable = object
490        .get("variable")
491        .and_then(Value::as_str)
492        .ok_or_else(|| "window base missing variable".to_owned())?
493        .to_owned();
494    let fixed_scopes = scopes_from_params(object.get("fixedScopes"))?;
495    let params = object
496        .get("params")
497        .and_then(Value::as_str)
498        .map(str::to_owned);
499    Ok(WindowBase {
500        table,
501        variable,
502        fixed_scopes,
503        params,
504    })
505}
506
507/// §7.5: parse a `coverage` array of `{base, units}` entries. Absent or null is
508/// empty; a non-array, a malformed base, or non-string units are rejected
509/// explicitly instead of silently defaulting.
510fn parse_window_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
511    let entries = match value {
512        None | Some(Value::Null) => return Ok(Vec::new()),
513        Some(Value::Array(entries)) => entries,
514        Some(_) => return Err("coverage must be a list".to_owned()),
515    };
516    let mut coverage = Vec::with_capacity(entries.len());
517    for entry in entries {
518        let units = match entry.get("units") {
519            None | Some(Value::Null) => Vec::new(),
520            Some(Value::Array(values)) => values
521                .iter()
522                .map(|value| value.as_str().map(str::to_owned))
523                .collect::<Option<Vec<_>>>()
524                .ok_or_else(|| "coverage units must be a list of strings".to_owned())?,
525            Some(_) => return Err("coverage units must be a list of strings".to_owned()),
526        };
527        coverage.push(WindowCoverage {
528            base: window_base_from_params(entry.get("base"))?,
529            units,
530        });
531    }
532    Ok(coverage)
533}
534
535/// §7.5 owner: `{id, tables}`, both required when present. Shared by
536/// `querySnapshot` and `snapshotRead`.
537fn parse_query_owner<'a>(
538    params: &'a Value,
539    method: &str,
540) -> Result<Option<(&'a str, Vec<&'a str>)>, CommandError> {
541    match params.get("owner") {
542        None | Some(Value::Null) => Ok(None),
543        Some(value) => {
544            let id = value
545                .get("id")
546                .and_then(Value::as_str)
547                .filter(|id| !id.is_empty());
548            let tables = value
549                .get("tables")
550                .and_then(Value::as_array)
551                .and_then(|list| {
552                    list.iter()
553                        .map(Value::as_str)
554                        .collect::<Option<Vec<&str>>>()
555                });
556            match (id, tables) {
557                (Some(id), Some(tables)) => Ok(Some((id, tables))),
558                _ => Err(client_err(format!(
559                    "sync.invalid_request: {method} owner must be {{id: non-empty string, tables: string[]}}"
560                ))),
561            }
562        }
563    }
564}
565
566/// §7.5: parse a `subscriptions`/`commitIds` list. Absent or null is empty; a
567/// non-array or a non-string entry is rejected explicitly.
568fn snapshot_string_list(value: Option<&Value>) -> Result<Vec<String>, CommandError> {
569    match value {
570        None | Some(Value::Null) => Ok(Vec::new()),
571        Some(Value::Array(items)) => items
572            .iter()
573            .map(|item| item.as_str().map(str::to_owned))
574            .collect::<Option<Vec<String>>>()
575            .ok_or_else(|| {
576                client_err(
577                    "sync.invalid_request: snapshotRead list must contain strings".to_owned(),
578                )
579            }),
580        Some(_) => Err(client_err(
581            "sync.invalid_request: snapshotRead list must be a list of strings".to_owned(),
582        )),
583    }
584}
585
586/// §5.10.5: parse the common `(table, rowId, column, name)` target of a crdt
587/// command. `name` selects the shared type inside the doc (default `"text"`,
588/// matching the TS `YjsColumn.text()` default).
589#[cfg(feature = "crdt-yjs")]
590fn crdt_target(params: &Value) -> Result<(String, String, String, String), CommandError> {
591    let table = params
592        .get("table")
593        .and_then(Value::as_str)
594        .ok_or_else(|| client_err("crdt command missing table".to_owned()))?
595        .to_owned();
596    let row_id = params
597        .get("rowId")
598        .and_then(Value::as_str)
599        .ok_or_else(|| client_err("crdt command missing rowId".to_owned()))?
600        .to_owned();
601    let column = params
602        .get("column")
603        .and_then(Value::as_str)
604        .ok_or_else(|| client_err("crdt command missing column".to_owned()))?
605        .to_owned();
606    let name = params
607        .get("name")
608        .and_then(Value::as_str)
609        .unwrap_or("text")
610        .to_owned();
611    Ok((table, row_id, column, name))
612}
613
614/// Dispatch one command against the client instance over `transport`.
615///
616/// The `create` command installs a fresh `SyncClient` into `client` and
617/// returns its parsed `CreateEffects` in the `Ok` result via `effects`; every
618/// other command mutates the existing instance. Errors come back as the
619/// driver-protocol `(code, message)` pair.
620///
621/// Generic over `T: Transport` so the shim (host-inverted transport) and the
622/// FFI core (native HTTP+WS transport) share this exact router.
623pub fn dispatch<T: Transport>(
624    transport: &mut T,
625    client: &mut Option<SyncClient>,
626    effects: &mut CreateEffects,
627    method: &str,
628    params: &Value,
629) -> Result<Value, CommandError> {
630    if method == "create" {
631        // Fail-closed against a compromised webview re-issuing `create` (or
632        // `shutdown` + `create`) WITHOUT the securityPreflight flag to exit
633        // the gate: while a preflight is pending — on the live client, or
634        // carried in `effects` across a `shutdown` — a replacement create must
635        // itself request securityPreflight; the gate opens only through
636        // activateSecurity.
637        let requests_preflight = params
638            .get("securityPreflight")
639            .and_then(Value::as_bool)
640            .unwrap_or(false);
641        let preflight_engaged = client.as_ref().map_or(
642            effects.security_preflight_pending,
643            SyncClient::security_preflight,
644        );
645        if preflight_engaged && !requests_preflight {
646            return Err(CommandError::new(
647                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
648                "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
649            ));
650        }
651    } else {
652        let allowed_during_preflight = matches!(
653            method,
654            "authoritySnapshot"
655                | "securityLifecycle"
656                | "beginSecurityPreflight"
657                | "activateSecurity"
658                | "setTransportEnabled"
659                | "purgeLocalData"
660                | "previousVersionDiscard"
661                | "localRevision"
662                | "statusSnapshot"
663                | "shutdown"
664        );
665        if client
666            .as_ref()
667            .is_some_and(|running| running.security_preflight())
668            && !allowed_during_preflight
669        {
670            return Err(CommandError::new(
671                syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
672                "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data",
673            ));
674        }
675    }
676    let mut result = match method {
677        "create" => {
678            let client_id = params
679                .get("clientId")
680                .and_then(Value::as_str)
681                .map(str::to_owned);
682            let schema = params
683                .get("schema")
684                .ok_or_else(|| client_err("create missing schema".to_owned()))?;
685            let limits = parse_limits(params.get("limits"))?;
686            // RFC 0005 D8: the feature config rides the top-level key the TS
687            // client uses; the Rust core carries it on `ClientLimits` so it is
688            // resolved before the opening schema reset runs.
689            let previous_version_context =
690                parse_previous_version_context(params.get("previousVersionContext"))?;
691            let mut limits = limits;
692            limits.previous_version_context = previous_version_context;
693            if let Some(reads) = params.get("authorityReads") {
694                limits.authority_reads = serde_json::from_value(reads.clone()).map_err(|_| {
695                    client_err(
696                        "client.authority_read_forbidden: invalid authority declaration".into(),
697                    )
698                })?;
699            }
700            // §native: a `dbPath` installs a file-backed rusqlite connection so
701            // native hosts (Tauri plugin, FFI file variant) persist across
702            // restarts; absent it, the default in-memory core (the shim's mode).
703            let mut instance = match params.get("dbPath").and_then(Value::as_str) {
704                Some(path) => SyncClient::open_path_with_identity(client_id, schema, limits, path)
705                    .map_err(client_err)?,
706                None => {
707                    SyncClient::new_with_identity(client_id, schema, limits).map_err(client_err)?
708                }
709            };
710            instance
711                .set_retain_failed_commits(
712                    params
713                        .get("retainFailedCommits")
714                        .and_then(Value::as_bool)
715                        .unwrap_or(false),
716                )
717                .map_err(client_err)?;
718            // §8.8: the realtime policy rides the create params; absent ⇒
719            // `optional` (today's socket-or-HTTP behavior).
720            let realtime_policy = parse_realtime_policy(params.get("realtimePolicy"))?;
721            instance
722                .set_realtime_policy(realtime_policy)
723                .map_err(client_err)?;
724            // Harness clock pin (§5.4 expiry runs on the virtual clock).
725            if let Some(now_ms) = params.get("nowMs").and_then(Value::as_i64) {
726                instance.set_now_ms(now_ms);
727            }
728            // §5.4 capability of the host endpoint set (accept bit 3) — the
729            // host applies it to its own transport.
730            effects.signed_urls = params
731                .get("signedUrls")
732                .and_then(Value::as_bool)
733                .unwrap_or(false);
734            // §5.11: install client-side encryption keys. Shape:
735            // { encryption: { keys: { "<keyId>": {"$bytes": "<hex>"} },
736            //                 keyIdColumns: { "<table>": "<column>" } } }.
737            let security_preflight = params
738                .get("securityPreflight")
739                .and_then(Value::as_bool)
740                .unwrap_or(false);
741            // A file-backed replica reopens in preflight when its persisted
742            // quarantine marker is set (client core restores it). Refuse a plain
743            // re-create so a rebuilt host handle — the React Native native module
744            // tears its FFI handle down on every create — cannot downgrade a
745            // quarantined replica to active. A create that itself requests
746            // securityPreflight, or an activated replica whose marker cleared,
747            // proceeds.
748            if instance.security_preflight() && !security_preflight {
749                return Err(CommandError::new(
750                    syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
751                    "the local replica is in security preflight; a replacement create must itself request securityPreflight, and protected data opens only after activateSecurity",
752                ));
753            }
754            if security_preflight && params.get("encryption").is_some() {
755                return Err(client_err(
756                    "sync.invalid_request: securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight"
757                        .to_owned(),
758                ));
759            }
760            if let Some(enc) = params.get("encryption") {
761                let config = parse_encryption(enc).map_err(client_err)?;
762                instance.set_encryption(config);
763            }
764            if security_preflight {
765                instance.begin_security_preflight();
766            }
767            if let Some(enabled) = params.get("transportEnabled") {
768                let enabled = enabled.as_bool().ok_or_else(|| {
769                    client_err("sync.invalid_request: transportEnabled must be a boolean".into())
770                })?;
771                instance.set_transport_enabled(transport, enabled);
772            }
773            effects.security_preflight_pending = security_preflight;
774            *client = Some(instance);
775            Ok(json!({}))
776        }
777        "securityLifecycle" => Ok(json!({
778            "state": need_client(client)?.security_lifecycle()
779        })),
780        "beginSecurityPreflight" => {
781            let running = need_client(client)?;
782            running.disconnect_realtime(transport);
783            running.begin_security_preflight();
784            effects.security_preflight_pending = true;
785            Ok(json!({}))
786        }
787        "authoritySnapshot" => {
788            if !params.as_object().is_some_and(|p| p.is_empty()) {
789                return Err(client_err(
790                    "client.authority_read_forbidden: authoritySnapshot accepts no arguments"
791                        .into(),
792                ));
793            }
794            client
795                .as_ref()
796                .ok_or_else(|| client_err("client.closed: no authority client is open".into()))?
797                .authority_snapshot()
798                .map_err(client_err)
799        }
800        "activateSecurity" => {
801            let encryption = match params.get("encryption") {
802                Some(value) => parse_encryption(value).map_err(client_err)?,
803                None => syncular_client::values::EncryptionConfig::default(),
804            };
805            // Optional fresh transport headers ride the activation atomically,
806            // so a preflight that outlives the boot token starts its first
807            // sync round with valid credentials. Validated here at the shared
808            // chokepoint (invalid input keeps the gate closed); the host
809            // applies the parsed set to its own transport.
810            if let Some(headers) = params.get("headers") {
811                parse_headers(headers).map_err(client_err)?;
812            }
813            need_client(client)?
814                .activate_security(encryption)
815                .map_err(client_err)?;
816            effects.security_preflight_pending = false;
817            Ok(json!({}))
818        }
819        "setTransportEnabled" => {
820            let enabled = params
821                .get("enabled")
822                .and_then(Value::as_bool)
823                .ok_or_else(|| {
824                    client_err(
825                        "sync.invalid_request: setTransportEnabled.enabled must be a boolean"
826                            .into(),
827                    )
828                })?;
829            need_client(client)?.set_transport_enabled(transport, enabled);
830            Ok(json!({}))
831        }
832        "setHeaders" => {
833            let headers = params
834                .get("headers")
835                .ok_or_else(|| client_err("setHeaders missing headers".to_owned()))?;
836            parse_headers(headers).map_err(client_err)?;
837            Ok(json!({}))
838        }
839        "shutdown" => {
840            if let Some(running) = client.as_mut() {
841                // Capture the gate state BEFORE the shutdown barrier flips the
842                // client into preflight: an unactivated preflight stays
843                // pending across the shutdown, while an activated client may
844                // be recreated plainly afterwards.
845                effects.security_preflight_pending = running.security_preflight();
846                running.disconnect_realtime(transport);
847                // Teardown barrier only: an activated client that shuts down
848                // cleanly must not leave a durable quarantine mark, or its next
849                // plain create would be refused forever.
850                running.seal_security_on_teardown();
851            }
852            *client = None;
853            Ok(json!({}))
854        }
855        "subscribe" => {
856            need_client(client)?
857                .subscribe_many(vec![subscription_from_params(params).map_err(client_err)?])
858                .map_err(client_err)?;
859            Ok(json!({}))
860        }
861        "subscribeMany" => {
862            let requests = params
863                .get("subscriptions")
864                .and_then(Value::as_array)
865                .ok_or_else(|| client_err("subscribeMany missing subscriptions".to_owned()))?
866                .iter()
867                .map(subscription_from_params)
868                .collect::<Result<Vec<_>, _>>()
869                .map_err(client_err)?;
870            need_client(client)?
871                .subscribe_many(requests)
872                .map_err(client_err)?;
873            Ok(json!({}))
874        }
875        "unsubscribe" => {
876            let id = params
877                .get("id")
878                .and_then(Value::as_str)
879                .ok_or_else(|| client_err("unsubscribe missing id".to_owned()))?;
880            need_client(client)?.unsubscribe(id);
881            Ok(json!({}))
882        }
883        "setWindow" => {
884            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
885            let units: Vec<String> = params
886                .get("units")
887                .and_then(Value::as_array)
888                .map(|arr| {
889                    arr.iter()
890                        .filter_map(|v| v.as_str().map(str::to_owned))
891                        .collect()
892                })
893                .unwrap_or_default();
894            let command_effects = need_client(client)?
895                .set_window(&base, &units)
896                .map_err(client_err)?;
897            Ok(json!({ "effects": command_effects }))
898        }
899        "windowState" => {
900            let base = window_base_from_params(params.get("base")).map_err(client_err)?;
901            let state = need_client(client)?.window_state(&base);
902            Ok(json!({ "units": state.units, "pending": state.pending }))
903        }
904        "timeWindowSugar" => {
905            let created_at_ms = params
906                .get("createdAtMs")
907                .and_then(Value::as_i64)
908                .ok_or_else(|| client_err("timeWindowSugar missing createdAtMs".to_owned()))?;
909            let count = params
910                .get("count")
911                .and_then(Value::as_u64)
912                .and_then(|value| usize::try_from(value).ok())
913                .ok_or_else(|| client_err("timeWindowSugar missing count".to_owned()))?;
914            let now_ms = params
915                .get("nowMs")
916                .and_then(Value::as_i64)
917                .ok_or_else(|| client_err("timeWindowSugar missing nowMs".to_owned()))?;
918            if params.get("unit").and_then(Value::as_str) != Some("month") {
919                return Err(client_err(
920                    "sync.invalid_request: timeWindowSugar requires the month unit".to_owned(),
921                ));
922            }
923            let unit = syncular_client::TimeBucketUnit::Month;
924            Ok(json!({
925                "bucket": syncular_client::creation_time_bucket(created_at_ms, unit)
926                    .map_err(client_err)?,
927                "units": syncular_client::last(count, unit, now_ms).map_err(client_err)?,
928            }))
929        }
930        "mutate" => {
931            let mutations = parse_mutations(params.get("mutations")).map_err(client_err)?;
932            let id = need_client(client)?
933                .mutate(mutations)
934                .map_err(CommandError::from_client)?;
935            Ok(json!({
936                "clientCommitId": id,
937                "effects": CommandEffects::interactive()
938            }))
939        }
940        "patch" => {
941            let table = params
942                .get("table")
943                .and_then(Value::as_str)
944                .ok_or_else(|| client_err("patch missing table".to_owned()))?;
945            let row_id = params
946                .get("rowId")
947                .and_then(Value::as_str)
948                .ok_or_else(|| client_err("patch missing rowId".to_owned()))?;
949            let mut partial = params
950                .get("partial")
951                .and_then(Value::as_object)
952                .cloned()
953                .ok_or_else(|| client_err("patch missing partial object".to_owned()))?;
954            decode_bigint_members(&mut partial).map_err(client_err)?;
955            let base_version = params.get("baseVersion").and_then(Value::as_i64);
956            let id = need_client(client)?
957                .patch(table, row_id, partial, base_version)
958                .map_err(CommandError::from_client)?;
959            Ok(json!({
960                "clientCommitId": id,
961                "effects": CommandEffects::interactive()
962            }))
963        }
964        "purgeLocalData" => {
965            let input: LocalDataPurgeInput =
966                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
967                    client_err("sync.invalid_request: purgeLocalData missing input".to_owned())
968                })?)
969                .map_err(|error| {
970                    client_err(format!(
971                        "sync.invalid_request: invalid purgeLocalData input: {error}"
972                    ))
973                })?;
974            let result = need_client(client)?
975                .purge_local_data(&input)
976                .map_err(client_err)?;
977            serde_json::to_value(result).map_err(|error| client_err(error.to_string()))
978        }
979        "rebootstrapLocalData" => {
980            let input: LocalDataRebootstrapInput =
981                serde_json::from_value(params.get("input").cloned().ok_or_else(|| {
982                    client_err(
983                        "sync.invalid_request: rebootstrapLocalData missing input".to_owned(),
984                    )
985                })?)
986                .map_err(|error| {
987                    client_err(format!(
988                        "sync.invalid_request: invalid rebootstrapLocalData input: {error}"
989                    ))
990                })?;
991            let result = need_client(client)?
992                .rebootstrap_local_data(&input)
993                .map_err(client_err)?;
994            Ok(json!({
995                "alreadyApplied": result.already_applied,
996                "retainedCommits": result.retained_commits,
997                "resetSubscriptions": result.reset_subscriptions,
998                "effects": if result.already_applied {
999                    CommandEffects::none()
1000                } else {
1001                    CommandEffects::interactive()
1002                }
1003            }))
1004        }
1005        "sync" => {
1006            let outcome = need_client(client)?.sync(transport);
1007            Ok(outcome.to_json())
1008        }
1009        "resetClientId" | "reset_client_id" => {
1010            let client_id = need_client(client)?.reset_client_id(transport);
1011            Ok(json!({ "clientId": client_id }))
1012        }
1013        "syncUntilIdle" => {
1014            let max_rounds = match params.get("maxRounds") {
1015                None | Some(Value::Null) => None,
1016                Some(value) => Some(
1017                    value
1018                        .as_u64()
1019                        .and_then(|n| u32::try_from(n).ok())
1020                        .filter(|n| *n >= 1)
1021                        .ok_or_else(|| {
1022                            client_err(
1023                                "sync.invalid_request: maxRounds must be a positive integer"
1024                                    .to_owned(),
1025                            )
1026                        })?,
1027                ),
1028            };
1029            let timeout_ms = match params.get("timeoutMs") {
1030                None | Some(Value::Null) => None,
1031                Some(value) => Some(value.as_u64().filter(|ms| *ms >= 1).ok_or_else(|| {
1032                    client_err(
1033                        "sync.invalid_request: timeoutMs must be a positive integer".to_owned(),
1034                    )
1035                })?),
1036            };
1037            let client = need_client(client)?;
1038            let outcome = match timeout_ms {
1039                Some(timeout_ms) => {
1040                    client.sync_until_idle_with_deadline(transport, max_rounds, Some(timeout_ms))
1041                }
1042                None => client.sync_until_idle(transport, max_rounds),
1043            };
1044            Ok(outcome.to_json())
1045        }
1046        "readRows" => {
1047            let table = params
1048                .get("table")
1049                .and_then(Value::as_str)
1050                .ok_or_else(|| client_err("readRows missing table".to_owned()))?;
1051            let rows = need_client(client)?.read_rows(table).map_err(client_err)?;
1052            Ok(json!({ "rows": rows }))
1053        }
1054        "query" => {
1055            // The React `useSyncQuery` live-query fast path: arbitrary read-only
1056            // SQL over the local visible tables/views. Params ride as the driver
1057            // value forms (bytes as `{"$bytes": hex}`); rows come back the same.
1058            let sql = params
1059                .get("sql")
1060                .and_then(Value::as_str)
1061                .ok_or_else(|| client_err("query missing sql".to_owned()))?;
1062            let bind = match params.get("params") {
1063                Some(Value::Array(list)) => list.as_slice(),
1064                None | Some(Value::Null) => &[],
1065                Some(_) => return Err(client_err("query params must be a list".to_owned())),
1066            };
1067            let rows = need_client(client)?.query(sql, bind).map_err(client_err)?;
1068            Ok(Value::Object(serde_json::Map::from_iter([(
1069                "rows".to_owned(),
1070                Value::Array(rows.into_iter().map(Value::Object).collect()),
1071            )])))
1072        }
1073        "querySnapshot" => {
1074            let sql = params
1075                .get("sql")
1076                .and_then(Value::as_str)
1077                .ok_or_else(|| client_err("querySnapshot missing sql".to_owned()))?;
1078            let bind = match params.get("params") {
1079                Some(Value::Array(list)) => list.as_slice(),
1080                None | Some(Value::Null) => &[],
1081                Some(_) => {
1082                    return Err(client_err("querySnapshot params must be a list".to_owned()))
1083                }
1084            };
1085            let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1086            let owner = parse_query_owner(params, "querySnapshot")?;
1087            let mut snapshot = need_client(client)?
1088                .query_snapshot(
1089                    sql,
1090                    bind,
1091                    &coverage,
1092                    owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1093                )
1094                .map_err(client_err)?;
1095            let rows = std::mem::take(&mut snapshot.rows);
1096            let mut result = serde_json::to_value(snapshot).expect("snapshot serializes");
1097            result["rows"] = Value::Array(rows.into_iter().map(Value::Object).collect());
1098            Ok(result)
1099        }
1100        "localRevision" => {
1101            // §7.5: the public revision read is fallible. An empty shared
1102            // snapshot read reports a corrupt marker instead of swallowing it
1103            // to zero. The internal infallible counter stays for bookkeeping.
1104            let read = need_client(client)?
1105                .snapshot_read(&syncular_client::SnapshotReadRequest::default(), None)
1106                .map_err(read_command_error)?;
1107            Ok(json!({ "revision": read.revision }))
1108        }
1109        "snapshotRead" => {
1110            let mut statement_sql: Vec<String> = Vec::new();
1111            let mut statement_params: Vec<Vec<Value>> = Vec::new();
1112            match params.get("statements") {
1113                None | Some(Value::Null) => {}
1114                Some(Value::Array(items)) => {
1115                    for item in items {
1116                        let Some(sql) = item.get("sql").and_then(Value::as_str) else {
1117                            return Err(client_err(
1118                                "sync.invalid_request: snapshotRead statements need a sql string"
1119                                    .to_owned(),
1120                            ));
1121                        };
1122                        let bind = match item.get("params") {
1123                            None | Some(Value::Null) => Vec::new(),
1124                            Some(Value::Array(values)) => values.clone(),
1125                            Some(_) => {
1126                                return Err(client_err(
1127                                    "sync.invalid_request: snapshotRead statement params must be a list"
1128                                        .to_owned(),
1129                                ))
1130                            }
1131                        };
1132                        statement_sql.push(sql.to_owned());
1133                        statement_params.push(bind);
1134                    }
1135                }
1136                Some(_) => {
1137                    return Err(client_err(
1138                        "sync.invalid_request: snapshotRead statements must be a list".to_owned(),
1139                    ))
1140                }
1141            }
1142            let subscription_ids = snapshot_string_list(params.get("subscriptions"))?;
1143            let commit_ids = snapshot_string_list(params.get("commitIds"))?;
1144            let coverage = parse_window_coverage(params.get("coverage")).map_err(client_err)?;
1145            let owner = parse_query_owner(params, "snapshotRead")?;
1146            let statements: Vec<syncular_client::SnapshotStatement<'_>> = statement_sql
1147                .iter()
1148                .zip(&statement_params)
1149                .map(|(sql, params)| syncular_client::SnapshotStatement { sql, params })
1150                .collect();
1151            let read = need_client(client)?
1152                .snapshot_read(
1153                    &syncular_client::SnapshotReadRequest {
1154                        statements,
1155                        coverage: &coverage,
1156                        subscriptions: subscription_ids,
1157                        commit_ids,
1158                    },
1159                    owner.as_ref().map(|(id, tables)| QueryOwner { id, tables }),
1160                )
1161                .map_err(read_command_error)?;
1162            serde_json::to_value(read).map_err(|error| client_err(error.to_string()))
1163        }
1164        "previousVersionSnapshot" | "previous_version_snapshot" => {
1165            let spec = parse_previous_version_snapshot_spec(params)?;
1166            let snapshot = need_client(client)?
1167                .previous_version_snapshot(&spec)
1168                .map_err(client_err)?;
1169            serde_json::to_value(snapshot).map_err(|error| client_err(error.to_string()))
1170        }
1171        "previousVersionAudit" | "previous_version_audit" => {
1172            Ok(match need_client(client)?.previous_version_audit() {
1173                Some(audit) => {
1174                    serde_json::to_value(audit).map_err(|error| client_err(error.to_string()))?
1175                }
1176                None => Value::Null,
1177            })
1178        }
1179        "previousVersionDiscard" | "previous_version_discard" => {
1180            let outcome = need_client(client)?.previous_version_discard();
1181            Ok(json!({
1182                "present": outcome.present,
1183                "discarded": outcome.discarded,
1184            }))
1185        }
1186        "statusSnapshot" => Ok(serde_json::to_value(need_client(client)?.status_snapshot())
1187            .expect("status serializes")),
1188        "diagnosticsSnapshot" => {
1189            let request = serde_json::from_value::<ClientDiagnosticsRequest>(params.clone())
1190                .map_err(|error| {
1191                    client_err(format!(
1192                        "sync.invalid_request: invalid diagnostics request: {error}"
1193                    ))
1194                })?;
1195            let snapshot = need_client(client)?
1196                .diagnostics_snapshot(&request)
1197                .map_err(client_err)?;
1198            Ok(serde_json::to_value(snapshot).expect("diagnostics serialize"))
1199        }
1200        // Conformance/debug drains. Production hosts normally drain these
1201        // immediately after every command, but exposing the exact core output
1202        // here lets both client implementations consume one observation
1203        // vector catalog without bridge inference.
1204        "progressSnapshot" => Ok(
1205            serde_json::to_value(need_client(client)?.progress().snapshot())
1206                .expect("progress JSON"),
1207        ),
1208        "drainChangeBatches" => Ok(json!({
1209            "batches": need_client(client)?.drain_change_batches()
1210        })),
1211        "drainSyncIntents" => Ok(json!({
1212            "intents": need_client(client)?.drain_sync_intents()
1213        })),
1214        // -- §5.10.5 native CRDT (the `crdt-yjs` feature) -----------------------
1215        // Thin forwards to the client core's yrs helpers. The command surface
1216        // stays present-but-unavailable in a lean build: without the feature
1217        // these fail loudly (`client.crdt_unavailable`) rather than being an
1218        // unknown method, so a wrapper's typed method gives a clear error.
1219        #[cfg(feature = "crdt-yjs")]
1220        "crdtText" => {
1221            let (table, row_id, column, name) = crdt_target(params)?;
1222            let text = need_client(client)?
1223                .crdt_text(&table, &row_id, &column, &name)
1224                .map_err(client_err)?;
1225            Ok(json!({ "text": text }))
1226        }
1227        #[cfg(feature = "crdt-yjs")]
1228        "crdtInsertText" => {
1229            let (table, row_id, column, name) = crdt_target(params)?;
1230            let index = params
1231                .get("index")
1232                .and_then(Value::as_u64)
1233                .ok_or_else(|| client_err("crdtInsertText missing index".to_owned()))?
1234                as u32;
1235            let value = params
1236                .get("value")
1237                .and_then(Value::as_str)
1238                .ok_or_else(|| client_err("crdtInsertText missing value".to_owned()))?;
1239            let id = need_client(client)?
1240                .crdt_insert_text(&table, &row_id, &column, &name, index, value)
1241                .map_err(CommandError::from_client)?;
1242            Ok(json!({ "clientCommitId": id }))
1243        }
1244        #[cfg(feature = "crdt-yjs")]
1245        "crdtDeleteText" => {
1246            let (table, row_id, column, name) = crdt_target(params)?;
1247            let index = params
1248                .get("index")
1249                .and_then(Value::as_u64)
1250                .ok_or_else(|| client_err("crdtDeleteText missing index".to_owned()))?
1251                as u32;
1252            let len = params
1253                .get("len")
1254                .and_then(Value::as_u64)
1255                .ok_or_else(|| client_err("crdtDeleteText missing len".to_owned()))?
1256                as u32;
1257            let id = need_client(client)?
1258                .crdt_delete_text(&table, &row_id, &column, &name, index, len)
1259                .map_err(CommandError::from_client)?;
1260            Ok(json!({ "clientCommitId": id }))
1261        }
1262        #[cfg(feature = "crdt-yjs")]
1263        "crdtApplyUpdate" => {
1264            let (table, row_id, column, _name) = crdt_target(params)?;
1265            let update = value_bytes(params.get("update")).map_err(client_err)?;
1266            let id = need_client(client)?
1267                .crdt_apply_update(&table, &row_id, &column, &update)
1268                .map_err(CommandError::from_client)?;
1269            Ok(json!({ "clientCommitId": id }))
1270        }
1271        #[cfg(not(feature = "crdt-yjs"))]
1272        "crdtText" | "crdtInsertText" | "crdtDeleteText" | "crdtApplyUpdate" => {
1273            Err(CommandError::new(
1274                "client.crdt_unavailable",
1275                "native CRDT support requires the `crdt-yjs` feature (§5.10.5)",
1276            ))
1277        }
1278
1279        "uploadBlob" => {
1280            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1281            let media_type = params
1282                .get("mediaType")
1283                .and_then(Value::as_str)
1284                .map(str::to_owned);
1285            let name = params
1286                .get("name")
1287                .and_then(Value::as_str)
1288                .map(str::to_owned);
1289            let reference = need_client(client)?
1290                .upload_blob(&bytes, media_type, name)
1291                .map_err(client_err)?;
1292            Ok(json!({ "ref": reference }))
1293        }
1294        "fetchBlob" => {
1295            let blob = params
1296                .get("blob")
1297                .and_then(Value::as_str)
1298                .ok_or_else(|| client_err("fetchBlob missing blob".to_owned()))?
1299                .to_owned();
1300            // fetch_blob_bytes returns (code, message) so the server's blob.*
1301            // code reaches the caller (§5.9.5 cross-scope probe). The command
1302            // boundary owns the JSON byte encoding used by native bindings.
1303            let blob = need_client(client)?.fetch_blob_bytes(transport, &blob)?;
1304            let mut value = serde_json::Map::new();
1305            value.insert("blobId".to_owned(), Value::from(blob.blob_id));
1306            value.insert("byteLength".to_owned(), Value::from(blob.byte_length));
1307            value.insert("bytes".to_owned(), bytes_value(&blob.bytes));
1308            if let Some(media_type) = blob.media_type {
1309                value.insert("mediaType".to_owned(), Value::from(media_type));
1310            }
1311            Ok(Value::Object(serde_json::Map::from_iter([(
1312                "blob".to_owned(),
1313                Value::Object(value),
1314            )])))
1315        }
1316        "conflicts" => {
1317            let conflicts = need_client(client)?.conflicts().to_vec();
1318            Ok(json!({ "conflicts": conflicts }))
1319        }
1320        "rejections" => {
1321            let rejections = need_client(client)?.rejections().to_vec();
1322            Ok(json!({ "rejections": rejections }))
1323        }
1324        "commitOutcome" => {
1325            let client_commit_id = params
1326                .get("clientCommitId")
1327                .and_then(Value::as_str)
1328                .ok_or_else(|| {
1329                    client_err(
1330                        "sync.invalid_request: commitOutcome missing clientCommitId".to_owned(),
1331                    )
1332                })?;
1333            let outcome = need_client(client)?
1334                .commit_outcome(client_commit_id)
1335                .map_err(client_err)?;
1336            Ok(json!({ "outcome": outcome }))
1337        }
1338        "commitOutcomes" => {
1339            let query = serde_json::from_value::<CommitOutcomeQuery>(
1340                params.get("query").cloned().unwrap_or_else(|| json!({})),
1341            )
1342            .map_err(|error| {
1343                client_err(format!(
1344                    "sync.invalid_request: invalid commit outcome query: {error}"
1345                ))
1346            })?;
1347            let outcomes = need_client(client)?
1348                .commit_outcomes(query)
1349                .map_err(client_err)?;
1350            Ok(json!({ "outcomes": outcomes }))
1351        }
1352        "resolveCommitOutcome" => {
1353            let input = serde_json::from_value::<ResolveCommitOutcomeInput>(
1354                params.get("input").cloned().ok_or_else(|| {
1355                    client_err(
1356                        "sync.invalid_request: resolveCommitOutcome missing input".to_owned(),
1357                    )
1358                })?,
1359            )
1360            .map_err(|error| {
1361                client_err(format!(
1362                    "sync.invalid_request: invalid outcome resolution: {error}"
1363                ))
1364            })?;
1365            let outcome = need_client(client)?
1366                .resolve_commit_outcome(input)
1367                .map_err(client_err)?;
1368            Ok(json!({ "outcome": outcome }))
1369        }
1370        "pendingCommitIds" => {
1371            let ids = need_client(client)?.pending_commit_ids();
1372            Ok(json!({ "ids": ids }))
1373        }
1374        "pendingPayloads" => {
1375            let payloads = need_client(client)?.pending_payloads();
1376            Ok(json!({
1377                "payloads": payloads.iter().map(|p| bytes_value(p)).collect::<Vec<_>>()
1378            }))
1379        }
1380        "subscriptionState" => {
1381            let id = params
1382                .get("id")
1383                .and_then(Value::as_str)
1384                .ok_or_else(|| client_err("subscriptionState missing id".to_owned()))?;
1385            let state = need_client(client)?.subscription_state(id);
1386            Ok(json!({ "state": state }))
1387        }
1388        "recreateWithSchema" => {
1389            // §7.4.2 "app ships new code": swap to the new schema on the SAME
1390            // in-memory database (the Rust core has no persistent restart, so
1391            // recreation IS the boot). Fires the §7.4.1 marker check.
1392            let schema = params
1393                .get("schema")
1394                .ok_or_else(|| client_err("recreateWithSchema missing schema".to_owned()))?;
1395            need_client(client)?
1396                .recreate_with_schema(schema)
1397                .map_err(client_err)?;
1398            Ok(json!({}))
1399        }
1400        "connectRealtime" => {
1401            need_client(client)?
1402                .connect_realtime(transport)
1403                .map_err(client_err)?;
1404            Ok(json!({}))
1405        }
1406        "disconnectRealtime" => {
1407            need_client(client)?.disconnect_realtime(transport);
1408            Ok(json!({}))
1409        }
1410        "setPresence" => {
1411            let scope_key = params
1412                .get("scopeKey")
1413                .and_then(Value::as_str)
1414                .ok_or_else(|| client_err("setPresence missing scopeKey".to_owned()))?
1415                .to_owned();
1416            // §8.6.2: `doc` may be a JSON object or null (a leave).
1417            let doc = params.get("doc");
1418            let doc_ref = match doc {
1419                None | Some(Value::Null) => None,
1420                Some(v) => Some(v),
1421            };
1422            need_client(client)?
1423                .set_presence(transport, &scope_key, doc_ref)
1424                .map_err(client_err)?;
1425            Ok(json!({}))
1426        }
1427        "presence" => {
1428            let scope_key = params
1429                .get("scopeKey")
1430                .and_then(Value::as_str)
1431                .ok_or_else(|| client_err("presence missing scopeKey".to_owned()))?;
1432            let peers = need_client(client)?.presence(scope_key);
1433            Ok(json!({ "peers": peers }))
1434        }
1435
1436        // -- CodecDriver surface (Appendix A) — no client instance needed --
1437        "messageRoundtrip" => {
1438            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1439            match decode_message(&bytes) {
1440                Ok(message) => Ok(json!({
1441                    "ok": true,
1442                    "bytes": bytes_value(&encode_message(&message)),
1443                    "renderedJson": render_message(&message).to_string(),
1444                })),
1445                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1446            }
1447        }
1448        "segmentRoundtrip" => {
1449            let bytes = value_bytes(params.get("bytes")).map_err(client_err)?;
1450            match decode_rows_segment(&bytes) {
1451                Ok(segment) => Ok(json!({
1452                    "ok": true,
1453                    "bytes": bytes_value(&encode_rows_segment(&segment)),
1454                    "renderedJson": render_rows_segment(&segment).to_string(),
1455                })),
1456                Err(error) => Ok(json!({ "ok": false, "errorCode": error.code.as_str() })),
1457            }
1458        }
1459        "realtimeKnown" => {
1460            let text = params
1461                .get("text")
1462                .and_then(Value::as_str)
1463                .ok_or_else(|| client_err("realtimeKnown missing text".to_owned()))?;
1464            let known = matches!(
1465                parse_control(text),
1466                Ok(ControlMessage::Hello { .. })
1467                    | Ok(ControlMessage::Wake { .. })
1468                    | Ok(ControlMessage::Heartbeat { .. })
1469                    | Ok(ControlMessage::Presence { .. })
1470            );
1471            Ok(json!({ "value": known }))
1472        }
1473
1474        other => Err(client_err(format!("unknown method {other:?}"))),
1475    };
1476    if client
1477        .as_ref()
1478        .is_some_and(|running| !running.transport_enabled())
1479    {
1480        if let Ok(value) = &mut result {
1481            if let Some(effects) = value.get_mut("effects").and_then(Value::as_object_mut) {
1482                effects.remove("sync");
1483            }
1484        }
1485    }
1486    result
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491    use serde_json::{json, Value};
1492    use syncular_client::{
1493        Mutation, SegmentRequest, SyncClient, Transport, TransportError,
1494        SECURITY_PREFLIGHT_REQUIRED_CODE,
1495    };
1496
1497    use super::{
1498        client_err, dispatch, parse_encryption, parse_headers, parse_previous_version_context,
1499        CreateEffects,
1500    };
1501
1502    #[derive(Default)]
1503    struct NoNetwork {
1504        realtime_connects: usize,
1505        realtime_closes: usize,
1506    }
1507
1508    impl Transport for NoNetwork {
1509        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1510            Err(TransportError::new("sync.transport_failed", "offline"))
1511        }
1512
1513        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1514            Err(TransportError::new("sync.transport_failed", "offline"))
1515        }
1516
1517        fn download_segment(
1518            &mut self,
1519            _request: &SegmentRequest,
1520            _on_progress: &mut dyn FnMut(u64),
1521        ) -> Result<Vec<u8>, TransportError> {
1522            Err(TransportError::new("sync.transport_failed", "offline"))
1523        }
1524
1525        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1526            self.realtime_connects += 1;
1527            Ok(())
1528        }
1529
1530        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1531            Ok(())
1532        }
1533
1534        fn realtime_close(&mut self) -> Result<(), TransportError> {
1535            self.realtime_closes += 1;
1536            Ok(())
1537        }
1538    }
1539
1540    fn schema() -> Value {
1541        json!({
1542            "version": 1,
1543            "tables": [{
1544                "name": "todos",
1545                "columns": [
1546                    { "name": "id", "type": "string", "nullable": false },
1547                    { "name": "list_id", "type": "string", "nullable": false }
1548                ],
1549                "primaryKey": "id",
1550                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1551            }]
1552        })
1553    }
1554
1555    #[test]
1556    fn native_command_realtime_connection_is_idempotent() {
1557        let mut transport = NoNetwork::default();
1558        let mut client: Option<SyncClient> = None;
1559        let mut effects = CreateEffects::default();
1560        dispatch(
1561            &mut transport,
1562            &mut client,
1563            &mut effects,
1564            "create",
1565            &json!({ "schema": schema() }),
1566        )
1567        .expect("create client");
1568
1569        for _ in 0..2 {
1570            dispatch(
1571                &mut transport,
1572                &mut client,
1573                &mut effects,
1574                "connectRealtime",
1575                &json!({}),
1576            )
1577            .expect("idempotent connect command");
1578        }
1579        assert_eq!(transport.realtime_connects, 1);
1580
1581        dispatch(
1582            &mut transport,
1583            &mut client,
1584            &mut effects,
1585            "disconnectRealtime",
1586            &json!({}),
1587        )
1588        .expect("disconnect command");
1589        dispatch(
1590            &mut transport,
1591            &mut client,
1592            &mut effects,
1593            "disconnectRealtime",
1594            &json!({}),
1595        )
1596        .expect("idempotent disconnect command");
1597        assert_eq!(transport.realtime_closes, 1);
1598        dispatch(
1599            &mut transport,
1600            &mut client,
1601            &mut effects,
1602            "connectRealtime",
1603            &json!({}),
1604        )
1605        .expect("deliberate reconnect command");
1606        assert_eq!(transport.realtime_connects, 2);
1607    }
1608
1609    #[test]
1610    fn schema_downgrade_has_structured_code_at_create_and_recreate_boundaries() {
1611        let path = temp_db_path("schema-downgrade");
1612        let mut newer = schema();
1613        newer["version"] = json!(3);
1614        let mut older = schema();
1615        older["version"] = json!(2);
1616        let mut transport = NoNetwork::default();
1617        let mut client = None;
1618        let mut effects = CreateEffects::default();
1619        dispatch(
1620            &mut transport,
1621            &mut client,
1622            &mut effects,
1623            "create",
1624            &json!({ "schema": newer, "dbPath": path }),
1625        )
1626        .expect("v3 create");
1627        let error = dispatch(
1628            &mut transport,
1629            &mut client,
1630            &mut effects,
1631            "recreateWithSchema",
1632            &json!({ "schema": older }),
1633        )
1634        .expect_err("refuse recreation");
1635        assert_eq!(error.code, "client.schema_downgrade");
1636        assert_eq!(
1637            error.message,
1638            "client.schema_downgrade: persisted local schema is newer than the requested schema"
1639        );
1640        dispatch(
1641            &mut transport,
1642            &mut client,
1643            &mut effects,
1644            "shutdown",
1645            &json!({}),
1646        )
1647        .expect("shutdown");
1648        let error = dispatch(
1649            &mut transport,
1650            &mut client,
1651            &mut effects,
1652            "create",
1653            &json!({ "schema": older, "dbPath": path }),
1654        )
1655        .expect_err("refuse open");
1656        assert_eq!(error.code, "client.schema_downgrade");
1657        assert!(client.is_none());
1658        dispatch(
1659            &mut transport,
1660            &mut client,
1661            &mut effects,
1662            "create",
1663            &json!({ "schema": newer, "dbPath": path }),
1664        )
1665        .expect("compatible reopen");
1666        dispatch(
1667            &mut transport,
1668            &mut client,
1669            &mut effects,
1670            "shutdown",
1671            &json!({}),
1672        )
1673        .expect("shutdown");
1674        let _ = std::fs::remove_file(path);
1675    }
1676
1677    #[test]
1678    fn native_command_realtime_policy_rides_create_and_refuses_http() {
1679        let mut transport = NoNetwork::default();
1680        let mut client: Option<SyncClient> = None;
1681        let mut effects = CreateEffects::default();
1682        dispatch(
1683            &mut transport,
1684            &mut client,
1685            &mut effects,
1686            "create",
1687            &json!({ "schema": schema(), "realtimePolicy": "required" }),
1688        )
1689        .expect("create client with a required realtime policy");
1690        let result = dispatch(
1691            &mut transport,
1692            &mut client,
1693            &mut effects,
1694            "sync",
1695            &json!({}),
1696        )
1697        .expect("sync command");
1698        assert_eq!(result["ok"], json!(false));
1699        assert_eq!(result["errorCode"], json!("sync.realtime_unavailable"));
1700        assert_eq!(result["realtimeState"], json!("disconnected"));
1701        assert_eq!(result["retryDelayMs"], json!(250));
1702        let snapshot = dispatch(
1703            &mut transport,
1704            &mut client,
1705            &mut effects,
1706            "diagnosticsSnapshot",
1707            &json!({}),
1708        )
1709        .expect("diagnostics command");
1710        assert_eq!(snapshot["host"]["realtime"], json!("disconnected"));
1711        assert_eq!(snapshot["host"]["realtimePolicy"], json!("required"));
1712        assert_eq!(snapshot["host"]["realtimeRetryDelayMs"], json!(250));
1713
1714        let mut second: Option<SyncClient> = None;
1715        let error = dispatch(
1716            &mut transport,
1717            &mut second,
1718            &mut effects,
1719            "create",
1720            &json!({ "schema": schema(), "realtimePolicy": "always" }),
1721        )
1722        .expect_err("an unknown policy is refused");
1723        assert_eq!(error.code, "sync.invalid_request");
1724    }
1725
1726    #[test]
1727    fn parses_portable_encryption_keyring_and_key_id_columns() {
1728        let key_hex = "2a".repeat(32);
1729        let config = parse_encryption(&json!({
1730            "keys": { "practice-key-v1": { "$bytes": key_hex } },
1731            "keyIdColumns": { "patients": "encryption_key_id" }
1732        }))
1733        .expect("portable keyring parses");
1734        assert_eq!(config.keys["practice-key-v1"], vec![0x2a; 32]);
1735        assert_eq!(config.key_id_columns["patients"], "encryption_key_id");
1736    }
1737
1738    #[test]
1739    fn rejects_non_string_key_id_columns() {
1740        let error = parse_encryption(&json!({
1741            "keys": {},
1742            "keyIdColumns": { "patients": 7 }
1743        }))
1744        .expect_err("invalid selector must fail");
1745        assert!(error.contains("must be a string"), "{error}");
1746    }
1747
1748    #[test]
1749    fn subscribe_many_registers_every_subscription() {
1750        let mut transport = NoNetwork::default();
1751        let mut client: Option<SyncClient> = None;
1752        let mut effects = CreateEffects::default();
1753        dispatch(
1754            &mut transport,
1755            &mut client,
1756            &mut effects,
1757            "create",
1758            &json!({ "schema": schema() }),
1759        )
1760        .expect("create client");
1761        dispatch(
1762            &mut transport,
1763            &mut client,
1764            &mut effects,
1765            "subscribeMany",
1766            &json!({ "subscriptions": [
1767                { "id": "a", "table": "todos", "scopes": { "list_id": ["list-1"] } },
1768                { "id": "b", "table": "todos", "scopes": { "list_id": ["list-2"] } }
1769            ] }),
1770        )
1771        .expect("register subscriptions");
1772        for id in ["a", "b"] {
1773            let state = dispatch(
1774                &mut transport,
1775                &mut client,
1776                &mut effects,
1777                "subscriptionState",
1778                &json!({ "id": id }),
1779            )
1780            .expect("subscription state");
1781            assert_eq!(state["state"]["table"], "todos");
1782        }
1783    }
1784
1785    #[test]
1786    fn native_command_hosts_reject_subscription_identity_rebinds() {
1787        let mut transport = NoNetwork::default();
1788        let mut client: Option<SyncClient> = None;
1789        let mut effects = CreateEffects::default();
1790        dispatch(
1791            &mut transport,
1792            &mut client,
1793            &mut effects,
1794            "create",
1795            &json!({ "schema": schema() }),
1796        )
1797        .expect("create client");
1798
1799        let original = json!({
1800            "id": "stable-subscription",
1801            "table": "todos",
1802            "scopes": { "list_id": ["list-2", "list-1"] },
1803            "params": "{\"view\":\"v1\"}"
1804        });
1805        dispatch(
1806            &mut transport,
1807            &mut client,
1808            &mut effects,
1809            "subscribe",
1810            &original,
1811        )
1812        .expect("register subscription");
1813        dispatch(
1814            &mut transport,
1815            &mut client,
1816            &mut effects,
1817            "subscribe",
1818            &json!({
1819                "id": "stable-subscription",
1820                "table": "todos",
1821                "scopes": { "list_id": ["list-1", "list-2", "list-1"] },
1822                "params": "{\"view\":\"v1\"}"
1823            }),
1824        )
1825        .expect("canonical re-declaration is idempotent");
1826
1827        let error = dispatch(
1828            &mut transport,
1829            &mut client,
1830            &mut effects,
1831            "subscribe",
1832            &json!({
1833                "id": "stable-subscription",
1834                "table": "todos",
1835                "scopes": { "list_id": ["list-2"] },
1836                "params": "{\"view\":\"v1\"}"
1837            }),
1838        )
1839        .expect_err("changed native query identity must fail");
1840        assert_eq!(error.code, "client.subscription_intent_mismatch");
1841
1842        let state = dispatch(
1843            &mut transport,
1844            &mut client,
1845            &mut effects,
1846            "subscriptionState",
1847            &json!({ "id": "stable-subscription" }),
1848        )
1849        .expect("subscription state");
1850        assert_eq!(state["state"]["cursor"], -1);
1851        assert_eq!(state["state"]["table"], "todos");
1852    }
1853
1854    #[test]
1855    fn security_preflight_is_fail_closed_until_exact_activation() {
1856        let mut transport = NoNetwork::default();
1857        let mut client: Option<SyncClient> = None;
1858        let mut effects = CreateEffects::default();
1859        dispatch(
1860            &mut transport,
1861            &mut client,
1862            &mut effects,
1863            "create",
1864            &json!({ "schema": schema(), "securityPreflight": true }),
1865        )
1866        .expect("preflight create");
1867
1868        let lifecycle = dispatch(
1869            &mut transport,
1870            &mut client,
1871            &mut effects,
1872            "securityLifecycle",
1873            &json!({}),
1874        )
1875        .expect("lifecycle");
1876        assert_eq!(lifecycle, json!({ "state": "preflight" }));
1877
1878        let query_error = dispatch(
1879            &mut transport,
1880            &mut client,
1881            &mut effects,
1882            "query",
1883            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1884        )
1885        .expect_err("protected query must fail");
1886        assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1887        let diagnostics_error = dispatch(
1888            &mut transport,
1889            &mut client,
1890            &mut effects,
1891            "diagnosticsSnapshot",
1892            &json!({}),
1893        )
1894        .expect_err("diagnostics table/subscription evidence remains protected");
1895        assert_eq!(diagnostics_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1896
1897        dispatch(
1898            &mut transport,
1899            &mut client,
1900            &mut effects,
1901            "purgeLocalData",
1902            &json!({
1903                "input": {
1904                    "purgeId": "directive-1",
1905                    "targets": [{
1906                        "table": "todos",
1907                        "selectors": { "list_id": ["list-1"] }
1908                    }]
1909                }
1910            }),
1911        )
1912        .expect("authorized local purge remains available");
1913
1914        let repair_error = dispatch(
1915            &mut transport,
1916            &mut client,
1917            &mut effects,
1918            "rebootstrapLocalData",
1919            &json!({ "input": { "rebootstrapId": "blocked-repair" } }),
1920        )
1921        .expect_err("projection repair must remain protected during preflight");
1922        assert_eq!(repair_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1923
1924        dispatch(
1925            &mut transport,
1926            &mut client,
1927            &mut effects,
1928            "activateSecurity",
1929            &json!({}),
1930        )
1931        .expect("activation");
1932        let rows = dispatch(
1933            &mut transport,
1934            &mut client,
1935            &mut effects,
1936            "query",
1937            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
1938        )
1939        .expect("active query");
1940        assert_eq!(rows, json!({ "rows": [] }));
1941
1942        dispatch(
1943            &mut transport,
1944            &mut client,
1945            &mut effects,
1946            "beginSecurityPreflight",
1947            &json!({}),
1948        )
1949        .expect("re-enter preflight");
1950        let blocked = dispatch(
1951            &mut transport,
1952            &mut client,
1953            &mut effects,
1954            "mutate",
1955            &json!({ "mutations": [] }),
1956        )
1957        .expect_err("mutation must be gated");
1958        assert_eq!(blocked.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
1959    }
1960
1961    #[test]
1962    fn mutate_and_patch_forward_typed_storage_failures() {
1963        let path = temp_db_path("busy-authoring");
1964        let schema = json!({
1965            "version": 1,
1966            "tables": [{
1967                "name": "todos",
1968                "columns": [
1969                    { "name": "id", "type": "string", "nullable": false },
1970                    { "name": "list_id", "type": "string", "nullable": false },
1971                    { "name": "title", "type": "string", "nullable": false }
1972                ],
1973                "primaryKey": "id",
1974                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
1975            }]
1976        });
1977        let mut transport = NoNetwork::default();
1978        let mut client: Option<SyncClient> = None;
1979        let mut effects = CreateEffects::default();
1980        dispatch(
1981            &mut transport,
1982            &mut client,
1983            &mut effects,
1984            "create",
1985            &json!({ "schema": schema, "dbPath": path }),
1986        )
1987        .expect("create file-backed client");
1988        let upsert = |id: &str| {
1989            json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
1990                "id": id, "list_id": "l1", "title": "first" } }] })
1991        };
1992        dispatch(
1993            &mut transport,
1994            &mut client,
1995            &mut effects,
1996            "mutate",
1997            &upsert("t1"),
1998        )
1999        .expect("seed row");
2000
2001        let locker = rusqlite::Connection::open(&path).expect("second connection");
2002        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
2003        let busy = dispatch(
2004            &mut transport,
2005            &mut client,
2006            &mut effects,
2007            "mutate",
2008            &upsert("t2"),
2009        )
2010        .expect_err("busy mutate");
2011        assert_eq!(busy.code, "client.storage_busy");
2012        assert!(busy.retryable);
2013        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
2014
2015        let busy_patch = dispatch(
2016            &mut transport,
2017            &mut client,
2018            &mut effects,
2019            "patch",
2020            &json!({ "table": "todos", "rowId": "t1", "partial": { "title": "second" } }),
2021        )
2022        .expect_err("busy patch");
2023        assert_eq!(busy_patch.code, "client.storage_busy");
2024        assert!(busy_patch.retryable);
2025        assert_eq!(busy_patch.details.as_ref().unwrap()["sqliteCode"], 5);
2026
2027        locker.execute_batch("ROLLBACK").unwrap();
2028        let retried = dispatch(
2029            &mut transport,
2030            &mut client,
2031            &mut effects,
2032            "mutate",
2033            &upsert("t2"),
2034        )
2035        .expect("retry after busy");
2036        assert!(retried.get("clientCommitId").is_some());
2037        let _ = std::fs::remove_file(&path);
2038    }
2039
2040    #[test]
2041    fn read_command_error_preserves_code_details_and_retryability() {
2042        let corrupt = super::read_command_error(syncular_client::QueryReadFailure {
2043            code: Some("sync.local_corrupt"),
2044            sqlite_code: None,
2045            sqlite_message: None,
2046            rollback_failure: None,
2047            message: "persisted local revision is invalid".to_owned(),
2048        });
2049        assert_eq!(corrupt.code, "sync.local_corrupt");
2050        assert!(!corrupt.retryable);
2051        assert!(corrupt.details.is_none());
2052
2053        let busy = super::read_command_error(syncular_client::QueryReadFailure {
2054            code: Some("client.storage_busy"),
2055            sqlite_code: Some(5),
2056            sqlite_message: Some("database is locked".to_owned()),
2057            rollback_failure: None,
2058            message: "local SQLite storage is busy".to_owned(),
2059        });
2060        assert_eq!(busy.code, "client.storage_busy");
2061        assert!(busy.retryable);
2062        assert_eq!(
2063            busy.details.expect("busy details")["sqliteCode"],
2064            serde_json::json!(5)
2065        );
2066    }
2067
2068    #[test]
2069    fn mutate_and_patch_report_caller_value_failures_as_invalid_request() {
2070        let path = temp_db_path("caller-values");
2071        let schema = json!({
2072            "version": 1,
2073            "tables": [{
2074                "name": "todos",
2075                "columns": [
2076                    { "name": "id", "type": "string", "nullable": false },
2077                    { "name": "list_id", "type": "string", "nullable": false },
2078                    { "name": "title", "type": "string", "nullable": false },
2079                    { "name": "priority", "type": "integer", "nullable": false }
2080                ],
2081                "primaryKey": "id",
2082                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2083            }]
2084        });
2085        let mut transport = NoNetwork::default();
2086        let mut client: Option<SyncClient> = None;
2087        let mut effects = CreateEffects::default();
2088        dispatch(
2089            &mut transport,
2090            &mut client,
2091            &mut effects,
2092            "create",
2093            &json!({ "schema": schema, "dbPath": path }),
2094        )
2095        .expect("create file-backed client");
2096        let seed = dispatch(
2097            &mut transport,
2098            &mut client,
2099            &mut effects,
2100            "mutate",
2101            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2102                "id": "t1", "list_id": "l1", "title": "first", "priority": 1 } }] }),
2103        )
2104        .expect("seed row");
2105        let seed_id = seed["clientCommitId"].clone();
2106
2107        // §6.1/§7.1: a caller value the authoring seam rejects is an invalid
2108        // request, not the internal-failure default.
2109        let unknown = dispatch(
2110            &mut transport,
2111            &mut client,
2112            &mut effects,
2113            "mutate",
2114            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2115                "id": "t2", "list_id": "l1", "title": "second", "priority": 1,
2116                "missing_column": 1 } }] }),
2117        )
2118        .expect_err("unknown column");
2119        assert_eq!(unknown.code, "sync.invalid_request");
2120        assert_eq!(unknown.message, "the authoring request is invalid");
2121        assert!(!unknown.retryable);
2122        assert!(unknown.details.as_ref().unwrap()["legacyCause"]
2123            .as_str()
2124            .unwrap()
2125            .contains("unknown column"));
2126
2127        let bad_patch = dispatch(
2128            &mut transport,
2129            &mut client,
2130            &mut effects,
2131            "patch",
2132            &json!({ "table": "todos", "rowId": "t1", "partial": { "priority": "high" } }),
2133        )
2134        .expect_err("bad patch value");
2135        assert_eq!(bad_patch.code, "sync.invalid_request");
2136        assert_eq!(bad_patch.message, "the authoring request is invalid");
2137        assert!(bad_patch.details.as_ref().unwrap()["legacyCause"]
2138            .as_str()
2139            .unwrap()
2140            .contains("priority"));
2141
2142        // Neither rejection enqueued a commit or touched the projection.
2143        let pending = dispatch(
2144            &mut transport,
2145            &mut client,
2146            &mut effects,
2147            "pendingCommitIds",
2148            &json!({}),
2149        )
2150        .expect("pending commit ids");
2151        assert_eq!(pending["ids"], json!([seed_id]));
2152        let rows = dispatch(
2153            &mut transport,
2154            &mut client,
2155            &mut effects,
2156            "query",
2157            &json!({ "sql": "SELECT id, priority FROM todos ORDER BY id" }),
2158        )
2159        .expect("visible rows");
2160        assert_eq!(rows["rows"], json!([{ "id": "t1", "priority": 1 }]));
2161
2162        // The next valid call authorizes normally.
2163        let accepted = dispatch(
2164            &mut transport,
2165            &mut client,
2166            &mut effects,
2167            "mutate",
2168            &json!({ "mutations": [{ "op": "upsert", "table": "todos", "values": {
2169                "id": "t2", "list_id": "l1", "title": "second", "priority": 2 } }] }),
2170        )
2171        .expect("retry after rejection");
2172        assert!(accepted.get("clientCommitId").is_some());
2173        let _ = std::fs::remove_file(&path);
2174    }
2175
2176    #[test]
2177    fn client_err_preserves_retryable_for_storage_busy() {
2178        let busy = client_err("client.storage_busy: local SQLite storage is busy".to_owned());
2179        assert_eq!(busy.code, "client.storage_busy");
2180        assert!(busy.retryable);
2181        // The legacy boundary keeps the full rendered message.
2182        assert_eq!(
2183            busy.message,
2184            "client.storage_busy: local SQLite storage is busy"
2185        );
2186        let full = client_err("client.storage_full: local SQLite storage is full".to_owned());
2187        assert_eq!(full.code, "client.storage_full");
2188        assert!(!full.retryable);
2189    }
2190
2191    #[test]
2192    fn sync_until_idle_rejects_an_invalid_max_rounds_budget() {
2193        for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2194            let mut transport = NoNetwork::default();
2195            let mut client: Option<SyncClient> = None;
2196            let mut effects = CreateEffects::default();
2197            dispatch(
2198                &mut transport,
2199                &mut client,
2200                &mut effects,
2201                "create",
2202                &json!({ "schema": schema() }),
2203            )
2204            .expect("create");
2205            let error = dispatch(
2206                &mut transport,
2207                &mut client,
2208                &mut effects,
2209                "syncUntilIdle",
2210                &json!({ "maxRounds": value }),
2211            )
2212            .expect_err("invalid budget");
2213            assert_eq!(error.code, "sync.invalid_request", "{value}");
2214        }
2215    }
2216
2217    #[test]
2218    fn sync_until_idle_rejects_an_invalid_timeout() {
2219        for value in [json!(0), json!(-1), json!(1.5)] {
2220            let mut transport = NoNetwork::default();
2221            let mut client: Option<SyncClient> = None;
2222            let mut effects = CreateEffects::default();
2223            dispatch(
2224                &mut transport,
2225                &mut client,
2226                &mut effects,
2227                "create",
2228                &json!({ "schema": schema() }),
2229            )
2230            .expect("create");
2231            let error = dispatch(
2232                &mut transport,
2233                &mut client,
2234                &mut effects,
2235                "syncUntilIdle",
2236                &json!({ "timeoutMs": value }),
2237            )
2238            .expect_err("invalid timeout");
2239            assert_eq!(error.code, "sync.invalid_request", "{value}");
2240        }
2241    }
2242
2243    #[test]
2244    fn create_rejects_invalid_push_limits() {
2245        for value in [json!(0), json!(-1), json!(1.5), json!(4_294_967_296u64)] {
2246            let mut transport = NoNetwork::default();
2247            let mut client: Option<SyncClient> = None;
2248            let mut effects = CreateEffects::default();
2249            let error = dispatch(
2250                &mut transport,
2251                &mut client,
2252                &mut effects,
2253                "create",
2254                &json!({
2255                    "schema": schema(),
2256                    "limits": { "maxPushRequestBytes": value }
2257                }),
2258            )
2259            .expect_err("invalid push limit");
2260            assert_eq!(error.code, "sync.invalid_request", "{value}");
2261            assert_eq!(
2262                error.details.as_ref().unwrap()["limit"],
2263                json!("maxPushRequestBytes")
2264            );
2265        }
2266    }
2267
2268    #[test]
2269    fn preflight_allows_only_the_authorized_previous_version_discard() {
2270        let mut transport = NoNetwork::default();
2271        let mut client: Option<SyncClient> = None;
2272        let mut effects = CreateEffects::default();
2273        dispatch(
2274            &mut transport,
2275            &mut client,
2276            &mut effects,
2277            "create",
2278            &json!({ "schema": schema(), "securityPreflight": true }),
2279        )
2280        .expect("preflight create");
2281
2282        // Reads and the audit observe protected local data: still gated.
2283        for method in ["previousVersionSnapshot", "previousVersionAudit"] {
2284            let error = dispatch(
2285                &mut transport,
2286                &mut client,
2287                &mut effects,
2288                method,
2289                &json!({}),
2290            )
2291            .expect_err("previous-version reads stay gated during preflight");
2292            assert_eq!(error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2293        }
2294        // RFC 0006 runs the discard consumer inside the quiesced window, so the
2295        // command router must permit it (feature off here: a clean no-op).
2296        let discarded = dispatch(
2297            &mut transport,
2298            &mut client,
2299            &mut effects,
2300            "previousVersionDiscard",
2301            &json!({}),
2302        )
2303        .expect("authorized cleanup is allowed during preflight");
2304        assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2305        assert!(client.as_ref().is_some_and(SyncClient::security_preflight));
2306    }
2307
2308    #[test]
2309    fn preflight_refuses_a_replacement_create_without_the_flag() {
2310        let mut transport = NoNetwork::default();
2311        let mut client: Option<SyncClient> = None;
2312        let mut effects = CreateEffects::default();
2313        dispatch(
2314            &mut transport,
2315            &mut client,
2316            &mut effects,
2317            "create",
2318            &json!({ "schema": schema(), "securityPreflight": true }),
2319        )
2320        .expect("preflight create");
2321
2322        // The escape the gate exists to prevent: a plain re-create must fail.
2323        let escape = dispatch(
2324            &mut transport,
2325            &mut client,
2326            &mut effects,
2327            "create",
2328            &json!({ "schema": schema() }),
2329        )
2330        .expect_err("plain create must be refused during preflight");
2331        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2332        // The refused create left the preflighted client installed and gated.
2333        let query_error = dispatch(
2334            &mut transport,
2335            &mut client,
2336            &mut effects,
2337            "query",
2338            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2339        )
2340        .expect_err("protected query stays gated");
2341        assert_eq!(query_error.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2342
2343        // A preflighted replacement is permitted and stays gated.
2344        dispatch(
2345            &mut transport,
2346            &mut client,
2347            &mut effects,
2348            "create",
2349            &json!({ "schema": schema(), "securityPreflight": true }),
2350        )
2351        .expect("preflighted replacement create");
2352        let still_gated = dispatch(
2353            &mut transport,
2354            &mut client,
2355            &mut effects,
2356            "query",
2357            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2358        )
2359        .expect_err("replacement stays gated");
2360        assert_eq!(still_gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2361
2362        // A legitimate activation releases the gate; creates behave as today.
2363        dispatch(
2364            &mut transport,
2365            &mut client,
2366            &mut effects,
2367            "activateSecurity",
2368            &json!({}),
2369        )
2370        .expect("activation");
2371        dispatch(
2372            &mut transport,
2373            &mut client,
2374            &mut effects,
2375            "create",
2376            &json!({ "schema": schema() }),
2377        )
2378        .expect("plain create after activation");
2379        let rows = dispatch(
2380            &mut transport,
2381            &mut client,
2382            &mut effects,
2383            "query",
2384            &json!({ "sql": "SELECT id FROM todos", "params": [] }),
2385        )
2386        .expect("active query");
2387        assert_eq!(rows, json!({ "rows": [] }));
2388    }
2389
2390    /// A file-backed replica: the persisted quarantine marker only exists here,
2391    /// which is why the in-memory tests above could not catch this.
2392    fn temp_db_path(tag: &str) -> String {
2393        std::env::temp_dir()
2394            .join(format!(
2395                "syncular-command-{tag}-{}-{:?}.db",
2396                std::process::id(),
2397                std::time::SystemTime::now()
2398                    .duration_since(std::time::UNIX_EPOCH)
2399                    .expect("clock")
2400                    .as_nanos()
2401            ))
2402            .to_string_lossy()
2403            .into_owned()
2404    }
2405
2406    #[test]
2407    fn an_activated_replica_reopens_plainly_after_shutdown() {
2408        // Shutting an ACTIVATED client down is not a quarantine event. The
2409        // teardown barrier used to call `begin_security_preflight`, which now
2410        // persists the gate, so every cleanly closed replica was marked pending
2411        // and its next plain `create` was refused forever — the reopen path
2412        // restores the flag from the marker and the create guard rejects it.
2413        let path = temp_db_path("activated-reopen");
2414        let mut transport = NoNetwork::default();
2415        let mut client: Option<SyncClient> = None;
2416        let mut effects = CreateEffects::default();
2417
2418        dispatch(
2419            &mut transport,
2420            &mut client,
2421            &mut effects,
2422            "create",
2423            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2424        )
2425        .expect("preflight create");
2426        dispatch(
2427            &mut transport,
2428            &mut client,
2429            &mut effects,
2430            "activateSecurity",
2431            &json!({}),
2432        )
2433        .expect("activation clears the gate");
2434        dispatch(
2435            &mut transport,
2436            &mut client,
2437            &mut effects,
2438            "shutdown",
2439            &json!({}),
2440        )
2441        .expect("shutdown after activation");
2442
2443        // The replica is active on disk, so a plain re-create must be admitted.
2444        dispatch(
2445            &mut transport,
2446            &mut client,
2447            &mut effects,
2448            "create",
2449            &json!({ "schema": schema(), "dbPath": path }),
2450        )
2451        .expect("an activated replica must reopen plainly after shutdown");
2452        let state = dispatch(
2453            &mut transport,
2454            &mut client,
2455            &mut effects,
2456            "securityLifecycle",
2457            &json!({}),
2458        )
2459        .expect("lifecycle");
2460        assert_eq!(state, json!({ "state": "active" }));
2461
2462        let _ = std::fs::remove_file(&path);
2463    }
2464
2465    #[test]
2466    fn an_unactivated_replica_stays_quarantined_across_shutdown_on_disk() {
2467        // The other half, and the reason the marker exists: a replica that
2468        // entered preflight and never activated must still refuse a plain
2469        // re-create after shutdown, even on a host that rebuilt its handle and
2470        // carries no in-memory flag.
2471        let path = temp_db_path("quarantined-reopen");
2472        let mut transport = NoNetwork::default();
2473        let mut client: Option<SyncClient> = None;
2474        let mut effects = CreateEffects::default();
2475
2476        dispatch(
2477            &mut transport,
2478            &mut client,
2479            &mut effects,
2480            "create",
2481            &json!({ "schema": schema(), "securityPreflight": true, "dbPath": path }),
2482        )
2483        .expect("preflight create");
2484        dispatch(
2485            &mut transport,
2486            &mut client,
2487            &mut effects,
2488            "shutdown",
2489            &json!({}),
2490        )
2491        .expect("shutdown during preflight");
2492
2493        // A fresh host handle: no in-memory pending flag survives here, so only
2494        // the persisted marker can hold the gate.
2495        let mut rebuilt = CreateEffects::default();
2496        let escape = dispatch(
2497            &mut transport,
2498            &mut client,
2499            &mut rebuilt,
2500            "create",
2501            &json!({ "schema": schema(), "dbPath": path }),
2502        )
2503        .expect_err("a quarantined replica must refuse a plain re-create");
2504        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2505
2506        let _ = std::fs::remove_file(&path);
2507    }
2508
2509    #[test]
2510    fn preflight_gate_survives_shutdown_before_replacement_creates() {
2511        let mut transport = NoNetwork::default();
2512        let mut client: Option<SyncClient> = None;
2513        let mut effects = CreateEffects::default();
2514        dispatch(
2515            &mut transport,
2516            &mut client,
2517            &mut effects,
2518            "create",
2519            &json!({ "schema": schema(), "securityPreflight": true }),
2520        )
2521        .expect("preflight create");
2522        dispatch(
2523            &mut transport,
2524            &mut client,
2525            &mut effects,
2526            "shutdown",
2527            &json!({}),
2528        )
2529        .expect("shutdown during preflight");
2530        assert!(client.is_none());
2531
2532        // The pending preflight rides `effects` across the empty client slot.
2533        let escape = dispatch(
2534            &mut transport,
2535            &mut client,
2536            &mut effects,
2537            "create",
2538            &json!({ "schema": schema() }),
2539        )
2540        .expect_err("shutdown + plain create must stay refused");
2541        assert_eq!(escape.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2542
2543        dispatch(
2544            &mut transport,
2545            &mut client,
2546            &mut effects,
2547            "create",
2548            &json!({ "schema": schema(), "securityPreflight": true }),
2549        )
2550        .expect("preflighted re-create");
2551        dispatch(
2552            &mut transport,
2553            &mut client,
2554            &mut effects,
2555            "activateSecurity",
2556            &json!({}),
2557        )
2558        .expect("activation");
2559
2560        // An ACTIVATED client shut down cleanly may be recreated plainly.
2561        dispatch(
2562            &mut transport,
2563            &mut client,
2564            &mut effects,
2565            "shutdown",
2566            &json!({}),
2567        )
2568        .expect("shutdown after activation");
2569        dispatch(
2570            &mut transport,
2571            &mut client,
2572            &mut effects,
2573            "create",
2574            &json!({ "schema": schema() }),
2575        )
2576        .expect("plain create after an activated shutdown");
2577    }
2578
2579    #[test]
2580    fn activate_security_validates_optional_headers_atomically() {
2581        let mut transport = NoNetwork::default();
2582        let mut client: Option<SyncClient> = None;
2583        let mut effects = CreateEffects::default();
2584        dispatch(
2585            &mut transport,
2586            &mut client,
2587            &mut effects,
2588            "create",
2589            &json!({ "schema": schema(), "securityPreflight": true }),
2590        )
2591        .expect("preflight create");
2592
2593        // Invalid header shapes fail loudly and keep the gate closed.
2594        let invalid = dispatch(
2595            &mut transport,
2596            &mut client,
2597            &mut effects,
2598            "activateSecurity",
2599            &json!({ "headers": { "authorization": 7 } }),
2600        )
2601        .expect_err("non-string header must fail");
2602        assert_eq!(invalid.code, "sync.invalid_request");
2603        let lifecycle = dispatch(
2604            &mut transport,
2605            &mut client,
2606            &mut effects,
2607            "securityLifecycle",
2608            &json!({}),
2609        )
2610        .expect("lifecycle");
2611        assert_eq!(lifecycle, json!({ "state": "preflight" }));
2612
2613        // A valid header set activates in one atomic step.
2614        dispatch(
2615            &mut transport,
2616            &mut client,
2617            &mut effects,
2618            "activateSecurity",
2619            &json!({ "headers": { "authorization": "Bearer fresh" } }),
2620        )
2621        .expect("activation with fresh headers");
2622        let lifecycle = dispatch(
2623            &mut transport,
2624            &mut client,
2625            &mut effects,
2626            "securityLifecycle",
2627            &json!({}),
2628        )
2629        .expect("lifecycle");
2630        assert_eq!(lifecycle, json!({ "state": "active" }));
2631    }
2632
2633    #[test]
2634    fn parse_headers_reads_the_full_replacement_set() {
2635        let parsed = parse_headers(&json!({
2636            "authorization": "Bearer fresh",
2637            "x-tenant": "t1"
2638        }))
2639        .expect("valid headers parse");
2640        assert_eq!(
2641            parsed,
2642            vec![
2643                ("authorization".to_owned(), "Bearer fresh".to_owned()),
2644                ("x-tenant".to_owned(), "t1".to_owned())
2645            ]
2646        );
2647        assert!(parse_headers(&json!(["authorization"])).is_err());
2648        assert!(parse_headers(&json!({ "authorization": null })).is_err());
2649    }
2650
2651    #[test]
2652    fn set_headers_validates_replacement_and_respects_preflight() {
2653        let mut transport = NoNetwork::default();
2654        let mut client: Option<SyncClient> = None;
2655        let mut effects = CreateEffects::default();
2656        dispatch(
2657            &mut transport,
2658            &mut client,
2659            &mut effects,
2660            "create",
2661            &json!({ "schema": schema() }),
2662        )
2663        .expect("create");
2664        dispatch(
2665            &mut transport,
2666            &mut client,
2667            &mut effects,
2668            "setHeaders",
2669            &json!({ "headers": { "authorization": "Bearer fresh" } }),
2670        )
2671        .expect("valid replacement");
2672        let invalid = dispatch(
2673            &mut transport,
2674            &mut client,
2675            &mut effects,
2676            "setHeaders",
2677            &json!({ "headers": { "authorization": 7 } }),
2678        )
2679        .expect_err("invalid replacement");
2680        assert_eq!(invalid.code, "sync.invalid_request");
2681
2682        dispatch(
2683            &mut transport,
2684            &mut client,
2685            &mut effects,
2686            "beginSecurityPreflight",
2687            &json!({}),
2688        )
2689        .expect("preflight");
2690        let gated = dispatch(
2691            &mut transport,
2692            &mut client,
2693            &mut effects,
2694            "setHeaders",
2695            &json!({ "headers": {} }),
2696        )
2697        .expect_err("direct rotation must respect preflight");
2698        assert_eq!(gated.code, SECURITY_PREFLIGHT_REQUIRED_CODE);
2699    }
2700
2701    fn schema_v2() -> Value {
2702        json!({
2703            "version": 2,
2704            "tables": [{
2705                "name": "todos",
2706                "columns": [
2707                    { "name": "id", "type": "string", "nullable": false },
2708                    { "name": "list_id", "type": "string", "nullable": false },
2709                    { "name": "note", "type": "string", "nullable": true }
2710                ],
2711                "primaryKey": "id",
2712                "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
2713            }]
2714        })
2715    }
2716
2717    fn previous_version_enabled() -> Value {
2718        json!({ "enabled": true })
2719    }
2720
2721    fn container_path(db_path: &str) -> String {
2722        format!("{db_path}.prev-context")
2723    }
2724
2725    #[test]
2726    fn previous_version_config_parser_matches_ts_keys_and_defaults() {
2727        assert_eq!(parse_previous_version_context(None).expect("absent"), None);
2728        let defaults = parse_previous_version_context(Some(&json!({})))
2729            .expect("empty")
2730            .expect("present");
2731        assert!(!defaults.enabled);
2732        assert_eq!(defaults.max_bytes, 8 * 1024 * 1024);
2733        assert_eq!(defaults.max_rows, 20_000);
2734        assert_eq!(defaults.max_tables, 32);
2735        assert_eq!(defaults.max_row_bytes, 1024 * 1024);
2736        assert_eq!(defaults.max_age_ms, 24 * 60 * 60 * 1000);
2737
2738        let configured = parse_previous_version_context(Some(&json!({
2739            "enabled": true,
2740            "maxBytes": 1024,
2741            "maxRows": 3,
2742            "maxTables": 2,
2743            "maxRowBytes": 5,
2744            "maxAgeMs": 7
2745        })))
2746        .expect("configured")
2747        .expect("present");
2748        assert!(configured.enabled);
2749        assert_eq!(configured.max_bytes, 1024);
2750        assert_eq!(configured.max_rows, 3);
2751        assert_eq!(configured.max_tables, 2);
2752        assert_eq!(configured.max_row_bytes, 5);
2753        assert_eq!(configured.max_age_ms, 7);
2754
2755        // Invalid values fail loudly instead of silently defaulting.
2756        for (value, expected) in [
2757            (json!([]), "must be an object"),
2758            (json!({ "enabled": "yes" }), "enabled must be a boolean"),
2759            (
2760                json!({ "maxBytes": 0 }),
2761                "maxBytes must be a positive safe integer",
2762            ),
2763            (
2764                json!({ "maxRows": -1 }),
2765                "maxRows must be a positive safe integer",
2766            ),
2767            (
2768                json!({ "maxTables": 1.5 }),
2769                "maxTables must be a positive safe integer",
2770            ),
2771            (
2772                json!({ "maxRowBytes": "1" }),
2773                "maxRowBytes must be a positive safe integer",
2774            ),
2775            (
2776                json!({ "maxAgeMs": 0 }),
2777                "maxAgeMs must be a positive safe integer",
2778            ),
2779        ] {
2780            let error = parse_previous_version_context(Some(&value)).expect_err("must fail");
2781            assert_eq!(error.code, "sync.invalid_request", "{value}");
2782            assert!(
2783                error.message.contains(expected),
2784                "{value}: {}",
2785                error.message
2786            );
2787        }
2788    }
2789
2790    #[test]
2791    fn invalid_previous_version_config_fails_create_loudly() {
2792        let mut transport = NoNetwork::default();
2793        let mut client: Option<SyncClient> = None;
2794        let mut effects = CreateEffects::default();
2795        let error = dispatch(
2796            &mut transport,
2797            &mut client,
2798            &mut effects,
2799            "create",
2800            &json!({ "schema": schema(), "previousVersionContext": { "enabled": true, "maxRows": 0 } }),
2801        )
2802        .expect_err("invalid config must fail the create");
2803        assert_eq!(error.code, "sync.invalid_request");
2804        assert!(error.message.contains("maxRows"), "{}", error.message);
2805        assert!(client.is_none(), "no client may be installed");
2806    }
2807
2808    #[test]
2809    fn previous_version_snapshot_reports_not_configured_by_default() {
2810        let mut transport = NoNetwork::default();
2811        let mut client: Option<SyncClient> = None;
2812        let mut effects = CreateEffects::default();
2813        dispatch(
2814            &mut transport,
2815            &mut client,
2816            &mut effects,
2817            "create",
2818            &json!({ "schema": schema() }),
2819        )
2820        .expect("create");
2821        let snapshot = dispatch(
2822            &mut transport,
2823            &mut client,
2824            &mut effects,
2825            "previousVersionSnapshot",
2826            &json!({ "table": "todos" }),
2827        )
2828        .expect("snapshot");
2829        assert_eq!(snapshot["state"], json!("previousVersion"));
2830        assert_eq!(snapshot["available"], json!(false));
2831        assert_eq!(snapshot["reason"], json!("not-configured"));
2832        assert_eq!(snapshot["rows"], json!([]));
2833        assert_eq!(snapshot["truncated"], json!(false));
2834        assert_eq!(snapshot["currentVersion"], json!(1));
2835        let discarded = dispatch(
2836            &mut transport,
2837            &mut client,
2838            &mut effects,
2839            "previousVersionDiscard",
2840            &json!({}),
2841        )
2842        .expect("discard");
2843        assert_eq!(discarded, json!({ "present": false, "discarded": false }));
2844        assert_eq!(
2845            dispatch(
2846                &mut transport,
2847                &mut client,
2848                &mut effects,
2849                "previousVersionAudit",
2850                &json!({}),
2851            )
2852            .expect("audit"),
2853            Value::Null
2854        );
2855    }
2856
2857    #[test]
2858    fn previous_version_commands_round_trip_through_the_dispatcher() {
2859        let path = temp_db_path("previous-version");
2860        let mut transport = NoNetwork::default();
2861        let mut client: Option<SyncClient> = None;
2862        let mut effects = CreateEffects::default();
2863
2864        // A v1 replica with one pending local row and the feature on.
2865        dispatch(
2866            &mut transport,
2867            &mut client,
2868            &mut effects,
2869            "create",
2870            &json!({
2871                "schema": schema(),
2872                "dbPath": path,
2873                "previousVersionContext": previous_version_enabled()
2874            }),
2875        )
2876        .expect("create v1");
2877        client
2878            .as_mut()
2879            .expect("client")
2880            .mutate(vec![Mutation::Upsert {
2881                table: "todos".to_owned(),
2882                values: serde_json::Map::from_iter([
2883                    ("id".to_owned(), json!("t1")),
2884                    ("list_id".to_owned(), json!("l1")),
2885                ]),
2886                base_version: None,
2887            }])
2888            .expect("seed a local row");
2889        dispatch(
2890            &mut transport,
2891            &mut client,
2892            &mut effects,
2893            "shutdown",
2894            &json!({}),
2895        )
2896        .expect("shutdown");
2897
2898        // The bump captures into the sibling file.
2899        dispatch(
2900            &mut transport,
2901            &mut client,
2902            &mut effects,
2903            "create",
2904            &json!({
2905                "schema": schema_v2(),
2906                "dbPath": path,
2907                "previousVersionContext": previous_version_enabled()
2908            }),
2909        )
2910        .expect("create v2");
2911        assert!(
2912            std::path::Path::new(&container_path(&path)).exists(),
2913            "the bump must leave a container file"
2914        );
2915        let status = dispatch(
2916            &mut transport,
2917            &mut client,
2918            &mut effects,
2919            "statusSnapshot",
2920            &json!({}),
2921        )
2922        .expect("status");
2923        assert_eq!(
2924            status["previousVersionContext"]["present"],
2925            json!(true),
2926            "{status}"
2927        );
2928        assert!(
2929            status["previousVersionContext"]["createdAtMs"].is_i64(),
2930            "{status}"
2931        );
2932
2933        let snapshot = dispatch(
2934            &mut transport,
2935            &mut client,
2936            &mut effects,
2937            "previousVersionSnapshot",
2938            &json!({ "table": "todos" }),
2939        )
2940        .expect("snapshot");
2941        assert_eq!(snapshot["state"], json!("previousVersion"));
2942        assert_eq!(snapshot["available"], json!(true));
2943        assert_eq!(snapshot["previousVersion"], json!(1));
2944        assert_eq!(snapshot["currentVersion"], json!(2));
2945        assert_eq!(snapshot["truncated"], json!(false));
2946        assert_eq!(snapshot["rows"][0]["id"], json!("t1"));
2947        assert_eq!(snapshot["rows"][0]["list_id"], json!("l1"));
2948
2949        // Unknown previous table and a bad limit are loud request errors.
2950        let unknown = dispatch(
2951            &mut transport,
2952            &mut client,
2953            &mut effects,
2954            "previousVersionSnapshot",
2955            &json!({ "table": "absent" }),
2956        )
2957        .expect_err("unknown previous table");
2958        assert_eq!(unknown.code, "sync.invalid_request");
2959        let bad_limit = dispatch(
2960            &mut transport,
2961            &mut client,
2962            &mut effects,
2963            "previousVersionSnapshot",
2964            &json!({ "table": "todos", "limit": 0 }),
2965        )
2966        .expect_err("bad limit");
2967        assert_eq!(bad_limit.code, "sync.invalid_request");
2968
2969        // D6: the audit names the pending commit's classification, with no
2970        // envelope and nothing dropped.
2971        let audit = dispatch(
2972            &mut transport,
2973            &mut client,
2974            &mut effects,
2975            "previousVersionAudit",
2976            &json!({}),
2977        )
2978        .expect("audit");
2979        assert_eq!(audit["v"], json!(1));
2980        assert_eq!(audit["fromVersion"], json!(1));
2981        assert_eq!(audit["toVersion"], json!(2));
2982        assert_eq!(audit["pending"], json!(1));
2983        assert_eq!(audit["encodable"], json!(1));
2984        assert_eq!(audit["incompatible"], json!([]));
2985        assert!(audit.get("operations").is_none());
2986
2987        // The executable downgrade step, idempotent.
2988        let discarded = dispatch(
2989            &mut transport,
2990            &mut client,
2991            &mut effects,
2992            "previousVersionDiscard",
2993            &json!({}),
2994        )
2995        .expect("discard");
2996        assert_eq!(discarded, json!({ "present": true, "discarded": true }));
2997        assert!(!std::path::Path::new(&container_path(&path)).exists());
2998        let again = dispatch(
2999            &mut transport,
3000            &mut client,
3001            &mut effects,
3002            "previousVersionDiscard",
3003            &json!({}),
3004        )
3005        .expect("idempotent discard");
3006        assert_eq!(again, json!({ "present": false, "discarded": false }));
3007
3008        // Storage read directly: both metadata records are gone.
3009        let inspect = rusqlite::Connection::open(&path).expect("inspect replica");
3010        let remaining: i64 = inspect
3011            .query_row(
3012                "SELECT COUNT(*) FROM _syncular_meta WHERE key IN (?1, ?2)",
3013                rusqlite::params!["previousVersionContext", "previousVersionAudit"],
3014                |row| row.get(0),
3015            )
3016            .expect("count meta");
3017        assert_eq!(remaining, 0);
3018
3019        let _ = std::fs::remove_file(&path);
3020    }
3021
3022    #[test]
3023    fn previous_version_ttl_expires_at_read_through_the_dispatcher() {
3024        let path = temp_db_path("previous-version-ttl");
3025        let mut transport = NoNetwork::default();
3026        let mut client: Option<SyncClient> = None;
3027        let mut effects = CreateEffects::default();
3028        dispatch(
3029            &mut transport,
3030            &mut client,
3031            &mut effects,
3032            "create",
3033            &json!({
3034                "schema": schema(),
3035                "dbPath": path,
3036                "previousVersionContext": previous_version_enabled()
3037            }),
3038        )
3039        .expect("create v1");
3040        dispatch(
3041            &mut transport,
3042            &mut client,
3043            &mut effects,
3044            "shutdown",
3045            &json!({}),
3046        )
3047        .expect("shutdown");
3048
3049        // The injected clock is two seconds past the capture, with a TTL far
3050        // larger than the boot gap but far smaller than the skew, so the boot
3051        // reconcile keeps the container and the read expires it.
3052        let now_ms = std::time::SystemTime::now()
3053            .duration_since(std::time::UNIX_EPOCH)
3054            .expect("clock")
3055            .as_millis() as i64
3056            + 2_000;
3057        dispatch(
3058            &mut transport,
3059            &mut client,
3060            &mut effects,
3061            "create",
3062            &json!({
3063                "schema": schema_v2(),
3064                "dbPath": path,
3065                "nowMs": now_ms,
3066                "previousVersionContext": { "enabled": true, "maxAgeMs": 500 }
3067            }),
3068        )
3069        .expect("create v2");
3070        assert!(std::path::Path::new(&container_path(&path)).exists());
3071        let snapshot = dispatch(
3072            &mut transport,
3073            &mut client,
3074            &mut effects,
3075            "previousVersionSnapshot",
3076            &json!({ "table": "todos" }),
3077        )
3078        .expect("snapshot");
3079        assert_eq!(snapshot["available"], json!(false));
3080        assert_eq!(snapshot["reason"], json!("expired"));
3081        assert_eq!(snapshot["rows"], json!([]));
3082        // The TTL discards, and the FILE is what must be gone.
3083        assert!(!std::path::Path::new(&container_path(&path)).exists());
3084
3085        let _ = std::fs::remove_file(&path);
3086    }
3087}