Skip to main content

syncular_command/
lib.rs

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