Skip to main content

syncular_command/
lib.rs

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