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