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