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