Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use std::cell::{Cell, RefCell};
11use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
12
13use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
14use rusqlite::{Connection, OpenFlags};
15use serde_json::{Map, Value};
16use sha2::{Digest, Sha256};
17use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
18use ssp2::primitives::RawJson;
19use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
20use ssp2::{
21    decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
22    PresenceKind,
23};
24
25use crate::api::{
26    ClientChangeBatch, ClientLimits, CommandEffects, ConflictRecord, CoverageSnapshot, LeaseState,
27    Mutation, PresencePeer, QuerySnapshot, RejectionRecord, RowState, SchemaFloor,
28    SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
29    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
30};
31use crate::schema::{parse_schema_json, ClientSchema};
32use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
33use crate::values::{
34    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
35    json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
36    scope_map_to_json, sort_scope_map,
37};
38
39/// §4.2 default: the rows baseline plus sqlite images (§5.3) — rusqlite
40/// can always import an image, so the premier path is advertised unless
41/// the host overrides `limits.accept`. Bit 3 (signed URLs, §5.4) is
42/// added per transport capability at request-build time.
43const DEFAULT_ACCEPT: u8 = 0b0111;
44const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
45const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
46const ACCEPT_SQLITE: u8 = 1 << 2;
47const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
48
49/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
50const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
51const LOCAL_REVISION_KEY: &str = "localRevision";
52const CLIENT_ID_KEY: &str = "clientId";
53const LEASE_STATE_KEY: &str = "leaseState";
54const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
55/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
56/// the new schema after a bump. Never a wire code (§10.3).
57const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum SubState {
61    Active,
62    Revoked,
63    Failed,
64}
65
66#[cfg(test)]
67mod observation_tests {
68    use super::*;
69    use serde_json::json;
70
71    fn client() -> SyncClient {
72        SyncClient::new(
73            "retry-test".to_owned(),
74            &json!({
75                "version": 1,
76                "tables": [{
77                    "name": "tasks",
78                    "primaryKey": "id",
79                    "columns": [
80                        { "name": "id", "type": "string", "nullable": false },
81                        { "name": "project_id", "type": "string", "nullable": false }
82                    ],
83                    "scopes": [{ "pattern": "project:{project_id}" }]
84                }]
85            }),
86            ClientLimits::default(),
87        )
88        .expect("test client")
89    }
90
91    #[test]
92    fn background_retry_deadlines_back_off_and_reset() {
93        let mut client = client();
94        client.schedule_background_retry();
95        assert!(matches!(
96            client.drain_sync_intents().as_slice(),
97            [SyncIntent::Background { delay_ms: 250 }]
98        ));
99        client.schedule_background_retry();
100        assert!(matches!(
101            client.drain_sync_intents().as_slice(),
102            [SyncIntent::Background { delay_ms: 500 }]
103        ));
104        client.reset_background_retry();
105        client.schedule_background_retry();
106        assert!(matches!(
107            client.drain_sync_intents().as_slice(),
108            [SyncIntent::Background { delay_ms: 250 }]
109        ));
110    }
111
112    #[test]
113    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
114        let path = std::env::temp_dir().join(format!(
115            "syncular-startup-intent-{}.db",
116            uuid::Uuid::new_v4()
117        ));
118        let schema = json!({
119            "version": 1,
120            "tables": [{
121                "name": "tasks",
122                "primaryKey": "id",
123                "columns": [
124                    { "name": "id", "type": "string", "nullable": false },
125                    { "name": "project_id", "type": "string", "nullable": false }
126                ],
127                "scopes": [{ "pattern": "project:{project_id}" }]
128            }]
129        });
130
131        {
132            let mut first = SyncClient::open_path_with_identity(
133                None,
134                &schema,
135                ClientLimits::default(),
136                path.to_str().expect("UTF-8 temp path"),
137            )
138            .expect("first open");
139            first
140                .set_window(
141                    &WindowBase {
142                        table: "tasks".to_owned(),
143                        variable: "project_id".to_owned(),
144                        fixed_scopes: Vec::new(),
145                        params: None,
146                    },
147                    &["persisted".to_owned()],
148                )
149                .expect("persist window");
150        }
151
152        let mut reopened = SyncClient::open_path_with_identity(
153            None,
154            &schema,
155            ClientLimits::default(),
156            path.to_str().expect("UTF-8 temp path"),
157        )
158        .expect("reopen");
159        assert!(reopened.sync_needed());
160        assert!(matches!(
161            reopened.drain_sync_intents().as_slice(),
162            [SyncIntent::Interactive]
163        ));
164        drop(reopened);
165        std::fs::remove_file(path).expect("remove temp database");
166    }
167
168    #[test]
169    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
170        let path =
171            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
172        let schema = json!({
173            "version": 1,
174            "tables": [{
175                "name": "tasks",
176                "primaryKey": "id",
177                "columns": [
178                    { "name": "id", "type": "string", "nullable": false },
179                    { "name": "project_id", "type": "string", "nullable": false }
180                ],
181                "scopes": [{ "pattern": "project:{project_id}" }]
182            }]
183        });
184        let mut client = SyncClient::open_path_with_identity(
185            Some("sidecar-client".to_owned()),
186            &schema,
187            ClientLimits::default(),
188            path.to_str().expect("UTF-8 temp path"),
189        )
190        .expect("open owner");
191        let base = WindowBase {
192            table: "tasks".to_owned(),
193            variable: "project_id".to_owned(),
194            fixed_scopes: Vec::new(),
195            params: None,
196        };
197        client
198            .set_window(&base, &["one".to_owned()])
199            .expect("set window");
200        client
201            .mutate(vec![Mutation::Upsert {
202                table: "tasks".to_owned(),
203                values: Map::from_iter([
204                    ("id".to_owned(), Value::from("t1")),
205                    ("project_id".to_owned(), Value::from("one")),
206                ]),
207                base_version: None,
208            }])
209            .expect("local mutate");
210
211        let coverage = [WindowCoverage {
212            base,
213            units: vec!["one".to_owned(), "missing".to_owned()],
214        }];
215        let owner = client
216            .query_snapshot(
217                "SELECT id, project_id FROM tasks ORDER BY id",
218                &[],
219                &coverage,
220            )
221            .expect("owner snapshot");
222        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
223        let sidecar = reader
224            .query_snapshot(
225                "SELECT id, project_id FROM tasks ORDER BY id",
226                &[],
227                &coverage,
228            )
229            .expect("sidecar snapshot");
230
231        assert_eq!(sidecar.revision, owner.revision);
232        assert_eq!(sidecar.rows, owner.rows);
233        assert_eq!(
234            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
235            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
236        );
237        assert_eq!(sidecar.revision, "2");
238        assert_eq!(sidecar.rows[0]["id"], "t1");
239        assert!(!sidecar.coverage.complete);
240        assert_eq!(sidecar.coverage.pending.len(), 1);
241        assert_eq!(sidecar.coverage.missing.len(), 1);
242
243        drop(reader);
244        drop(client);
245        std::fs::remove_file(path).expect("remove temp database");
246    }
247}
248
249impl SubState {
250    fn name(self) -> &'static str {
251        match self {
252            SubState::Active => "active",
253            SubState::Revoked => "revoked",
254            SubState::Failed => "failed",
255        }
256    }
257
258    fn parse(value: &str) -> Self {
259        match value {
260            "revoked" => Self::Revoked,
261            "failed" => Self::Failed,
262            _ => Self::Active,
263        }
264    }
265}
266
267#[derive(Debug, Clone)]
268struct Subscription {
269    id: String,
270    table: String,
271    requested: Vec<(String, Vec<String>)>,
272    params: Option<String>,
273    cursor: i64,
274    /// §4.7 resume token, round-tripped opaquely.
275    bootstrap_state: Option<String>,
276    state: SubState,
277    reason_code: Option<String>,
278    /// Last effective scopes echoed while active (§3.3: persisted for the
279    /// purge contract; each active echo replaces it).
280    effective: Option<Vec<(String, Vec<String>)>>,
281    synced_once: bool,
282}
283
284#[derive(Debug, Clone)]
285struct OutboxOp {
286    upsert: bool,
287    table: String,
288    row_id: String,
289    base_version: Option<i64>,
290    /// Schema-agnostic local form (§0): driver JSON values, encoded with
291    /// the current codec at send time.
292    values: Option<Map<String, Value>>,
293}
294
295#[derive(Debug, Clone)]
296struct OutboxCommit {
297    client_commit_id: String,
298    ops: Vec<OutboxOp>,
299}
300
301/// Section outcome distinguishing the §5.6 subscription-local fail-closed
302/// path from a round-aborting failure (§1.4 rule 5).
303enum SectionError {
304    FailClosed,
305    Abort(String, String),
306}
307
308struct RequestMeta {
309    pushed_ids: Vec<String>,
310    /// Subscription id → the request carried `cursor < 0` and no resume
311    /// token (§5.6 first-page detection: a *fresh* bootstrap).
312    fresh: Vec<(String, bool)>,
313    accept: u8,
314    /// §6.1 splitBatch: outbox commits held back from THIS request because
315    /// the running operation count reached the push cap — the next round
316    /// pushes them (`sync_needed` stays set while any remain).
317    deferred_commits: usize,
318}
319
320#[derive(Default)]
321struct ChangeAccumulator {
322    /// None means table-wide; Some is the exact scoped domain.
323    tables: BTreeMap<String, Option<BTreeSet<String>>>,
324    windows: BTreeMap<(String, String), BTreeSet<String>>,
325    status: bool,
326    conflicts: bool,
327    rejections: bool,
328}
329
330impl ChangeAccumulator {
331    fn table(&mut self, table: &str) {
332        self.tables.insert(table.to_owned(), None);
333    }
334
335    fn scope(&mut self, table: &str, key: String) {
336        match self.tables.get_mut(table) {
337            Some(None) => {}
338            Some(Some(keys)) => {
339                keys.insert(key);
340            }
341            None => {
342                self.tables
343                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
344            }
345        }
346    }
347
348    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
349        self.windows
350            .entry((base_key.to_owned(), table.to_owned()))
351            .or_default()
352            .insert(unit.to_owned());
353    }
354
355    fn touched(&self) -> bool {
356        !self.tables.is_empty()
357            || !self.windows.is_empty()
358            || self.status
359            || self.conflicts
360            || self.rejections
361    }
362}
363
364/// §6.1: the server caps total operations per request (reference default
365/// 500) and rejects the whole batch with `sync.too_many_operations`; the
366/// client "splits and retries". Splitting happens at build time: commits are
367/// included IN ORDER until the operation budget is spent, the rest wait for
368/// the next round.
369const PUSH_OPS_PER_REQUEST: usize = 500;
370
371pub struct SyncClient {
372    conn: Connection,
373    schema: ClientSchema,
374    client_id: String,
375    limits: ClientLimits,
376    subs: Vec<Subscription>,
377    outbox: Vec<OutboxCommit>,
378    conflicts: Vec<ConflictRecord>,
379    rejections: Vec<RejectionRecord>,
380    schema_floor: Option<SchemaFloor>,
381    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
382    lease_state: Option<LeaseState>,
383    /// §1.6: the schema-floor response stops syncing until an upgrade.
384    stopped: bool,
385    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
386    upgrading: bool,
387    /// §8.4 coalesced sync-needed signal.
388    sync_needed: bool,
389    realtime_connected: bool,
390    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
391    presence: HashMap<String, HashMap<String, PresencePeer>>,
392    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
393    /// host may pin it (conformance runs on a virtual clock).
394    now_ms: Option<i64>,
395    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
396    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
397    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
398    encryption: crate::values::EncryptionConfig,
399    /// Per-table `INSERT OR REPLACE` SQL, built once per (full table name) —
400    /// the row write path runs per row during bootstrap (§5.6), so the SQL
401    /// string (and, via `prepare_cached`, its compiled statement) is reused
402    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
403    /// schema reset (the column lists may have changed).
404    insert_sql: RefCell<HashMap<String, String>>,
405    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
406    /// diverged from the visible overlay since the last rebuild. Lets a
407    /// no-op sync round skip the full base→visible copy.
408    overlay_dirty: Cell<bool>,
409    /// Exact observer-transaction output drained by command/FFI hosts.
410    change_queue: VecDeque<ClientChangeBatch>,
411    sync_intent_queue: VecDeque<SyncIntent>,
412    /// Explicit exponential retry policy for transient transport failures.
413    retry_delay_ms: u64,
414}
415
416fn quote_ident(name: &str) -> String {
417    format!("\"{}\"", name.replace('"', "\"\""))
418}
419
420fn base_table(name: &str) -> String {
421    quote_ident(&format!("_syncular_base_{name}"))
422}
423
424/// §4.8: a stable, server-opaque key for a window base — table + variable +
425/// canonical fixed scopes. Two `set_window` calls with the same base
426/// address the same registry rows.
427/// §4.8 deferred eviction record: (sub id, table, effective scope map).
428type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
429
430fn window_base_key(base: &WindowBase) -> String {
431    format!(
432        "{}\0{}\0{}",
433        base.table,
434        base.variable,
435        canonical_scope_json(&base.fixed_scopes)
436    )
437}
438
439/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
440fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
441    let mut scopes = base.fixed_scopes.clone();
442    scopes.retain(|(k, _)| k != &base.variable);
443    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
444    scopes
445}
446
447/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
448/// echoed not interpreted by the server, so the exact hash is client
449/// convention; SHA-256 matches the SPEC's worked example.
450fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
451    let canonical = canonical_scope_json(&unit_scopes(base, unit));
452    let digest = Sha256::digest(canonical.as_bytes());
453    let hex = bytes_to_hex(&digest);
454    format!("w:{}:{}", base.table, &hex[..16])
455}
456
457fn visible_table(name: &str) -> String {
458    quote_ident(name)
459}
460
461/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
462/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
463fn is_synced_table_name(name: &str) -> bool {
464    if name.starts_with("sqlite_") {
465        return false;
466    }
467    if name.starts_with("_syncular_base_") {
468        return true; // the base half of a synced table pair
469    }
470    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
471    !name.starts_with("_syncular_")
472}
473
474/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
475fn blob_id_for(bytes: &[u8]) -> String {
476    let digest = Sha256::digest(bytes);
477    format!("sha256:{}", bytes_to_hex(&digest))
478}
479
480/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
481/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
482/// per row on the §5.6 bootstrap path), scalars bind owned.
483enum RowParam<'a> {
484    Cell(&'a Option<ColumnValue>),
485    Version(i64),
486}
487
488impl rusqlite::ToSql for RowParam<'_> {
489    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
490        Ok(match self {
491            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
492            RowParam::Cell(cell) => match cell {
493                None => ToSqlOutput::Owned(SqlValue::Null),
494                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
495                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
496                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
497                Some(ColumnValue::Boolean(b)) => {
498                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
499                }
500                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
501                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
502                }
503                // §5.10: crdt bytes store as BLOB, like bytes.
504                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
505                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
506                }
507            },
508        })
509    }
510}
511
512/// §5.3 image cell → bind parameter, strict per the declared column type
513/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
514/// nullable). Mismatches are image-producer violations. Returns the cell
515/// borrowed when the stored representation already matches what the row
516/// codec would write, or the normalized scalar (boolean → 0/1, float from
517/// INTEGER → REAL) otherwise — the local write is byte-identical to the
518/// old convert-then-insert path without allocating per cell.
519fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
520    use ssp2::segment::ColumnType;
521    let mismatch = || {
522        Err(format!(
523            "image column {:?} holds a value of the wrong type",
524            column.name
525        ))
526    };
527    match value {
528        ValueRef::Null => {
529            if !column.nullable {
530                return Err(format!(
531                    "image column {:?} is NULL but not nullable",
532                    column.name
533                ));
534            }
535            Ok(ToSqlOutput::Owned(SqlValue::Null))
536        }
537        ValueRef::Integer(i) => match column.ty {
538            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
539            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
540            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
541            _ => mismatch(),
542        },
543        ValueRef::Real(_) => match column.ty {
544            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
545            _ => mismatch(),
546        },
547        ValueRef::Text(t) => {
548            std::str::from_utf8(t)
549                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
550            match column.ty {
551                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
552                    Ok(ToSqlOutput::Borrowed(value))
553                }
554                _ => mismatch(),
555            }
556        }
557        ValueRef::Blob(_) => match column.ty {
558            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
559            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
560            _ => mismatch(),
561        },
562    }
563}
564
565fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
566    use rusqlite::types::ValueRef;
567    match value {
568        ValueRef::Null => Value::Null,
569        ValueRef::Integer(i) => match column.ty {
570            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
571            ssp2::segment::ColumnType::Float => {
572                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
573            }
574            _ => Value::from(i),
575        },
576        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
577        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
578        ValueRef::Blob(b) => {
579            let mut map = Map::new();
580            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
581            Value::Object(map)
582        }
583    }
584}
585
586/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
587/// Objects are accepted in lossless `{"$bytes": hex}` and
588/// `{"$bigint": decimal}` envelope forms.
589fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
590    Ok(match value {
591        Value::Null => SqlValue::Null,
592        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
593        Value::Number(n) => {
594            if let Some(i) = n.as_i64() {
595                SqlValue::Integer(i)
596            } else if let Some(f) = n.as_f64() {
597                SqlValue::Real(f)
598            } else {
599                return Err(format!("query param number {n} is out of range"));
600            }
601        }
602        Value::String(s) => SqlValue::Text(s.clone()),
603        Value::Object(_) => {
604            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
605                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
606            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
607                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
608                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
609                })?)
610            } else {
611                return Err(
612                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
613                        .to_owned(),
614                );
615            }
616        }
617        Value::Array(_) => return Err("query array params are not supported".to_owned()),
618    })
619}
620
621/// Map a rusqlite value with no schema column to consult (arbitrary query
622/// output): integers/reals/text pass through by stored affinity, blobs ride
623/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
624/// schema column type to recover booleans/floats/json.
625fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
626    use rusqlite::types::ValueRef;
627    match value {
628        ValueRef::Null => Value::Null,
629        ValueRef::Integer(i) => {
630            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
631            // ordinary UI-sized integers ergonomic and envelope only values
632            // beyond JavaScript's safe range.
633            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
634            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
635                Value::from(i)
636            } else {
637                let mut map = Map::new();
638                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
639                Value::Object(map)
640            }
641        }
642        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
643        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
644        ValueRef::Blob(b) => {
645            let mut map = Map::new();
646            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
647            Value::Object(map)
648        }
649    }
650}
651
652fn query_connection(
653    conn: &Connection,
654    sql: &str,
655    params: &[Value],
656) -> Result<Vec<Map<String, Value>>, String> {
657    crate::query_guard::assert_read_only_query(sql)?;
658    let bound: Vec<SqlValue> = params
659        .iter()
660        .map(json_param_to_sql)
661        .collect::<Result<_, _>>()?;
662    let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
663    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
664    let bound_refs: Vec<&dyn rusqlite::ToSql> =
665        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
666    let mut sql_rows = stmt
667        .query(bound_refs.as_slice())
668        .map_err(|e| e.to_string())?;
669    let mut out = Vec::new();
670    while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
671        let mut record = Map::new();
672        for (i, name) in column_names.iter().enumerate() {
673            let value = row.get_ref(i).map_err(|e| e.to_string())?;
674            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
675        }
676        out.push(record);
677    }
678    Ok(out)
679}
680
681fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
682    let mut stmt = conn
683        .prepare(
684            "SELECT windows.unit, subscriptions.state_json
685               FROM _syncular_windows AS windows
686               JOIN _syncular_subscriptions AS subscriptions
687                 ON subscriptions.id = windows.sub_id
688              WHERE windows.base = ?1
689              ORDER BY windows.unit ASC",
690        )
691        .map_err(|error| error.to_string())?;
692    let rows = stmt
693        .query_map(rusqlite::params![window_base_key(base)], |row| {
694            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
695        })
696        .map_err(|error| error.to_string())?;
697    let mut units = Vec::new();
698    let mut pending = Vec::new();
699    for row in rows {
700        let (unit, raw) = row.map_err(|error| error.to_string())?;
701        let state: Value = serde_json::from_str(&raw)
702            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
703        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
704            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
705            || state
706                .get("bootstrapState")
707                .is_some_and(|value| !value.is_null());
708        if is_pending {
709            pending.push(unit.clone());
710        }
711        units.push(unit);
712    }
713    Ok(WindowState { units, pending })
714}
715
716fn snapshot_connection(
717    conn: &Connection,
718    sql: &str,
719    params: &[Value],
720    coverage: &[WindowCoverage],
721) -> Result<QuerySnapshot, String> {
722    conn.execute_batch("SAVEPOINT syncular_snapshot_read")
723        .map_err(|error| error.to_string())?;
724    let result = (|| {
725        let revision = conn
726            .query_row(
727                "SELECT value FROM _syncular_meta WHERE key = ?1",
728                rusqlite::params![LOCAL_REVISION_KEY],
729                |row| row.get::<_, String>(0),
730            )
731            .ok()
732            .and_then(|value| value.parse::<u64>().ok())
733            .unwrap_or(0);
734        let rows = query_connection(conn, sql, params)?;
735        let mut pending = Vec::new();
736        let mut missing = Vec::new();
737        for requested in coverage {
738            let base_key = window_base_key(&requested.base);
739            let state = persisted_window_state(conn, &requested.base)?;
740            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
741                let reference = WindowUnitRef {
742                    base_key: base_key.clone(),
743                    unit: unit.clone(),
744                };
745                if !state.units.iter().any(|held| held == &unit) {
746                    missing.push(reference);
747                } else if state.pending.iter().any(|held| held == &unit) {
748                    pending.push(reference);
749                }
750            }
751        }
752        Ok(QuerySnapshot {
753            revision: revision.to_string(),
754            rows,
755            coverage: CoverageSnapshot {
756                complete: pending.is_empty() && missing.is_empty(),
757                pending,
758                missing,
759            },
760        })
761    })();
762    match result {
763        Ok(snapshot) => {
764            conn.execute_batch("RELEASE syncular_snapshot_read")
765                .map_err(|error| error.to_string())?;
766            Ok(snapshot)
767        }
768        Err(error) => {
769            let _ = conn.execute_batch(
770                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
771            );
772            Err(error)
773        }
774    }
775}
776
777/// A long-lived read-only SQLite sidecar for latency-critical native views.
778/// Network rounds stay serialized on the mutable core owner, while atomic
779/// query snapshots use this independent connection and therefore never queue
780/// behind HTTP/WebSocket latency.
781pub struct FileQuerySnapshotReader {
782    path: String,
783    conn: Option<Connection>,
784}
785
786impl FileQuerySnapshotReader {
787    #[must_use]
788    pub fn new(path: impl Into<String>) -> Self {
789        Self {
790            path: path.into(),
791            conn: None,
792        }
793    }
794
795    fn connection(&mut self) -> Result<&Connection, String> {
796        if self.conn.is_none() {
797            let conn = Connection::open_with_flags(
798                &self.path,
799                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
800            )
801            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
802            conn.busy_timeout(std::time::Duration::from_millis(250))
803                .map_err(|error| error.to_string())?;
804            self.conn = Some(conn);
805        }
806        self.conn
807            .as_ref()
808            .ok_or_else(|| "read sidecar connection missing".to_owned())
809    }
810
811    pub fn query_snapshot(
812        &mut self,
813        sql: &str,
814        params: &[Value],
815        coverage: &[WindowCoverage],
816    ) -> Result<QuerySnapshot, String> {
817        snapshot_connection(self.connection()?, sql, params, coverage)
818    }
819}
820
821impl SyncClient {
822    pub fn new_with_identity(
823        client_id: Option<String>,
824        schema_json: &Value,
825        limits: ClientLimits,
826    ) -> Result<Self, String> {
827        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
828        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
829        Self::with_connection(resolved, schema_json, limits, conn)
830    }
831
832    pub fn new(
833        client_id: String,
834        schema_json: &Value,
835        limits: ClientLimits,
836    ) -> Result<Self, String> {
837        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
838        Self::with_connection(client_id, schema_json, limits, conn)
839    }
840
841    /// Build a client backed by an on-disk SQLite database at `path` — the
842    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
843    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
844    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
845    /// of the command router's dependency set (the router only holds a path).
846    pub fn open_path(
847        client_id: String,
848        schema_json: &Value,
849        limits: ClientLimits,
850        path: &str,
851    ) -> Result<Self, String> {
852        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
853        Self::with_connection(client_id, schema_json, limits, conn)
854    }
855
856    pub fn open_path_with_identity(
857        client_id: Option<String>,
858        schema_json: &Value,
859        limits: ClientLimits,
860        path: &str,
861    ) -> Result<Self, String> {
862        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
863        // File-backed native clients use an independent read connection for
864        // latency-critical snapshots. WAL is SQLite's intended reader/writer
865        // concurrency mode: a view read never holds a rollback-journal lock
866        // that delays the mutable client's next commit, and a short busy
867        // timeout absorbs the tiny checkpoint/schema-lock windows.
868        conn.busy_timeout(std::time::Duration::from_millis(250))
869            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
870        conn.pragma_update(None, "journal_mode", "WAL")
871            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
872        let persisted = conn
873            .query_row(
874                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
875                [],
876                |row| row.get::<_, String>(0),
877            )
878            .ok();
879        let resolved = persisted
880            .clone()
881            .or(client_id.clone())
882            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
883        if let (Some(existing), Some(requested)) = (persisted, client_id) {
884            if existing != requested {
885                return Err(format!(
886                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
887                ));
888            }
889        }
890        Self::with_connection(resolved, schema_json, limits, conn)
891    }
892
893    #[must_use]
894    pub fn client_id(&self) -> &str {
895        &self.client_id
896    }
897
898    /// Build a client over a caller-supplied rusqlite connection — the seam a
899    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
900    /// with an on-disk database (`Connection::open(path)`) rather than the
901    /// default `:memory:`. The connection MUST be fresh (no pre-existing
902    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
903    /// the same file across process restarts reuses the persisted rows.
904    pub fn with_connection(
905        client_id: String,
906        schema_json: &Value,
907        limits: ClientLimits,
908        conn: Connection,
909    ) -> Result<Self, String> {
910        let schema = parse_schema_json(schema_json)?;
911        let mut client = SyncClient {
912            conn,
913            schema,
914            client_id,
915            limits,
916            subs: Vec::new(),
917            outbox: Vec::new(),
918            conflicts: Vec::new(),
919            rejections: Vec::new(),
920            schema_floor: None,
921            lease_state: None,
922            stopped: false,
923            upgrading: false,
924            sync_needed: false,
925            realtime_connected: false,
926            presence: HashMap::new(),
927            now_ms: None,
928            encryption: crate::values::EncryptionConfig::default(),
929            insert_sql: RefCell::new(HashMap::new()),
930            overlay_dirty: Cell::new(false),
931            change_queue: VecDeque::new(),
932            sync_intent_queue: VecDeque::new(),
933            retry_delay_ms: 250,
934        };
935        // The row write path leans on the prepared-statement cache (two
936        // insert statements per synced table, plus the bookkeeping
937        // statements); size it so a multi-table schema never thrashes.
938        client
939            .conn
940            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
941        client.create_tables()?;
942        match client.get_meta(CLIENT_ID_KEY) {
943            Some(existing) if existing != client.client_id => {
944                return Err(format!(
945                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
946                    client.client_id
947                ));
948            }
949            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
950            _ => {}
951        }
952        client.restore_persisted_state()?;
953        let marker = client
954            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
955            .and_then(|value| value.parse::<i32>().ok());
956        if marker != Some(client.schema.version) {
957            client.run_schema_reset()?;
958        } else if !client.outbox.is_empty() {
959            // Reconstruct the visible optimistic overlay from the durable base
960            // plus outbox instead of trusting a process-interrupted mirror.
961            client.overlay_dirty.set(true);
962            client.rebuild_overlay();
963        }
964        // Every persisted active subscription needs one catch-up pull on open:
965        // realtime only covers changes after connection, while an idempotent
966        // setWindow correctly creates no fresh command effect. Pending outbox
967        // work has the same restart requirement. The core owns this intent so
968        // native hosts never poll or require an application-issued sync().
969        client.enqueue_startup_sync_if_needed();
970        Ok(client)
971    }
972
973    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
974    /// against this instead of system time (conformance virtual clock).
975    pub fn set_now_ms(&mut self, now_ms: i64) {
976        self.now_ms = Some(now_ms);
977    }
978
979    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
980    /// The command router parses these from the `create` command's
981    /// `encryption` config (keys as `{$bytes: hex}`).
982    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
983        self.encryption = encryption;
984    }
985
986    fn clock_now_ms(&self) -> i64 {
987        self.now_ms.unwrap_or_else(|| {
988            std::time::SystemTime::now()
989                .duration_since(std::time::UNIX_EPOCH)
990                .map(|d| d.as_millis() as i64)
991                .unwrap_or(0)
992        })
993    }
994
995    fn create_tables(&self) -> Result<(), String> {
996        self.create_synced_tables()?;
997        // Durable client bookkeeping (outbox + subscription + meta).
998        self.conn
999            .execute_batch(
1000                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
1001                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
1002                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
1003                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
1004                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
1005                 CREATE TABLE IF NOT EXISTS _syncular_meta (
1006                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
1007                 CREATE TABLE IF NOT EXISTS _syncular_windows (
1008                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
1009                   PRIMARY KEY (base, unit));
1010                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
1011                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
1012                   effective_scopes TEXT NOT NULL);",
1013            )
1014            .map_err(|e| e.to_string())?;
1015        // §7.4.1: seed the persisted local schema-version marker on first
1016        // create (a fresh install is already at its generated version).
1017        if self.get_meta(LOCAL_SCHEMA_VERSION_KEY).is_none() {
1018            self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
1019        }
1020        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
1021            self.set_meta(LOCAL_REVISION_KEY, "0");
1022        }
1023        // §5.9.7 blob cache + pending-upload queue (created only when the
1024        // schema declares blob_ref columns; harmless otherwise). IF NOT EXISTS
1025        // so a reopened on-disk DB reuses the persisted bodies across restarts
1026        // (the §5.9.7 B1 storage model: bytes live as BLOBs in the client DB).
1027        if self.schema_has_blobs() {
1028            self.conn
1029                .execute_batch(
1030                    "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
1031                       bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
1032                       media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
1033                       created_at_ms INTEGER NOT NULL,
1034                       last_used_ms INTEGER NOT NULL DEFAULT 0);
1035                     CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
1036                       media_type TEXT, created_at_ms INTEGER NOT NULL);",
1037                )
1038                .map_err(|e| e.to_string())?;
1039            // Migrate a cache created before the §5.9.7 B1 LRU column
1040            // (additive; the duplicate-column error on an already-migrated DB
1041            // is swallowed).
1042            let _ = self.conn.execute_batch(
1043                "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
1044            );
1045        }
1046        Ok(())
1047    }
1048
1049    /// True iff any synced table declares a `blob_ref` column (§5.9).
1050    fn schema_has_blobs(&self) -> bool {
1051        self.schema
1052            .tables
1053            .iter()
1054            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
1055    }
1056
1057    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
1058
1059    fn get_meta(&self, key: &str) -> Option<String> {
1060        self.conn
1061            .query_row(
1062                "SELECT value FROM _syncular_meta WHERE key = ?1",
1063                rusqlite::params![key],
1064                |row| row.get::<_, String>(0),
1065            )
1066            .ok()
1067    }
1068
1069    fn set_meta(&self, key: &str, value: &str) {
1070        let _ = self.conn.execute(
1071            "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
1072            rusqlite::params![key, value],
1073        );
1074    }
1075
1076    fn delete_meta(&self, key: &str) {
1077        let _ = self.conn.execute(
1078            "DELETE FROM _syncular_meta WHERE key = ?1",
1079            rusqlite::params![key],
1080        );
1081    }
1082
1083    fn restore_persisted_state(&mut self) -> Result<(), String> {
1084        self.subs = {
1085            let mut stmt = self
1086                .conn
1087                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
1088                .map_err(|error| error.to_string())?;
1089            let rows = stmt
1090                .query_map([], |row| {
1091                    Ok((
1092                        row.get::<_, String>(0)?,
1093                        row.get::<_, String>(1)?,
1094                        row.get::<_, String>(2)?,
1095                    ))
1096                })
1097                .map_err(|error| error.to_string())?;
1098            let mut subscriptions = Vec::new();
1099            for row in rows {
1100                let (id, table, raw) = row.map_err(|error| error.to_string())?;
1101                let state: Value = serde_json::from_str(&raw)
1102                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
1103                let requested = json_to_scope_map(
1104                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
1105                )?;
1106                let effective = state
1107                    .get("effectiveScopes")
1108                    .filter(|value| !value.is_null())
1109                    .map(json_to_scope_map)
1110                    .transpose()?;
1111                subscriptions.push(Subscription {
1112                    id,
1113                    table,
1114                    requested,
1115                    params: state
1116                        .get("params")
1117                        .and_then(Value::as_str)
1118                        .map(str::to_owned),
1119                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
1120                    bootstrap_state: state
1121                        .get("bootstrapState")
1122                        .and_then(Value::as_str)
1123                        .map(str::to_owned),
1124                    state: SubState::parse(
1125                        state
1126                            .get("status")
1127                            .and_then(Value::as_str)
1128                            .unwrap_or("active"),
1129                    ),
1130                    reason_code: state
1131                        .get("reasonCode")
1132                        .and_then(Value::as_str)
1133                        .map(str::to_owned),
1134                    effective,
1135                    synced_once: state
1136                        .get("syncedOnce")
1137                        .and_then(Value::as_bool)
1138                        .unwrap_or(false),
1139                });
1140            }
1141            subscriptions
1142        };
1143
1144        self.outbox = {
1145            let mut stmt = self
1146                .conn
1147                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
1148                .map_err(|error| error.to_string())?;
1149            let rows = stmt
1150                .query_map([], |row| {
1151                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1152                })
1153                .map_err(|error| error.to_string())?;
1154            let mut commits = Vec::new();
1155            for row in rows {
1156                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
1157                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
1158                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
1159                })?;
1160                let mut ops = Vec::with_capacity(entries.len());
1161                for entry in entries {
1162                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
1163                    ops.push(OutboxOp {
1164                        upsert: op == "upsert",
1165                        table: entry
1166                            .get("table")
1167                            .and_then(Value::as_str)
1168                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
1169                            .to_owned(),
1170                        row_id: entry
1171                            .get("rowId")
1172                            .and_then(Value::as_str)
1173                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
1174                            .to_owned(),
1175                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
1176                        values: entry.get("values").and_then(Value::as_object).cloned(),
1177                    });
1178                }
1179                commits.push(OutboxCommit {
1180                    client_commit_id,
1181                    ops,
1182                });
1183            }
1184            commits
1185        };
1186
1187        self.lease_state = self
1188            .get_meta(LEASE_STATE_KEY)
1189            .map(|raw| serde_json::from_str(&raw))
1190            .transpose()
1191            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
1192        self.schema_floor = self
1193            .get_meta(SCHEMA_FLOOR_KEY)
1194            .map(|raw| serde_json::from_str(&raw))
1195            .transpose()
1196            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
1197        self.stopped = self.schema_floor.is_some();
1198        Ok(())
1199    }
1200
1201    #[must_use]
1202    pub fn local_revision(&self) -> u64 {
1203        self.get_meta(LOCAL_REVISION_KEY)
1204            .and_then(|value| value.parse().ok())
1205            .unwrap_or(0)
1206    }
1207
1208    #[must_use]
1209    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
1210        SyncStatusSnapshot {
1211            outbox: self.outbox.len(),
1212            upgrading: self.upgrading,
1213            lease_state: self.lease_state.clone(),
1214            schema_floor: self.schema_floor.clone(),
1215            sync_needed: self.sync_needed,
1216        }
1217    }
1218
1219    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
1220        self.change_queue.drain(..).collect()
1221    }
1222
1223    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
1224        self.sync_intent_queue.drain(..).collect()
1225    }
1226
1227    fn schedule_background_retry(&mut self) {
1228        self.sync_intent_queue.push_back(SyncIntent::Background {
1229            delay_ms: self.retry_delay_ms,
1230        });
1231        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
1232    }
1233
1234    fn reset_background_retry(&mut self) {
1235        self.retry_delay_ms = 250;
1236    }
1237
1238    fn retryable_transport_code(code: &str) -> bool {
1239        code == "transport.failed" || code == "sync.transport_failed"
1240    }
1241
1242    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
1243        if self.sync_needed != value {
1244            if self.begin_observation("syncular_status").is_ok() {
1245                self.sync_needed = value;
1246                let batch = ChangeAccumulator {
1247                    status: true,
1248                    ..ChangeAccumulator::default()
1249                };
1250                if self.finish_observation("syncular_status", batch).is_err() {
1251                    self.rollback_observation("syncular_status");
1252                }
1253            } else {
1254                self.sync_needed = value;
1255            }
1256        }
1257        if value && interactive {
1258            self.sync_intent_queue.push_back(SyncIntent::Interactive);
1259        }
1260    }
1261
1262    fn begin_observation(&self, name: &str) -> Result<(), String> {
1263        self.conn
1264            .execute_batch(&format!("SAVEPOINT {name}"))
1265            .map_err(|error| error.to_string())
1266    }
1267
1268    fn rollback_observation(&self, name: &str) {
1269        let _ = self
1270            .conn
1271            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
1272    }
1273
1274    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
1275        if !batch.touched() {
1276            self.conn
1277                .execute_batch(&format!("RELEASE {name}"))
1278                .map_err(|error| error.to_string())?;
1279            return Ok(());
1280        }
1281        let revision = self
1282            .local_revision()
1283            .checked_add(1)
1284            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
1285        self.conn
1286            .execute(
1287                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
1288                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
1289            )
1290            .map_err(|error| error.to_string())?;
1291        let status = batch.status.then(|| self.status_snapshot());
1292        let event = ClientChangeBatch {
1293            revision: revision.to_string(),
1294            tables: batch
1295                .tables
1296                .into_iter()
1297                .map(|(table, scope_keys)| TableChange {
1298                    table,
1299                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
1300                })
1301                .collect(),
1302            windows: batch
1303                .windows
1304                .into_iter()
1305                .map(|((base_key, table), units)| WindowChange {
1306                    base_key,
1307                    table,
1308                    units: units.into_iter().collect(),
1309                })
1310                .collect(),
1311            status,
1312            conflicts_changed: batch.conflicts,
1313            rejections_changed: batch.rejections,
1314        };
1315        self.conn
1316            .execute_batch(&format!("RELEASE {name}"))
1317            .map_err(|error| error.to_string())?;
1318        self.change_queue.push_back(event);
1319        Ok(())
1320    }
1321
1322    fn record_scope_map(
1323        &self,
1324        batch: &mut ChangeAccumulator,
1325        table_name: &str,
1326        scopes: &[(String, Vec<String>)],
1327    ) {
1328        let Some(table) = self.schema.table(table_name) else {
1329            return;
1330        };
1331        for (variable, values) in scopes {
1332            let Some(scope) = table
1333                .scope_variables
1334                .iter()
1335                .find(|scope| &scope.variable == variable)
1336            else {
1337                continue;
1338            };
1339            for value in values {
1340                batch.scope(table_name, format!("{}:{value}", scope.prefix));
1341            }
1342        }
1343    }
1344
1345    /// Record a row's current scope keys from the base or visible table.
1346    fn record_row_scopes(
1347        &self,
1348        batch: &mut ChangeAccumulator,
1349        table_name: &str,
1350        row_id: &str,
1351        base: bool,
1352    ) -> bool {
1353        let Some(table) = self.schema.table(table_name) else {
1354            return false;
1355        };
1356        if table.scope_variables.is_empty() {
1357            return false;
1358        }
1359        let columns = table
1360            .scope_variables
1361            .iter()
1362            .map(|scope| quote_ident(&scope.column))
1363            .collect::<Vec<_>>()
1364            .join(", ");
1365        let full_table = if base {
1366            base_table(table_name)
1367        } else {
1368            visible_table(table_name)
1369        };
1370        let sql = format!(
1371            "SELECT {columns} FROM {full_table} WHERE CAST({} AS TEXT) = ?1 LIMIT 1",
1372            quote_ident(&table.primary_key)
1373        );
1374        let Ok(mut stmt) = self.conn.prepare(&sql) else {
1375            return false;
1376        };
1377        let values = stmt.query_row(rusqlite::params![row_id], |row| {
1378            let mut values = Vec::with_capacity(table.scope_variables.len());
1379            for index in 0..table.scope_variables.len() {
1380                values.push(row.get::<_, Option<String>>(index)?);
1381            }
1382            Ok(values)
1383        });
1384        let Ok(values) = values else {
1385            return false;
1386        };
1387        let mut recorded = false;
1388        for (scope, value) in table.scope_variables.iter().zip(values) {
1389            if let Some(value) = value {
1390                batch.scope(table_name, format!("{}:{value}", scope.prefix));
1391                recorded = true;
1392            }
1393        }
1394        recorded
1395    }
1396
1397    fn record_commit_changes(
1398        &self,
1399        batch: &mut ChangeAccumulator,
1400        tables: &[String],
1401        changes: &[ssp2::model::Change],
1402    ) {
1403        for change in changes {
1404            let Some(table_name) = tables.get(change.table_index as usize) else {
1405                continue;
1406            };
1407            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
1408            if let Some(table) = self.schema.table(table_name) {
1409                for (variable, value) in &change.scopes {
1410                    if let Some(scope) = table
1411                        .scope_variables
1412                        .iter()
1413                        .find(|scope| &scope.variable == variable)
1414                    {
1415                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
1416                        precise = true;
1417                    }
1418                }
1419            }
1420            if !precise {
1421                batch.table(table_name);
1422            }
1423        }
1424    }
1425
1426    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
1427        if effective.is_empty() {
1428            return false;
1429        }
1430        let Some(table) = self.schema.table(table_name) else {
1431            return false;
1432        };
1433        let mut clauses = Vec::new();
1434        let mut params = Vec::new();
1435        for (variable, values) in effective {
1436            let Some(column) = table.scope_column(variable) else {
1437                return false;
1438            };
1439            if values.is_empty() {
1440                return false;
1441            }
1442            let placeholders = values
1443                .iter()
1444                .map(|value| {
1445                    params.push(SqlValue::Text(value.clone()));
1446                    "?"
1447                })
1448                .collect::<Vec<_>>()
1449                .join(", ");
1450            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
1451        }
1452        let sql = format!(
1453            "SELECT 1 FROM {} WHERE {} LIMIT 1",
1454            base_table(table_name),
1455            clauses.join(" AND ")
1456        );
1457        self.conn
1458            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
1459            .is_ok()
1460    }
1461
1462    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
1463    pub fn upgrading(&self) -> bool {
1464        self.upgrading
1465    }
1466
1467    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
1468    /// keeping this client's local database (identity, outbox, tables). The
1469    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
1470    /// version changed. Mirrors the TS client's boot-time detection —
1471    /// the Rust core has no persistent restart, so recreation IS the boot.
1472    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
1473        let new_schema = parse_schema_json(schema_json)?;
1474        let marker: Option<i32> = self
1475            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
1476            .and_then(|v| v.parse().ok());
1477        self.schema = new_schema;
1478        if marker != Some(self.schema.version) {
1479            self.run_schema_reset()?;
1480        }
1481        // The conformance recreate is the in-memory equivalent of reopening a
1482        // durable client. Apply the same startup catch-up contract even when
1483        // the schema itself did not change.
1484        self.enqueue_startup_sync_if_needed();
1485        Ok(())
1486    }
1487
1488    fn enqueue_startup_sync_if_needed(&mut self) {
1489        let startup_work = !self.stopped
1490            && (!self.outbox.is_empty()
1491                || self.subs.iter().any(|sub| sub.state == SubState::Active));
1492        if startup_work {
1493            self.sync_needed = true;
1494            self.sync_intent_queue.push_back(SyncIntent::Interactive);
1495        }
1496    }
1497
1498    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
1499    /// and leaseState. Drops/recreates every synced table from the new
1500    /// schema, resets subscription sync-state (keeping registrations), clears
1501    /// the schema-floor stop state, rewrites the marker, drops outbox commits
1502    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
1503    fn run_schema_reset(&mut self) -> Result<(), String> {
1504        self.begin_observation("syncular_schema_reset")?;
1505        let mut batch = ChangeAccumulator::default();
1506        let result = self.run_schema_reset_observed(&mut batch);
1507        if let Err(error) = result {
1508            self.rollback_observation("syncular_schema_reset");
1509            return Err(error);
1510        }
1511        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
1512            self.rollback_observation("syncular_schema_reset");
1513            return Err(error);
1514        }
1515        Ok(())
1516    }
1517
1518    fn run_schema_reset_observed(&mut self, batch: &mut ChangeAccumulator) -> Result<(), String> {
1519        self.upgrading = true;
1520        batch.status = true;
1521        for table in &self.schema.tables {
1522            batch.table(&table.name);
1523        }
1524        for (base_key, unit, table) in self.load_registered_window_units() {
1525            batch.window(&base_key, &table, &unit);
1526        }
1527        // The per-table insert SQL is derived from the OLD column lists.
1528        self.insert_sql.borrow_mut().clear();
1529        self.overlay_dirty.set(true);
1530        // Drop every synced table (base + visible) that currently exists —
1531        // discovered from sqlite_master so a bump that adds/removes tables is
1532        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
1533        // and the blob cache) are preserved; base tables are `_syncular_base_*`
1534        // so they are matched explicitly, not by the bookkeeping filter.
1535        let existing: Vec<String> = {
1536            let mut stmt = self
1537                .conn
1538                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
1539                .map_err(|e| e.to_string())?;
1540            let rows = stmt
1541                .query_map([], |row| row.get::<_, String>(0))
1542                .map_err(|e| e.to_string())?;
1543            rows.filter_map(Result::ok)
1544                .filter(|name| is_synced_table_name(name))
1545                .collect()
1546        };
1547        for name in &existing {
1548            if let Some(table) = name.strip_prefix("_syncular_base_") {
1549                batch.table(table);
1550            } else if !name.starts_with("_syncular_") {
1551                batch.table(name);
1552            }
1553        }
1554        for name in existing {
1555            let _ = self
1556                .conn
1557                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), []);
1558        }
1559        // Recreate the synced tables from the NEW schema.
1560        self.create_synced_tables()?;
1561        // Reset every subscription's sync-state, keeping the registration.
1562        for sub in &mut self.subs {
1563            sub.cursor = -1;
1564            sub.bootstrap_state = None;
1565            sub.effective = None;
1566            sub.state = SubState::Active;
1567            sub.reason_code = None;
1568            sub.synced_once = false;
1569        }
1570        let subs = self.subs.clone();
1571        for sub in &subs {
1572            self.persist_sub(sub);
1573        }
1574        // The stop state is over: this client now ships a servable schema.
1575        self.stopped = false;
1576        self.schema_floor = None;
1577        self.delete_meta(SCHEMA_FLOOR_KEY);
1578        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
1579        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
1580        // §7.4.4: drop outbox commits that cannot re-encode under the new
1581        // schema (a referenced column/table the bump removed), surfacing each
1582        // as a `sync.outbox_incompatible` rejection.
1583        if self.drop_incompatible_outbox() {
1584            batch.rejections = true;
1585            batch.status = true;
1586        }
1587        // Re-apply the surviving outbox optimistically over the empty tables.
1588        self.rebuild_overlay();
1589        Ok(())
1590    }
1591
1592    /// §7.4.4: a persisted upsert whose values reference a column the current
1593    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
1594    /// and raise a client-local `sync.outbox_incompatible` rejection.
1595    fn drop_incompatible_outbox(&mut self) -> bool {
1596        let schema = &self.schema;
1597        let mut incompatible: Vec<String> = Vec::new();
1598        self.outbox.retain(|commit| {
1599            let bad = commit.ops.iter().any(|op| {
1600                if !op.upsert {
1601                    return false;
1602                }
1603                match schema.table(&op.table) {
1604                    None => true,
1605                    Some(table) => op.values.as_ref().is_some_and(|values| {
1606                        values
1607                            .keys()
1608                            .any(|key| !table.columns.iter().any(|c| &c.name == key))
1609                    }),
1610                }
1611            });
1612            if bad {
1613                incompatible.push(commit.client_commit_id.clone());
1614            }
1615            !bad
1616        });
1617        let changed = !incompatible.is_empty();
1618        for client_commit_id in incompatible {
1619            self.persist_outbox_delete(&client_commit_id);
1620            self.rejections.push(RejectionRecord {
1621                client_commit_id,
1622                op_index: 0,
1623                code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
1624                retryable: false,
1625            });
1626        }
1627        changed
1628    }
1629
1630    /// §7.4.3: (re)create the base + visible table pair for every synced
1631    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
1632    fn create_synced_tables(&self) -> Result<(), String> {
1633        for table in &self.schema.tables {
1634            // The base half + the visible half form the synced-table pair. An
1635            // index name is global in SQLite, so the base half's indexes are
1636            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
1637            for (full, index_prefix) in [
1638                (base_table(&table.name), "_syncular_base_"),
1639                (visible_table(&table.name), ""),
1640            ] {
1641                let mut cols: Vec<String> =
1642                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
1643                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
1644                let sql = format!(
1645                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
1646                    cols.join(", "),
1647                    quote_ident(&table.primary_key)
1648                );
1649                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
1650                // Local secondary indexes (CREATE INDEX subset). Created on
1651                // both halves so mirror reads hit an index on either. Runs on
1652                // both the initial create and the §7.4.3 reset recreate path.
1653                for index in &table.indexes {
1654                    let unique = if index.unique { "UNIQUE " } else { "" };
1655                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
1656                    let cols_sql = index
1657                        .columns
1658                        .iter()
1659                        .map(|c| quote_ident(c))
1660                        .collect::<Vec<_>>()
1661                        .join(", ");
1662                    let index_sql = format!(
1663                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
1664                    );
1665                    self.conn
1666                        .execute(&index_sql, [])
1667                        .map_err(|e| e.to_string())?;
1668                }
1669            }
1670        }
1671        Ok(())
1672    }
1673
1674    // -- persistence write-through --------------------------------------------
1675
1676    fn persist_sub(&self, sub: &Subscription) {
1677        let state = serde_json::json!({
1678            "requested": scope_map_to_json(&sub.requested),
1679            "params": sub.params,
1680            "cursor": sub.cursor,
1681            "bootstrapState": sub.bootstrap_state,
1682            "status": sub.state.name(),
1683            "reasonCode": sub.reason_code,
1684            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
1685            "syncedOnce": sub.synced_once,
1686        });
1687        let _ = self.conn.execute(
1688            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
1689            rusqlite::params![sub.id, sub.table, state.to_string()],
1690        );
1691    }
1692
1693    fn persist_outbox_insert(&self, commit: &OutboxCommit) {
1694        let ops: Vec<Value> = commit
1695            .ops
1696            .iter()
1697            .map(|op| {
1698                serde_json::json!({
1699                    "op": if op.upsert { "upsert" } else { "delete" },
1700                    "table": op.table,
1701                    "rowId": op.row_id,
1702                    "baseVersion": op.base_version,
1703                    "values": op.values.clone().map(Value::Object),
1704                })
1705            })
1706            .collect();
1707        let _ = self.conn.execute(
1708            "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
1709            rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
1710        );
1711    }
1712
1713    fn persist_outbox_delete(&self, client_commit_id: &str) {
1714        let _ = self.conn.execute(
1715            "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
1716            rusqlite::params![client_commit_id],
1717        );
1718    }
1719
1720    // -- driver surface ---------------------------------------------------------
1721
1722    pub fn subscribe(
1723        &mut self,
1724        id: String,
1725        table: String,
1726        scopes: Vec<(String, Vec<String>)>,
1727        params: Option<String>,
1728    ) -> Result<(), String> {
1729        if self.schema.table(&table).is_none() {
1730            return Err(format!("unknown table {table:?}"));
1731        }
1732        let sub = Subscription {
1733            id: id.clone(),
1734            table,
1735            requested: scopes,
1736            params,
1737            cursor: -1,
1738            bootstrap_state: None,
1739            state: SubState::Active,
1740            reason_code: None,
1741            effective: None,
1742            synced_once: false,
1743        };
1744        self.persist_sub(&sub);
1745        if let Some(existing) = self.subs.iter_mut().find(|s| s.id == id) {
1746            *existing = sub;
1747        } else {
1748            self.subs.push(sub);
1749        }
1750        Ok(())
1751    }
1752
1753    pub fn unsubscribe(&mut self, id: &str) {
1754        self.subs.retain(|s| s.id != id);
1755        let _ = self.conn.execute(
1756            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
1757            rusqlite::params![id],
1758        );
1759    }
1760
1761    // -- windowed subscriptions (§4.8) ------------------------------------------
1762
1763    /// §4.8: set the live window units for a base — a value-sharded family
1764    /// of subscriptions, one per unit. Added units get fresh subscriptions
1765    /// (image-lane bootstrap on the next sync); removed units are
1766    /// unsubscribed and evicted, fused in one local transaction (E1–E4).
1767    /// Idempotent; re-entry cancels any deferred eviction.
1768    pub fn set_window(
1769        &mut self,
1770        base: &WindowBase,
1771        units: &[String],
1772    ) -> Result<CommandEffects, String> {
1773        let table = self
1774            .schema
1775            .table(&base.table)
1776            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
1777        if table.scope_column(&base.variable).is_none() {
1778            return Err(format!(
1779                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
1780                base.table, base.variable
1781            ));
1782        }
1783        let base_key = window_base_key(base);
1784        let wanted: std::collections::HashSet<&String> = units.iter().collect();
1785        let live = self.load_window_units(&base_key);
1786        self.begin_observation("syncular_window")?;
1787        let mut batch = ChangeAccumulator::default();
1788        let mut changed = false;
1789
1790        // Widen: units wanted but not live → fresh subscription + registry row.
1791        for unit in units {
1792            if live.iter().any(|(u, _)| u == unit) {
1793                continue;
1794            }
1795            let sub_id = derive_sub_id(base, unit);
1796            self.delete_pending_evict(&sub_id);
1797            self.insert_window_unit(&base_key, unit, &sub_id);
1798            self.subscribe(
1799                sub_id,
1800                base.table.clone(),
1801                unit_scopes(base, unit),
1802                base.params.clone(),
1803            )?;
1804            batch.window(&base_key, &base.table, unit);
1805            changed = true;
1806        }
1807
1808        // Shrink: units live but not wanted → unsubscribe fused with eviction.
1809        for (unit, sub_id) in live {
1810            if wanted.contains(&unit) {
1811                continue;
1812            }
1813            let effective = self
1814                .subs
1815                .iter()
1816                .find(|sub| sub.id == sub_id)
1817                .and_then(|sub| sub.effective.clone())
1818                .unwrap_or_else(|| unit_scopes(base, &unit));
1819            self.record_scope_map(&mut batch, &base.table, &effective);
1820            batch.window(&base_key, &base.table, &unit);
1821            self.evict_unit(&base_key, base, &unit, &sub_id);
1822            changed = true;
1823        }
1824        if let Err(error) = self.finish_observation("syncular_window", batch) {
1825            self.rollback_observation("syncular_window");
1826            return Err(error);
1827        }
1828        Ok(if changed {
1829            CommandEffects::interactive()
1830        } else {
1831            CommandEffects::none()
1832        })
1833    }
1834
1835    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
1836    /// the subset still bootstrap-pending. Registration alone is not
1837    /// completeness — a unit is pending until its subscription completes a
1838    /// bootstrap round (cursor advances past -1 with no resume token held).
1839    pub fn window_state(&self, base: &WindowBase) -> WindowState {
1840        let mut units = Vec::new();
1841        let mut pending = Vec::new();
1842        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
1843            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
1844                Some(sub) => {
1845                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
1846                }
1847                None => true,
1848            };
1849            if is_pending {
1850                pending.push(unit.clone());
1851            }
1852            units.push(unit);
1853        }
1854        WindowState { units, pending }
1855    }
1856
1857    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
1858        let mut stmt = match self
1859            .conn
1860            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
1861        {
1862            Ok(stmt) => stmt,
1863            Err(_) => return Vec::new(),
1864        };
1865        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
1866            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1867        });
1868        match rows {
1869            Ok(rows) => rows.filter_map(Result::ok).collect(),
1870            Err(_) => Vec::new(),
1871        }
1872    }
1873
1874    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
1875        let mut stmt = match self.conn.prepare(
1876            "SELECT windows.base, windows.unit, subscriptions.tbl
1877               FROM _syncular_windows AS windows
1878               JOIN _syncular_subscriptions AS subscriptions
1879                 ON subscriptions.id = windows.sub_id
1880               ORDER BY windows.base, windows.unit",
1881        ) {
1882            Ok(stmt) => stmt,
1883            Err(_) => return Vec::new(),
1884        };
1885        let rows = stmt.query_map([], |row| {
1886            Ok((
1887                row.get::<_, String>(0)?,
1888                row.get::<_, String>(1)?,
1889                row.get::<_, String>(2)?,
1890            ))
1891        });
1892        match rows {
1893            Ok(rows) => rows.filter_map(Result::ok).collect(),
1894            Err(_) => Vec::new(),
1895        }
1896    }
1897
1898    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
1899        self.conn
1900            .query_row(
1901                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
1902                rusqlite::params![sub_id],
1903                |row| Ok((row.get(0)?, row.get(1)?)),
1904            )
1905            .ok()
1906    }
1907
1908    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
1909        let _ = self.conn.execute(
1910            "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
1911            rusqlite::params![base_key, unit, sub_id],
1912        );
1913    }
1914
1915    fn delete_window_unit(&self, base_key: &str, unit: &str) {
1916        let _ = self.conn.execute(
1917            "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
1918            rusqlite::params![base_key, unit],
1919        );
1920    }
1921
1922    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
1923    /// Deletes the unit's rows except those pinned by a pending outbox
1924    /// commit (E1); records a deferred eviction if any pin remains; discards
1925    /// the subscription's cursor/resume/effective-echo (E3) and version
1926    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
1927    /// nothing.
1928    fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
1929        let effective = self
1930            .subs
1931            .iter()
1932            .find(|s| s.id == sub_id)
1933            .and_then(|s| s.effective.clone())
1934            .unwrap_or_else(|| unit_scopes(base, unit));
1935        let pinned = self.pinned_row_ids(&base.table);
1936        let deferred = self
1937            .evict_scope_rows(&base.table, &effective, &pinned)
1938            .unwrap_or(false);
1939        self.delete_window_unit(base_key, unit);
1940        self.unsubscribe(sub_id);
1941        if deferred {
1942            self.save_pending_evict(sub_id, &base.table, &effective);
1943        } else {
1944            self.delete_pending_evict(sub_id);
1945        }
1946        self.rebuild_overlay();
1947    }
1948
1949    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
1950    /// primary keys; returns `Ok(true)` iff a pinned row was left behind (so
1951    /// the eviction must be deferred). `Err(())` = fail-closed (no mapping).
1952    fn evict_scope_rows(
1953        &mut self,
1954        table_name: &str,
1955        effective: &[(String, Vec<String>)],
1956        pinned: &std::collections::HashSet<String>,
1957    ) -> Result<bool, ()> {
1958        if effective.is_empty() {
1959            return Ok(false);
1960        }
1961        let table = self.schema.table(table_name).ok_or(())?.clone();
1962        let mut clauses = Vec::new();
1963        let mut params: Vec<SqlValue> = Vec::new();
1964        for (variable, values) in effective {
1965            let column = table.scope_column(variable).ok_or(())?;
1966            let placeholders: Vec<String> = values
1967                .iter()
1968                .map(|v| {
1969                    params.push(SqlValue::Text(v.clone()));
1970                    "?".to_owned()
1971                })
1972                .collect();
1973            clauses.push(format!(
1974                "{} IN ({})",
1975                quote_ident(column),
1976                placeholders.join(", ")
1977            ));
1978        }
1979        let mut sql = format!(
1980            "DELETE FROM {} WHERE {}",
1981            base_table(table_name),
1982            clauses.join(" AND ")
1983        );
1984        if !pinned.is_empty() {
1985            let pk = quote_ident(&table.primary_key);
1986            let holes: Vec<String> = pinned
1987                .iter()
1988                .map(|id| {
1989                    params.push(SqlValue::Text(id.clone()));
1990                    "?".to_owned()
1991                })
1992                .collect();
1993            sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
1994        }
1995        self.overlay_dirty.set(true);
1996        self.conn
1997            .execute(&sql, rusqlite::params_from_iter(params))
1998            .map_err(|_| ())?;
1999        if pinned.is_empty() {
2000            return Ok(false);
2001        }
2002        // A pin defers the eviction only if a pinned row actually falls
2003        // inside this unit's effective scopes — re-select the survivors.
2004        let mut where_params: Vec<SqlValue> = Vec::new();
2005        let mut where_clauses = Vec::new();
2006        for (variable, values) in effective {
2007            let column = table.scope_column(variable).ok_or(())?;
2008            let placeholders: Vec<String> = values
2009                .iter()
2010                .map(|v| {
2011                    where_params.push(SqlValue::Text(v.clone()));
2012                    "?".to_owned()
2013                })
2014                .collect();
2015            where_clauses.push(format!(
2016                "{} IN ({})",
2017                quote_ident(column),
2018                placeholders.join(", ")
2019            ));
2020        }
2021        let pk = quote_ident(&table.primary_key);
2022        let select = format!(
2023            "SELECT {} FROM {} WHERE {}",
2024            pk,
2025            base_table(table_name),
2026            where_clauses.join(" AND ")
2027        );
2028        let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
2029        let survivors: Vec<String> = stmt
2030            .query_map(rusqlite::params_from_iter(where_params), |row| {
2031                row.get::<_, String>(0)
2032            })
2033            .map_err(|_| ())?
2034            .filter_map(Result::ok)
2035            .collect();
2036        Ok(survivors.iter().any(|id| pinned.contains(id)))
2037    }
2038
2039    /// §4.8 E1: retry deferred evictions after the outbox drains. No
2040    /// pending records (the common case) means nothing to retry — and no
2041    /// overlay rebuild.
2042    fn drain_pending_evictions(&mut self) {
2043        let pending = self.load_pending_evictions();
2044        if pending.is_empty() {
2045            return;
2046        }
2047        for (sub_id, table_name, effective) in pending {
2048            if self.schema.table(&table_name).is_none() {
2049                self.delete_pending_evict(&sub_id);
2050                continue;
2051            }
2052            let pinned = self.pinned_row_ids(&table_name);
2053            let deferred = self
2054                .evict_scope_rows(&table_name, &effective, &pinned)
2055                .unwrap_or(false);
2056            if !deferred {
2057                self.delete_pending_evict(&sub_id);
2058            }
2059        }
2060        self.rebuild_overlay_if_dirty();
2061    }
2062
2063    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
2064    /// commit — rows that MUST NOT be evicted until the commit drains.
2065    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
2066        let mut pinned = std::collections::HashSet::new();
2067        for commit in &self.outbox {
2068            for op in &commit.ops {
2069                if op.table == table {
2070                    pinned.insert(op.row_id.clone());
2071                }
2072            }
2073        }
2074        pinned
2075    }
2076
2077    fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
2078        let _ = self.conn.execute(
2079            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
2080               VALUES (?1, ?2, ?3)",
2081            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
2082        );
2083    }
2084
2085    fn delete_pending_evict(&self, sub_id: &str) {
2086        let _ = self.conn.execute(
2087            "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
2088            rusqlite::params![sub_id],
2089        );
2090    }
2091
2092    fn load_pending_evictions(&self) -> Vec<PendingEvict> {
2093        let mut stmt = match self
2094            .conn
2095            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
2096        {
2097            Ok(stmt) => stmt,
2098            Err(_) => return Vec::new(),
2099        };
2100        let rows = stmt.query_map([], |row| {
2101            Ok((
2102                row.get::<_, String>(0)?,
2103                row.get::<_, String>(1)?,
2104                row.get::<_, String>(2)?,
2105            ))
2106        });
2107        let mut out = Vec::new();
2108        if let Ok(rows) = rows {
2109            for entry in rows.filter_map(Result::ok) {
2110                let (sub_id, table, json) = entry;
2111                if let Ok(value) = serde_json::from_str::<Value>(&json) {
2112                    if let Ok(effective) = json_to_scope_map(&value) {
2113                        out.push((sub_id, table, effective));
2114                    }
2115                }
2116            }
2117        }
2118        out
2119    }
2120
2121    /// Record one atomic local commit (§7.1) and apply it optimistically.
2122    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
2123        if mutations.is_empty() {
2124            return Err("a commit must contain at least one operation (§6.1)".to_owned());
2125        }
2126        let mut ops = Vec::with_capacity(mutations.len());
2127        for mutation in mutations {
2128            match mutation {
2129                Mutation::Upsert {
2130                    table,
2131                    values,
2132                    base_version,
2133                } => {
2134                    let schema_table = self
2135                        .schema
2136                        .table(&table)
2137                        .ok_or_else(|| format!("unknown table {table:?}"))?;
2138                    // §5: value keys are accepted in snake_case AND the
2139                    // generated row types' camelCase; normalize to SQL truth
2140                    // before the pk lookup / codec see them.
2141                    let values = normalize_values_casing(schema_table, values)?;
2142                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
2143                    // Validate the payload encodes with the current codec
2144                    // (and, §5.11, that the encrypt seam has its keys).
2145                    encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
2146                    ops.push(OutboxOp {
2147                        upsert: true,
2148                        table,
2149                        row_id,
2150                        base_version,
2151                        values: Some(values),
2152                    });
2153                }
2154                Mutation::Delete {
2155                    table,
2156                    row_id,
2157                    base_version,
2158                } => {
2159                    if self.schema.table(&table).is_none() {
2160                        return Err(format!("unknown table {table:?}"));
2161                    }
2162                    ops.push(OutboxOp {
2163                        upsert: false,
2164                        table,
2165                        row_id,
2166                        base_version,
2167                        values: None,
2168                    });
2169                }
2170            }
2171        }
2172        let commit = OutboxCommit {
2173            client_commit_id: uuid::Uuid::new_v4().to_string(),
2174            ops,
2175        };
2176        self.begin_observation("syncular_mutation")?;
2177        let mut batch = ChangeAccumulator::default();
2178        for op in &commit.ops {
2179            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
2180            if let Some(values) = &op.values {
2181                if let Some(table) = self.schema.table(&op.table) {
2182                    for scope in &table.scope_variables {
2183                        if let Some(Value::String(value)) = values.get(&scope.column) {
2184                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
2185                            precise = true;
2186                        }
2187                    }
2188                }
2189            }
2190            if !precise {
2191                batch.table(&op.table);
2192            }
2193        }
2194        self.persist_outbox_insert(&commit);
2195        let id = commit.client_commit_id.clone();
2196        self.outbox.push(commit);
2197        self.overlay_dirty.set(true);
2198        self.rebuild_overlay();
2199        batch.status = true;
2200        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
2201            self.rollback_observation("syncular_mutation");
2202            return Err(error);
2203        }
2204        Ok(id)
2205    }
2206
2207    /// Merge a partial update over the current visible local row, then record
2208    /// the ordinary full-row upsert. This keeps patch semantics identical
2209    /// across the TypeScript and native cores without weakening the wire's
2210    /// full-row invariant.
2211    pub fn patch(
2212        &mut self,
2213        table: &str,
2214        row_id: &str,
2215        partial: Map<String, Value>,
2216        base_version: Option<i64>,
2217    ) -> Result<String, String> {
2218        let schema_table = self
2219            .schema
2220            .table(table)
2221            .ok_or_else(|| format!("unknown table {table:?}"))?;
2222        let partial = normalize_values_casing(schema_table, partial)?;
2223        let mut values = self
2224            .read_rows(table)?
2225            .into_iter()
2226            .find(|row| row.row_id == row_id)
2227            .map(|row| row.values)
2228            .ok_or_else(|| {
2229                format!(
2230                    "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
2231                )
2232            })?;
2233        values.extend(partial);
2234        self.mutate(vec![Mutation::Upsert {
2235            table: table.to_owned(),
2236            values,
2237            base_version,
2238        }])
2239    }
2240
2241    pub fn pending_commit_ids(&self) -> Vec<String> {
2242        self.outbox
2243            .iter()
2244            .map(|c| c.client_commit_id.clone())
2245            .collect()
2246    }
2247
2248    pub fn conflicts(&self) -> &[ConflictRecord] {
2249        &self.conflicts
2250    }
2251
2252    pub fn rejections(&self) -> &[RejectionRecord] {
2253        &self.rejections
2254    }
2255
2256    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
2257        self.schema_floor.as_ref()
2258    }
2259
2260    /// §7.3.5: the client's opaque auth-lease state, if any.
2261    pub fn lease_state(&self) -> Option<&LeaseState> {
2262        self.lease_state.as_ref()
2263    }
2264
2265    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
2266    /// the two lease codes set it; other errors leave leaseState untouched.
2267    fn record_lease_error(&mut self, code: &str) {
2268        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
2269            return;
2270        }
2271        let mut next = self.lease_state.clone().unwrap_or_default();
2272        next.error_code = Some(code.to_owned());
2273        self.set_lease_state(Some(next));
2274    }
2275
2276    fn set_lease_state(&mut self, next: Option<LeaseState>) {
2277        if self.lease_state == next {
2278            return;
2279        }
2280        if self.begin_observation("syncular_lease").is_err() {
2281            return;
2282        }
2283        self.lease_state = next;
2284        if let Some(lease) = &self.lease_state {
2285            if let Ok(json) = serde_json::to_string(lease) {
2286                self.set_meta(LEASE_STATE_KEY, &json);
2287            }
2288        } else {
2289            self.delete_meta(LEASE_STATE_KEY);
2290        }
2291        let batch = ChangeAccumulator {
2292            status: true,
2293            ..ChangeAccumulator::default()
2294        };
2295        if self.finish_observation("syncular_lease", batch).is_err() {
2296            self.rollback_observation("syncular_lease");
2297        }
2298    }
2299
2300    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
2301        if self.schema_floor == next {
2302            return;
2303        }
2304        if self.begin_observation("syncular_schema_floor").is_err() {
2305            return;
2306        }
2307        self.schema_floor = next;
2308        self.stopped = self.schema_floor.is_some();
2309        if let Some(floor) = &self.schema_floor {
2310            if let Ok(json) = serde_json::to_string(floor) {
2311                self.set_meta(SCHEMA_FLOOR_KEY, &json);
2312            }
2313        } else {
2314            self.delete_meta(SCHEMA_FLOOR_KEY);
2315        }
2316        let batch = ChangeAccumulator {
2317            status: true,
2318            ..ChangeAccumulator::default()
2319        };
2320        if self
2321            .finish_observation("syncular_schema_floor", batch)
2322            .is_err()
2323        {
2324            self.rollback_observation("syncular_schema_floor");
2325        }
2326    }
2327
2328    fn set_upgrading(&mut self, value: bool) {
2329        if self.upgrading == value {
2330            return;
2331        }
2332        if self.begin_observation("syncular_upgrading").is_err() {
2333            return;
2334        }
2335        self.upgrading = value;
2336        let batch = ChangeAccumulator {
2337            status: true,
2338            ..ChangeAccumulator::default()
2339        };
2340        if self
2341            .finish_observation("syncular_upgrading", batch)
2342            .is_err()
2343        {
2344            self.rollback_observation("syncular_upgrading");
2345        }
2346    }
2347
2348    pub fn sync_needed(&self) -> bool {
2349        self.sync_needed
2350    }
2351
2352    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
2353        let sub = self.subs.iter().find(|s| s.id == id)?;
2354        Some(SubscriptionStateView {
2355            id: sub.id.clone(),
2356            table: sub.table.clone(),
2357            status: sub.state.name().to_owned(),
2358            cursor: sub.cursor,
2359            has_resume_token: sub.bootstrap_state.is_some(),
2360            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
2361            reason_code: sub.reason_code.clone(),
2362        })
2363    }
2364
2365    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
2366        let schema_table = self
2367            .schema
2368            .table(table)
2369            .ok_or_else(|| format!("unknown table {table:?}"))?;
2370        let sql = format!(
2371            "SELECT * FROM {} ORDER BY {} ASC",
2372            visible_table(table),
2373            quote_ident(&schema_table.primary_key)
2374        );
2375        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
2376        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
2377        let mut out = Vec::new();
2378        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
2379            let mut values = Map::new();
2380            for (i, column) in schema_table.columns.iter().enumerate() {
2381                let value = row.get_ref(i).map_err(|e| e.to_string())?;
2382                values.insert(column.name.clone(), sql_ref_to_json(column, value));
2383            }
2384            let version: i64 = row
2385                .get(schema_table.columns.len())
2386                .map_err(|e| e.to_string())?;
2387            let row_id = match values.get(&schema_table.primary_key) {
2388                Some(Value::String(s)) => s.clone(),
2389                Some(Value::Number(n)) => n.to_string(),
2390                Some(Value::Bool(b)) => b.to_string(),
2391                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
2392            };
2393            out.push(RowState {
2394                row_id,
2395                version,
2396                values,
2397            });
2398        }
2399        Ok(out)
2400    }
2401
2402    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
2403    //
2404    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
2405    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
2406    // the op with `yrs`, re-encodes the whole doc state, and pushes it as a
2407    // baseVersion-less upsert through the ordinary `mutate` path (§5.10.3
2408    // "crdt-only divergence merges cleanly"). No local merge — merging is
2409    // server-side; the overlay's last-write-wins re-materializes the edit
2410    // immediately (optimistic apply, §7.1) and the server-merged bytes arrive
2411    // on the next pull, idempotently. Byte-compatible with `@syncular/crdt-yjs`.
2412
2413    /// The current stored value of a `crdt` column for one row — the visible
2414    /// (optimistic) bytes, or `None` when the row is absent or the column is
2415    /// NULL (the empty document, §5.10.1). Errors if the column is not a
2416    /// `crdt` column (guards the app against a typo'd column name).
2417    #[cfg(feature = "crdt-yjs")]
2418    fn crdt_column_bytes(
2419        &self,
2420        table: &str,
2421        row_id: &str,
2422        column: &str,
2423    ) -> Result<Option<Vec<u8>>, String> {
2424        let schema_table = self
2425            .schema
2426            .table(table)
2427            .ok_or_else(|| format!("unknown table {table:?}"))?;
2428        let col = schema_table
2429            .columns
2430            .iter()
2431            .find(|c| c.name == column)
2432            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
2433        if col.ty != ColumnType::Crdt {
2434            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
2435        }
2436        let sql = format!(
2437            "SELECT {} FROM {} WHERE CAST({} AS TEXT) = ?1",
2438            quote_ident(column),
2439            visible_table(table),
2440            quote_ident(&schema_table.primary_key)
2441        );
2442        let bytes: Option<Vec<u8>> = self
2443            .conn
2444            .query_row(&sql, rusqlite::params![row_id], |row| {
2445                row.get::<_, Option<Vec<u8>>>(0)
2446            })
2447            .map_err(|e| match e {
2448                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
2449                other => other.to_string(),
2450            })?;
2451        Ok(bytes)
2452    }
2453
2454    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
2455    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
2456    /// An absent row / NULL column is the empty document (empty string).
2457    #[cfg(feature = "crdt-yjs")]
2458    pub fn crdt_text(
2459        &self,
2460        table: &str,
2461        row_id: &str,
2462        column: &str,
2463        name: &str,
2464    ) -> Result<String, String> {
2465        let bytes = self
2466            .crdt_column_bytes(table, row_id, column)?
2467            .unwrap_or_default();
2468        crate::crdt::text(&bytes, name)
2469    }
2470
2471    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and push
2472    /// the resulting full-state update through the normal (baseVersion-less)
2473    /// mutate path. Returns the enqueued `clientCommitId`.
2474    #[cfg(feature = "crdt-yjs")]
2475    pub fn crdt_insert_text(
2476        &mut self,
2477        table: &str,
2478        row_id: &str,
2479        column: &str,
2480        name: &str,
2481        index: u32,
2482        value: &str,
2483    ) -> Result<String, String> {
2484        let current = self
2485            .crdt_column_bytes(table, row_id, column)?
2486            .unwrap_or_default();
2487        let update = crate::crdt::insert_text(&current, name, index, value)?;
2488        self.crdt_push_update(table, row_id, column, &update)
2489    }
2490
2491    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and push
2492    /// the resulting full-state update. Returns the enqueued `clientCommitId`.
2493    #[cfg(feature = "crdt-yjs")]
2494    pub fn crdt_delete_text(
2495        &mut self,
2496        table: &str,
2497        row_id: &str,
2498        column: &str,
2499        name: &str,
2500        index: u32,
2501        len: u32,
2502    ) -> Result<String, String> {
2503        let current = self
2504            .crdt_column_bytes(table, row_id, column)?
2505            .unwrap_or_default();
2506        let update = crate::crdt::delete_text(&current, name, index, len)?;
2507        self.crdt_push_update(table, row_id, column, &update)
2508    }
2509
2510    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
2511    /// `crdt` column's current state and push the resulting full state. The
2512    /// app authored the update with its own `yrs` model. Returns the enqueued
2513    /// `clientCommitId`.
2514    #[cfg(feature = "crdt-yjs")]
2515    pub fn crdt_apply_update(
2516        &mut self,
2517        table: &str,
2518        row_id: &str,
2519        column: &str,
2520        update: &[u8],
2521    ) -> Result<String, String> {
2522        let current = self
2523            .crdt_column_bytes(table, row_id, column)?
2524            .unwrap_or_default();
2525        let next = crate::crdt::apply_update(&current, update)?;
2526        self.crdt_push_update(table, row_id, column, &next)
2527    }
2528
2529    /// Shared tail of the crdt edit methods: build the full-row upsert that
2530    /// carries the new crdt bytes and enqueue it. The row's other columns are
2531    /// preserved from the current visible row (so a crdt edit does not clobber
2532    /// the LWW columns); a brand-new row is seeded with just the primary key +
2533    /// crdt column. Pushed baseVersion-less (§5.10.3 crdt-only-divergence rule).
2534    #[cfg(feature = "crdt-yjs")]
2535    fn crdt_push_update(
2536        &mut self,
2537        table: &str,
2538        row_id: &str,
2539        column: &str,
2540        crdt_bytes: &[u8],
2541    ) -> Result<String, String> {
2542        let schema_table = self
2543            .schema
2544            .table(table)
2545            .ok_or_else(|| format!("unknown table {table:?}"))?
2546            .clone();
2547        // The current visible row's values (preserving LWW columns), or a
2548        // fresh row keyed by row_id if it does not exist yet.
2549        let mut values: Map<String, Value> = self
2550            .read_rows(table)?
2551            .into_iter()
2552            .find(|r| r.row_id == row_id)
2553            .map(|r| r.values)
2554            .unwrap_or_else(|| {
2555                let mut map = Map::new();
2556                map.insert(
2557                    schema_table.primary_key.clone(),
2558                    Value::from(row_id.to_owned()),
2559                );
2560                map
2561            });
2562        // Replace the crdt column with the new bytes in the driver envelope.
2563        let mut bytes_obj = Map::new();
2564        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
2565        values.insert(column.to_owned(), Value::Object(bytes_obj));
2566        self.mutate(vec![Mutation::Upsert {
2567            table: table.to_owned(),
2568            values,
2569            base_version: None,
2570        }])
2571    }
2572
2573    /// Run an arbitrary read-only SQL query against the local database and
2574    /// return each row as a `column-name → JSON value` map. This is the seam
2575    /// the React `useSyncQuery` live-query API needs (it takes app-authored
2576    /// SQL over the visible tables/views, not a fixed table read like
2577    /// [`read_rows`]).
2578    ///
2579    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
2580    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
2581    /// same envelope the command surface uses everywhere else. Output BLOB
2582    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
2583    ///
2584    /// The result column typing is dynamic (SQLite's stored affinity), because
2585    /// arbitrary SQL can alias, join, and compute — there is no schema column
2586    /// to consult per output cell, unlike [`read_rows`].
2587    pub fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Map<String, Value>>, String> {
2588        query_connection(&self.conn, sql, params)
2589    }
2590
2591    /// Rows, coverage, and local revision from one SQLite read snapshot.
2592    pub fn query_snapshot(
2593        &mut self,
2594        sql: &str,
2595        params: &[Value],
2596        coverage: &[WindowCoverage],
2597    ) -> Result<QuerySnapshot, String> {
2598        snapshot_connection(&self.conn, sql, params, coverage)
2599    }
2600
2601    // -- request building ---------------------------------------------------------
2602
2603    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
2604        let mut frames = vec![Frame::ReqHeader {
2605            client_id: self.client_id.clone(),
2606            schema_version: self.schema.version,
2607        }];
2608        let mut pushed_ids = Vec::new();
2609        let mut ops_in_request = 0usize;
2610        let mut deferred_commits = 0usize;
2611        for (index, commit) in self.outbox.iter().enumerate() {
2612            // §6.1 splitBatch: stop at the operation cap — commits apply in
2613            // order, so everything from the first non-fitting commit on is
2614            // deferred to the next round. A single over-cap commit still goes
2615            // alone (commits are atomic and cannot be split).
2616            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
2617                deferred_commits = self.outbox.len() - index;
2618                break;
2619            }
2620            ops_in_request += commit.ops.len();
2621            let operations = commit
2622                .ops
2623                .iter()
2624                .map(|op| {
2625                    let payload = op.values.as_ref().and_then(|values| {
2626                        let table = self.schema.table(&op.table)?;
2627                        // §0: outbox entries encode at send time with the
2628                        // current codec (validated at mutate()). §5.11:
2629                        // encrypted columns are encrypted here.
2630                        encode_row_json(table, &op.row_id, values, &self.encryption).ok()
2631                    });
2632                    ssp2::model::Operation {
2633                        table: op.table.clone(),
2634                        row_id: op.row_id.clone(),
2635                        op: if op.upsert { Op::Upsert } else { Op::Delete },
2636                        base_version: op.base_version,
2637                        payload,
2638                    }
2639                })
2640                .collect();
2641            frames.push(Frame::PushCommit {
2642                client_commit_id: commit.client_commit_id.clone(),
2643                operations,
2644            });
2645            pushed_ids.push(commit.client_commit_id.clone());
2646        }
2647        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
2648        // bare URL — capability negotiation, decided per transport.
2649        let accept = self.limits.accept.unwrap_or(if url_capable {
2650            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
2651        } else {
2652            DEFAULT_ACCEPT
2653        });
2654        frames.push(Frame::PullHeader {
2655            limit_commits: self.limits.limit_commits.unwrap_or(0),
2656            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
2657            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
2658            accept,
2659        });
2660        let mut fresh = Vec::new();
2661        for sub in &self.subs {
2662            if sub.state != SubState::Active {
2663                continue;
2664            }
2665            let mut scopes = sub.requested.clone();
2666            sort_scope_map(&mut scopes);
2667            frames.push(Frame::Subscription {
2668                id: sub.id.clone(),
2669                table: sub.table.clone(),
2670                scopes,
2671                params: sub.params.clone().map(RawJson),
2672                cursor: sub.cursor,
2673                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
2674            });
2675            fresh.push((
2676                sub.id.clone(),
2677                sub.cursor < 0 && sub.bootstrap_state.is_none(),
2678            ));
2679        }
2680        let message = Message {
2681            msg_kind: MsgKind::Request,
2682            frames,
2683        };
2684        (
2685            message,
2686            RequestMeta {
2687                pushed_ids,
2688                fresh,
2689                accept,
2690                deferred_commits,
2691            },
2692        )
2693    }
2694
2695    // -- sync -------------------------------------------------------------------
2696
2697    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
2698        if self.stopped {
2699            // §1.6: the client stopped at the schema floor; syncing is inert
2700            // until an upgrade. The outbox is preserved for replay.
2701            return SyncOutcome::Ok(SyncReport {
2702                schema_floor: self.schema_floor.clone(),
2703                ..SyncReport::default()
2704            });
2705        }
2706        // §8.4: the coalesced sync-needed signal clears when a pull round
2707        // BEGINS, so a wake-up landing mid-round survives it.
2708        self.set_sync_needed(false, false);
2709        // §5.9.7 B4: upload pending blobs before pushing the referencing
2710        // rows, so the server-side existence check (§6.6) passes.
2711        if self.schema_has_blobs() {
2712            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
2713                if Self::retryable_transport_code(&code) {
2714                    self.schedule_background_retry();
2715                }
2716                return SyncOutcome::Failed {
2717                    error_code: code,
2718                    message,
2719                };
2720            }
2721        }
2722        let (message, meta) = self.build_request(transport.supports_url_fetch());
2723        let request_bytes = encode_message(&message);
2724        // §8.7: rounds ride the socket whenever it is connected (one
2725        // loop, no fallback pair); the transport seam stays bytes-in /
2726        // bytes-out either way. Registration-at-round-end is server-side.
2727        let round = if self.realtime_connected {
2728            transport.realtime_sync(&request_bytes)
2729        } else {
2730            transport.sync(&request_bytes)
2731        };
2732        let response_bytes = match round {
2733            Ok(bytes) => bytes,
2734            Err(TransportError { code, message }) => {
2735                // §7.3.5: a request-level lease code stops-and-surfaces —
2736                // record it in leaseState (no local-data purge, §7.3.4).
2737                self.record_lease_error(&code);
2738                if Self::retryable_transport_code(&code) {
2739                    self.schedule_background_retry();
2740                }
2741                return SyncOutcome::Failed {
2742                    error_code: code,
2743                    message,
2744                };
2745            }
2746        };
2747        let response = match decode_message(&response_bytes) {
2748            Ok(message) => message,
2749            Err(error) => {
2750                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
2751                // responses abort without persisting anything.
2752                return SyncOutcome::Failed {
2753                    error_code: error.code.as_str().to_owned(),
2754                    message: error.detail,
2755                };
2756            }
2757        };
2758        if response.msg_kind != MsgKind::Response {
2759            return SyncOutcome::Failed {
2760                error_code: "sync.invalid_request".to_owned(),
2761                message: "expected a response message".to_owned(),
2762            };
2763        }
2764        let outcome = self.process_response(transport, response, &meta);
2765        match &outcome {
2766            SyncOutcome::Ok(_) => self.reset_background_retry(),
2767            SyncOutcome::Failed { error_code, .. }
2768                if Self::retryable_transport_code(error_code) =>
2769            {
2770                self.schedule_background_retry();
2771            }
2772            SyncOutcome::Failed { .. } => {}
2773        }
2774        if meta.deferred_commits > 0 {
2775            // §6.1 splitBatch: commits past the operation cap wait for the
2776            // next round — keep the host's sync signal raised until then.
2777            self.set_sync_needed(true, true);
2778        }
2779        outcome
2780    }
2781
2782    pub fn sync_until_idle(
2783        &mut self,
2784        transport: &mut dyn Transport,
2785        max_rounds: Option<u32>,
2786    ) -> SyncOutcome {
2787        let rounds = max_rounds.unwrap_or(12).max(1);
2788        let mut aggregate = SyncReport::default();
2789        for _ in 0..rounds {
2790            match self.sync(transport) {
2791                SyncOutcome::Failed {
2792                    error_code,
2793                    message,
2794                } => {
2795                    return SyncOutcome::Failed {
2796                        error_code,
2797                        message,
2798                    };
2799                }
2800                SyncOutcome::Ok(report) => {
2801                    aggregate.pushed += report.pushed;
2802                    aggregate.applied.extend(report.applied.iter().cloned());
2803                    aggregate.rejected.extend(report.rejected.iter().cloned());
2804                    aggregate.retryable.extend(report.retryable.iter().cloned());
2805                    aggregate.conflicts += report.conflicts;
2806                    aggregate.commits_applied += report.commits_applied;
2807                    aggregate.segment_rows_applied += report.segment_rows_applied;
2808                    aggregate.bootstrapping = report.bootstrapping.clone();
2809                    aggregate.resets.extend(report.resets.iter().cloned());
2810                    aggregate.revoked.extend(report.revoked.iter().cloned());
2811                    aggregate.failed.extend(report.failed.iter().cloned());
2812                    if report.schema_floor.is_some() {
2813                        aggregate.schema_floor = report.schema_floor.clone();
2814                    }
2815                    // §4.5: pull again whenever the response contained
2816                    // commits or segments; resets re-bootstrap; a pending
2817                    // resume token continues paging (§4.7); a raised
2818                    // sync-needed signal covers §6.1 splitBatch remainders
2819                    // (deferred outbox commits push on the next round).
2820                    let more = !report.bootstrapping.is_empty()
2821                        || report.commits_applied > 0
2822                        || report.segment_rows_applied > 0
2823                        || !report.resets.is_empty()
2824                        || self.sync_needed;
2825                    if !more {
2826                        break;
2827                    }
2828                }
2829            }
2830        }
2831        SyncOutcome::Ok(aggregate)
2832    }
2833
2834    fn process_response(
2835        &mut self,
2836        transport: &mut dyn Transport,
2837        response: Message,
2838        meta: &RequestMeta,
2839    ) -> SyncOutcome {
2840        let mut report = SyncReport {
2841            pushed: meta.pushed_ids.len() as u32,
2842            ..SyncReport::default()
2843        };
2844        let mut frames = response.frames.into_iter();
2845        match frames.next() {
2846            Some(Frame::RespHeader {
2847                required_schema_version,
2848                latest_schema_version,
2849            }) => {
2850                if let Some(required) = required_schema_version {
2851                    // §1.6 schema-floor response: nothing else is processed;
2852                    // stop syncing and surface the upgrade requirement.
2853                    let floor = SchemaFloor {
2854                        required_schema_version: Some(required),
2855                        latest_schema_version,
2856                    };
2857                    self.set_schema_floor(Some(floor.clone()));
2858                    report.schema_floor = Some(floor);
2859                    return SyncOutcome::Ok(report);
2860                }
2861            }
2862            _ => {
2863                return SyncOutcome::Failed {
2864                    error_code: "sync.invalid_request".to_owned(),
2865                    message: "response does not start with RESP_HEADER".to_owned(),
2866                };
2867            }
2868        }
2869
2870        let mut failure: Option<(String, String)> = None;
2871        while let Some(frame) = frames.next() {
2872            match frame {
2873                Frame::PushResult {
2874                    client_commit_id,
2875                    status,
2876                    commit_seq: _,
2877                    results,
2878                } => {
2879                    self.handle_push_result(&client_commit_id, status, &results, &mut report);
2880                }
2881                Frame::SubStart {
2882                    id,
2883                    status,
2884                    reason_code,
2885                    effective_scopes,
2886                    bootstrap: _,
2887                } => {
2888                    let mut body = Vec::new();
2889                    let mut sub_end: Option<(i64, Option<String>)> = None;
2890                    for inner in frames.by_ref() {
2891                        match inner {
2892                            Frame::SubEnd {
2893                                next_cursor,
2894                                bootstrap_state,
2895                            } => {
2896                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
2897                                break;
2898                            }
2899                            Frame::Unknown { .. } => {}
2900                            other => body.push(other),
2901                        }
2902                    }
2903                    let Some((next_cursor, bootstrap_state)) = sub_end else {
2904                        failure = Some((
2905                            "sync.invalid_request".to_owned(),
2906                            "subscription section without SUB_END".to_owned(),
2907                        ));
2908                        break;
2909                    };
2910                    if let Err(SectionError::Abort(code, message)) = self.process_section(
2911                        transport,
2912                        &id,
2913                        status,
2914                        &reason_code,
2915                        effective_scopes,
2916                        body,
2917                        next_cursor,
2918                        bootstrap_state,
2919                        meta,
2920                        &mut report,
2921                    ) {
2922                        failure = Some((code, message));
2923                        break;
2924                    }
2925                }
2926                Frame::Lease {
2927                    lease_id,
2928                    expires_at_ms,
2929                } => {
2930                    // §7.3.5: persist the opaque lease; a fresh lease clears
2931                    // any prior lease error (the outage/revocation is over).
2932                    self.set_lease_state(Some(LeaseState {
2933                        lease_id: Some(lease_id),
2934                        expires_at_ms: Some(expires_at_ms),
2935                        error_code: None,
2936                    }));
2937                }
2938                Frame::Error { code, message, .. } => {
2939                    // §1.6: the whole request failed; already-completed
2940                    // subscriptions keep their applied data and cursors.
2941                    failure = Some((code, message));
2942                    break;
2943                }
2944                Frame::Unknown { .. } => {}
2945                _ => {
2946                    failure = Some((
2947                        "sync.invalid_request".to_owned(),
2948                        "unexpected frame in response".to_owned(),
2949                    ));
2950                    break;
2951                }
2952            }
2953        }
2954
2955        // §7.1: reconciliation is outbox replay on top — whenever server
2956        // data has been applied, including a round that aborted mid-way.
2957        if self.overlay_dirty.get() {
2958            self.rebuild_overlay();
2959        }
2960        // §5.9.7 B1: refcounts follow the final visible rows at every response
2961        // boundary. Push-result handling can rebuild the overlay (and clear
2962        // `overlay_dirty`) before this point, so gating reconciliation on that
2963        // flag can leave a newly referenced body at refcount zero and make it
2964        // eligible for LRU eviction. The TypeScript core has the same
2965        // unconditional response-boundary reconciliation.
2966        self.reconcile_blob_refcounts(false);
2967
2968        if let Some((error_code, message)) = failure {
2969            return SyncOutcome::Failed {
2970                error_code,
2971                message,
2972            };
2973        }
2974        // §4.8 E1: the push half may have drained commits that pinned rows of
2975        // a shrunk window unit — retry any deferred evictions now.
2976        self.drain_pending_evictions();
2977        self.ack_after_pull(transport);
2978        // §7.4.5: the reset is over once the first post-reset pull round
2979        // leaves no subscription mid-bootstrap — the tables are rebuilt.
2980        if self.upgrading && report.bootstrapping.is_empty() {
2981            self.set_upgrading(false);
2982        }
2983        SyncOutcome::Ok(report)
2984    }
2985
2986    // -- push results (§6.3, §7.2) ------------------------------------------------
2987
2988    fn handle_push_result(
2989        &mut self,
2990        client_commit_id: &str,
2991        status: PushStatus,
2992        results: &[OpResult],
2993        report: &mut SyncReport,
2994    ) {
2995        let Some(index) = self
2996            .outbox
2997            .iter()
2998            .position(|c| c.client_commit_id == client_commit_id)
2999        else {
3000            return;
3001        };
3002        if self.begin_observation("syncular_push_result").is_err() {
3003            return;
3004        }
3005        let mut batch = ChangeAccumulator::default();
3006        let operations = self.outbox[index].ops.clone();
3007        // Every non-retryable outcome below removes the commit from the
3008        // outbox, so the optimistic overlay must be rebuilt.
3009        self.overlay_dirty.set(true);
3010        match status {
3011            PushStatus::Applied | PushStatus::Cached => {
3012                // §7.2: a lost ack replays as `cached` — proceed as if the
3013                // ack had arrived.
3014                report.applied.push(client_commit_id.to_owned());
3015                self.outbox.remove(index);
3016                self.persist_outbox_delete(client_commit_id);
3017                batch.status = true;
3018            }
3019            PushStatus::Rejected => {
3020                let terminating = results
3021                    .iter()
3022                    .find(|r| !matches!(r, OpResult::Applied { .. }));
3023                match terminating {
3024                    Some(OpResult::Conflict {
3025                        op_index,
3026                        code,
3027                        message: _,
3028                        server_version,
3029                        server_row,
3030                    }) => {
3031                        let commit = &self.outbox[index];
3032                        let (table, row_id) = commit
3033                            .ops
3034                            .get(*op_index as usize)
3035                            .map(|op| (op.table.clone(), op.row_id.clone()))
3036                            .unwrap_or_default();
3037                        let server_row_json = self
3038                            .schema
3039                            .table(&table)
3040                            .and_then(|t| {
3041                                decode_row_bytes(t, server_row, &self.encryption)
3042                                    .ok()
3043                                    .map(|row| (t, row))
3044                            })
3045                            .map(|(t, row)| {
3046                                let mut map = Map::new();
3047                                for (i, column) in t.columns.iter().enumerate() {
3048                                    map.insert(
3049                                        column.name.clone(),
3050                                        column_value_to_json(row.get(i).unwrap_or(&None)),
3051                                    );
3052                                }
3053                                map
3054                            })
3055                            .unwrap_or_default();
3056                        self.conflicts.push(ConflictRecord {
3057                            client_commit_id: client_commit_id.to_owned(),
3058                            op_index: *op_index,
3059                            table,
3060                            row_id,
3061                            code: code.clone(),
3062                            server_version: *server_version,
3063                            server_row: server_row_json,
3064                        });
3065                        batch.conflicts = true;
3066                        report.conflicts += 1;
3067                        report.rejected.push(client_commit_id.to_owned());
3068                        self.outbox.remove(index);
3069                        self.persist_outbox_delete(client_commit_id);
3070                        batch.status = true;
3071                    }
3072                    Some(OpResult::Error {
3073                        op_index,
3074                        code,
3075                        message: _,
3076                        retryable,
3077                    }) => {
3078                        if code == "sync.idempotency_cache_miss" {
3079                            // §6.3/§7.2: a serving failure, not an outcome —
3080                            // the commit stays queued for an identical retry.
3081                            report.retryable.push(client_commit_id.to_owned());
3082                        } else {
3083                            self.rejections.push(RejectionRecord {
3084                                client_commit_id: client_commit_id.to_owned(),
3085                                op_index: *op_index,
3086                                code: code.clone(),
3087                                retryable: *retryable,
3088                            });
3089                            batch.rejections = true;
3090                            report.rejected.push(client_commit_id.to_owned());
3091                            self.outbox.remove(index);
3092                            self.persist_outbox_delete(client_commit_id);
3093                            batch.status = true;
3094                        }
3095                    }
3096                    _ => {
3097                        report.rejected.push(client_commit_id.to_owned());
3098                        self.outbox.remove(index);
3099                        self.persist_outbox_delete(client_commit_id);
3100                        batch.status = true;
3101                    }
3102                }
3103            }
3104        }
3105        if batch.status {
3106            for operation in &operations {
3107                if !self.record_row_scopes(&mut batch, &operation.table, &operation.row_id, false) {
3108                    batch.table(&operation.table);
3109                }
3110            }
3111            self.rebuild_overlay_if_dirty();
3112        }
3113        if self
3114            .finish_observation("syncular_push_result", batch)
3115            .is_err()
3116        {
3117            self.rollback_observation("syncular_push_result");
3118        }
3119    }
3120
3121    // -- subscription sections ------------------------------------------------------
3122
3123    #[allow(clippy::too_many_arguments)]
3124    fn process_section(
3125        &mut self,
3126        transport: &mut dyn Transport,
3127        id: &str,
3128        status: SubStatus,
3129        reason_code: &str,
3130        effective_scopes: Vec<(String, Vec<String>)>,
3131        body: Vec<Frame>,
3132        next_cursor: i64,
3133        bootstrap_state: Option<String>,
3134        meta: &RequestMeta,
3135        report: &mut SyncReport,
3136    ) -> Result<(), SectionError> {
3137        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
3138            return Ok(()); // unknown echo: ignore
3139        };
3140        match status {
3141            SubStatus::Revoked => {
3142                self.begin_observation("syncular_revocation")
3143                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3144                let mut batch = ChangeAccumulator::default();
3145                let registered = self.window_unit_by_sub_id(id);
3146                // §3.3: stop pulling, purge exactly the last effective grant.
3147                let (table, effective) = {
3148                    let sub = &self.subs[sub_index];
3149                    (sub.table.clone(), sub.effective.clone().unwrap_or_default())
3150                };
3151                let purged = self.purge_scope_rows(&table, &effective);
3152                match purged {
3153                    Ok(()) => {
3154                        self.record_scope_map(&mut batch, &table, &effective);
3155                        let sub = &mut self.subs[sub_index];
3156                        sub.state = SubState::Revoked;
3157                        sub.reason_code = Some(if reason_code.is_empty() {
3158                            "sync.scope_revoked".to_owned()
3159                        } else {
3160                            reason_code.to_owned()
3161                        });
3162                        report.revoked.push(id.to_owned());
3163                        let doomed_effective = effective;
3164                        let sub_table = table;
3165                        self.persist_sub(&self.subs[sub_index].clone());
3166                        let outbox_before = self.outbox.len();
3167                        self.drop_doomed_outbox(&sub_table, &doomed_effective);
3168                        if self.outbox.len() != outbox_before {
3169                            batch.status = true;
3170                        }
3171                        // §5.9.7 B2: revocation deletes now-unauthorized blob
3172                        // bodies (evicted ≠ revoked).
3173                        self.reconcile_blob_refcounts(true);
3174                    }
3175                    Err(()) => {
3176                        // §3.3 fail closed: no local mapping — never clear by
3177                        // approximation; fatal configuration error.
3178                        let sub = &mut self.subs[sub_index];
3179                        sub.state = SubState::Failed;
3180                        sub.reason_code = Some("sync.scope_revoked".to_owned());
3181                        report.failed.push(id.to_owned());
3182                        self.persist_sub(&self.subs[sub_index].clone());
3183                    }
3184                }
3185                if let Some((base_key, unit)) = registered {
3186                    batch.window(&base_key, &self.subs[sub_index].table, &unit);
3187                }
3188                self.rebuild_overlay_if_dirty();
3189                self.finish_observation("syncular_revocation", batch)
3190                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3191                Ok(())
3192            }
3193            SubStatus::Reset => {
3194                self.begin_observation("syncular_reset")
3195                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3196                let mut batch = ChangeAccumulator::default();
3197                let registered = self.window_unit_by_sub_id(id);
3198                // §4.6: discard cursor + bootstrap state, keep local rows —
3199                // reset is a staleness signal, not a purge signal.
3200                let sub = &mut self.subs[sub_index];
3201                sub.cursor = -1;
3202                sub.bootstrap_state = None;
3203                report.resets.push(id.to_owned());
3204                self.persist_sub(&self.subs[sub_index].clone());
3205                if let Some((base_key, unit)) = registered {
3206                    batch.window(&base_key, &self.subs[sub_index].table, &unit);
3207                }
3208                self.finish_observation("syncular_reset", batch)
3209                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3210                Ok(())
3211            }
3212            SubStatus::Active => {
3213                let fresh = meta
3214                    .fresh
3215                    .iter()
3216                    .find(|(fid, _)| fid == id)
3217                    .map(|(_, f)| *f)
3218                    .unwrap_or(false);
3219                let was_pending = self.subs[sub_index].cursor < 0
3220                    || self.subs[sub_index].bootstrap_state.is_some();
3221                let registered = self.window_unit_by_sub_id(id);
3222                // §3.3: each active echo replaces the persisted copy.
3223                self.subs[sub_index].effective = Some(effective_scopes);
3224                self.begin_observation("syncular_section")
3225                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3226                let mut batch = ChangeAccumulator::default();
3227                let outcome = self.apply_section_body(
3228                    transport, sub_index, body, fresh, meta, report, &mut batch,
3229                );
3230                match outcome {
3231                    Ok(()) => {
3232                        let sub = &mut self.subs[sub_index];
3233                        // §1.4: durable cursor/resume state persists only at
3234                        // SUB_END.
3235                        sub.cursor = next_cursor;
3236                        sub.bootstrap_state = bootstrap_state;
3237                        sub.synced_once = true;
3238                        if sub.bootstrap_state.is_some() {
3239                            report.bootstrapping.push(id.to_owned());
3240                        }
3241                        let completed =
3242                            was_pending && sub.cursor >= 0 && sub.bootstrap_state.is_none();
3243                        self.persist_sub(&self.subs[sub_index].clone());
3244                        if completed {
3245                            if let Some((base_key, unit)) = registered.clone() {
3246                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
3247                            }
3248                        }
3249                        self.rebuild_overlay_if_dirty();
3250                        self.finish_observation("syncular_section", batch)
3251                            .map_err(|message| {
3252                                SectionError::Abort("storage.failed".to_owned(), message)
3253                            })?;
3254                        Ok(())
3255                    }
3256                    Err(SectionError::FailClosed) => {
3257                        // §5.6: subscription-local; the rest of the response
3258                        // still applies. SUB_END values are NOT persisted.
3259                        self.rollback_observation("syncular_section");
3260                        self.begin_observation("syncular_section_failure")
3261                            .map_err(|message| {
3262                                SectionError::Abort("storage.failed".to_owned(), message)
3263                            })?;
3264                        let mut failure_batch = ChangeAccumulator::default();
3265                        let sub = &mut self.subs[sub_index];
3266                        sub.state = SubState::Failed;
3267                        sub.reason_code = Some("sync.scope_revoked".to_owned());
3268                        report.failed.push(id.to_owned());
3269                        self.persist_sub(&self.subs[sub_index].clone());
3270                        if let Some((base_key, unit)) = registered {
3271                            failure_batch.window(&base_key, &self.subs[sub_index].table, &unit);
3272                        }
3273                        self.finish_observation("syncular_section_failure", failure_batch)
3274                            .map_err(|message| {
3275                                SectionError::Abort("storage.failed".to_owned(), message)
3276                            })?;
3277                        Ok(())
3278                    }
3279                    Err(SectionError::Abort(code, message)) => {
3280                        // §1.4 rule 5: roll back the open subscription; do
3281                        // not persist its SUB_END values.
3282                        self.rollback_observation("syncular_section");
3283                        Err(SectionError::Abort(code, message))
3284                    }
3285                }
3286            }
3287        }
3288    }
3289
3290    // The section context and its transaction-owned change accumulator are
3291    // deliberately explicit here: folding either into shared mutable state
3292    // would weaken the atomic observation boundary.
3293    #[allow(clippy::too_many_arguments)]
3294    fn apply_section_body(
3295        &mut self,
3296        transport: &mut dyn Transport,
3297        sub_index: usize,
3298        body: Vec<Frame>,
3299        fresh: bool,
3300        meta: &RequestMeta,
3301        report: &mut SyncReport,
3302        batch: &mut ChangeAccumulator,
3303    ) -> Result<(), SectionError> {
3304        let mut saw_segment = false;
3305        for frame in body {
3306            match frame {
3307                Frame::Commit {
3308                    tables, changes, ..
3309                } => {
3310                    self.record_commit_changes(batch, &tables, &changes);
3311                    self.apply_commit_changes(&tables, &changes)
3312                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
3313                    report.commits_applied += 1;
3314                }
3315                Frame::SegmentInline { payload } => {
3316                    let segment = decode_rows_segment(&payload)
3317                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
3318                    let first = !saw_segment;
3319                    saw_segment = true;
3320                    let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
3321                    let cleared =
3322                        fresh && first && self.scoped_rows_exist(&segment.table, &effective);
3323                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
3324                    if applied > 0 || cleared {
3325                        batch.table(&segment.table);
3326                    }
3327                    report.segment_rows_applied += applied;
3328                }
3329                Frame::SegmentRef {
3330                    segment_id,
3331                    media_type,
3332                    table,
3333                    row_count,
3334                    as_of_commit_seq,
3335                    scope_digest,
3336                    row_cursor,
3337                    next_row_cursor,
3338                    url,
3339                    url_expires_at_ms,
3340                    ..
3341                } => {
3342                    // §4.2: reject a descriptor whose mediaType was not
3343                    // advertised — never skip or guess.
3344                    let advertised = match media_type {
3345                        MediaType::Rows => {
3346                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
3347                                || meta.accept & ACCEPT_INLINE_ROWS != 0
3348                        }
3349                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
3350                    };
3351                    if !advertised {
3352                        return Err(SectionError::Abort(
3353                            "sync.invalid_request".to_owned(),
3354                            format!(
3355                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
3356                                media_type.name()
3357                            ),
3358                        ));
3359                    }
3360                    let bytes = if let Some(url) = url {
3361                        // §5.4: a url-carrying descriptor MUST be fetched
3362                        // from that URL; failure invalidates the whole
3363                        // descriptor (no fall-through to §5.5 — re-pull
3364                        // recovers, §1.4 rule 5).
3365                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
3366                            return Err(SectionError::Abort(
3367                                "sync.invalid_request".to_owned(),
3368                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
3369                                    .to_owned(),
3370                            ));
3371                        }
3372                        // §5.4: MUST NOT start a fetch at/past expiry.
3373                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
3374                            return Err(SectionError::Abort(
3375                                "sync.segment_expired".to_owned(),
3376                                format!(
3377                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
3378                                ),
3379                            ));
3380                        }
3381                        transport
3382                            .fetch_url(&url)
3383                            .map_err(|e| SectionError::Abort(e.code, e.message))?
3384                    } else {
3385                        let requested_scopes_json =
3386                            canonical_scope_json(&self.subs[sub_index].requested);
3387                        transport
3388                            .download_segment(&SegmentRequest {
3389                                segment_id: segment_id.clone(),
3390                                table,
3391                                requested_scopes_json,
3392                            })
3393                            .map_err(|e| SectionError::Abort(e.code, e.message))?
3394                    };
3395                    // §5.1: verify the content address before applying.
3396                    let digest = Sha256::digest(&bytes);
3397                    let expected = segment_id
3398                        .strip_prefix("sha256:")
3399                        .unwrap_or(segment_id.as_str());
3400                    if bytes_to_hex(&digest) != expected {
3401                        return Err(SectionError::Abort(
3402                            "sync.invalid_request".to_owned(),
3403                            "segment bytes do not match the content address (§5.1)".to_owned(),
3404                        ));
3405                    }
3406                    if media_type == MediaType::Sqlite {
3407                        // §5.3: images are whole-table — a paged sqlite
3408                        // descriptor is invalid.
3409                        if row_cursor.is_some() || next_row_cursor.is_some() {
3410                            return Err(SectionError::Abort(
3411                                "sync.invalid_request".to_owned(),
3412                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
3413                                    .to_owned(),
3414                            ));
3415                        }
3416                        let first = !saw_segment;
3417                        saw_segment = true;
3418                        let sub_table = self.subs[sub_index].table.clone();
3419                        let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
3420                        let cleared =
3421                            fresh && first && self.scoped_rows_exist(&sub_table, &effective);
3422                        let applied = self.apply_sqlite_segment(
3423                            sub_index,
3424                            &bytes,
3425                            fresh && first,
3426                            row_count,
3427                            as_of_commit_seq,
3428                            &scope_digest,
3429                        )?;
3430                        if applied > 0 || cleared {
3431                            batch.table(&sub_table);
3432                        }
3433                        report.segment_rows_applied += applied;
3434                    } else {
3435                        let segment = decode_rows_segment(&bytes).map_err(|e| {
3436                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
3437                        })?;
3438                        let first = row_cursor.is_none();
3439                        saw_segment = true;
3440                        let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
3441                        let cleared =
3442                            fresh && first && self.scoped_rows_exist(&segment.table, &effective);
3443                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
3444                        if applied > 0 || cleared {
3445                            batch.table(&segment.table);
3446                        }
3447                        report.segment_rows_applied += applied;
3448                    }
3449                }
3450                Frame::Unknown { .. } => {}
3451                _ => {
3452                    return Err(SectionError::Abort(
3453                        "sync.invalid_request".to_owned(),
3454                        "unexpected frame inside a subscription section".to_owned(),
3455                    ));
3456                }
3457            }
3458        }
3459        Ok(())
3460    }
3461
3462    fn apply_commit_changes(
3463        &mut self,
3464        tables: &[String],
3465        changes: &[ssp2::model::Change],
3466    ) -> Result<(), (String, String)> {
3467        for change in changes {
3468            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
3469                (
3470                    "sync.invalid_request".to_owned(),
3471                    "change tableIndex out of range".to_owned(),
3472                )
3473            })?;
3474            let table = self.schema.table(table_name).ok_or_else(|| {
3475                (
3476                    "sync.schema_mismatch".to_owned(),
3477                    format!("change targets unknown table {table_name:?}"),
3478                )
3479            })?;
3480            match change.op {
3481                Op::Upsert => {
3482                    let payload = change.row.as_ref().ok_or_else(|| {
3483                        (
3484                            "sync.invalid_request".to_owned(),
3485                            "upsert change without row payload".to_owned(),
3486                        )
3487                    })?;
3488                    // §5.11: decrypt encrypted columns on apply.
3489                    let row = decode_row_bytes(table, payload, &self.encryption)
3490                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
3491                    let version = change.row_version.unwrap_or(0);
3492                    let table_name = table.name.clone();
3493                    self.write_base_row(&table_name, &row, version)
3494                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
3495                }
3496                Op::Delete => {
3497                    self.delete_base_row(table_name, &change.row_id)
3498                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
3499                }
3500            }
3501        }
3502        Ok(())
3503    }
3504
3505    /// §5.6 segment application: validate against the generated schema,
3506    /// clear the grant on a fresh bootstrap's first page (fail closed
3507    /// without a mapping), then replace-or-upsert each row with its
3508    /// segment-carried server version (§5.2).
3509    fn apply_segment(
3510        &mut self,
3511        sub_index: usize,
3512        segment: &RowsSegment,
3513        first_fresh_page: bool,
3514    ) -> Result<u32, SectionError> {
3515        let (sub_table, effective) = {
3516            let sub = &self.subs[sub_index];
3517            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
3518        };
3519        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
3520            SectionError::Abort(
3521                "sync.schema_mismatch".to_owned(),
3522                format!("subscription table {sub_table:?} is not in the client schema"),
3523            )
3524        })?;
3525        // §5.2: the column table validates against the generated schema —
3526        // order, names, types, nullability; mismatch is fatal. §5.11: the
3527        // server sends the WIRE types (bytes for an encrypted column), so
3528        // validate against wire_columns.
3529        let matches = segment.table == table.name
3530            && segment.schema_version == self.schema.version
3531            && segment.columns.len() == table.wire_columns.len()
3532            && segment
3533                .columns
3534                .iter()
3535                .zip(table.wire_columns.iter())
3536                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
3537        if !matches {
3538            return Err(SectionError::Abort(
3539                "sync.schema_mismatch".to_owned(),
3540                "segment column table does not match the generated schema (§5.2)".to_owned(),
3541            ));
3542        }
3543        if first_fresh_page {
3544            // §5.6: delete local rows for the subscription's scope so
3545            // removed rows don't survive re-bootstrap; fail closed at the
3546            // clear too.
3547            self.purge_scope_rows(&table.name, &effective)
3548                .map_err(|()| SectionError::FailClosed)?;
3549        }
3550        let mut applied = 0u32;
3551        for block in &segment.blocks {
3552            for row in block {
3553                // §5.11: a bootstrap segment carries ciphertext for encrypted
3554                // columns; decrypt to plaintext before the local write. A
3555                // plaintext table writes the decoded row directly (no per-row
3556                // clone on the hot bootstrap path).
3557                let decrypted;
3558                let values = if table.has_encrypted_columns() {
3559                    let mut values = row.values.clone();
3560                    crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
3561                        .map_err(|m| SectionError::Abort("client.decrypt_failed".to_owned(), m))?;
3562                    decrypted = values;
3563                    &decrypted
3564                } else {
3565                    &row.values
3566                };
3567                // §5.6: the row record's serverVersion is the row's
3568                // last-known server_version, same as a COMMIT rowVersion.
3569                self.write_base_row(&table.name, values, row.server_version)
3570                    .map_err(|m| SectionError::Abort("sync.invalid_request".to_owned(), m))?;
3571                applied += 1;
3572            }
3573        }
3574        Ok(applied)
3575    }
3576
3577    /// §5.3 sqlite-image application: validate the in-file metadata
3578    /// against the descriptor, validate column names/order against the
3579    /// generated schema, run the §5.6 first-page clear when fresh, then
3580    /// replace-or-upsert every image row with its `_syncular_version`.
3581    /// Mechanics: the image lands in a temp file read through a second
3582    /// rusqlite connection (semantics identical to ATTACH + INSERT…SELECT;
3583    /// ATTACH is unavailable inside the open section savepoint).
3584    fn apply_sqlite_segment(
3585        &mut self,
3586        sub_index: usize,
3587        bytes: &[u8],
3588        first_fresh_page: bool,
3589        row_count: i64,
3590        as_of_commit_seq: i64,
3591        scope_digest: &str,
3592    ) -> Result<u32, SectionError> {
3593        let invalid = |detail: &str| {
3594            SectionError::Abort(
3595                "sync.invalid_request".to_owned(),
3596                format!("sqlite segment rejected: {detail} (§5.3)"),
3597            )
3598        };
3599        let (sub_table, effective) = {
3600            let sub = &self.subs[sub_index];
3601            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
3602        };
3603        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
3604            SectionError::Abort(
3605                "sync.schema_mismatch".to_owned(),
3606                format!("subscription table {sub_table:?} is not in the client schema"),
3607            )
3608        })?;
3609
3610        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
3611        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
3612        let img = match rusqlite::Connection::open_with_flags(
3613            &path,
3614            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3615        ) {
3616            Ok(conn) => conn,
3617            Err(_) => {
3618                let _ = std::fs::remove_file(&path);
3619                return Err(invalid("bytes do not open as a SQLite database"));
3620            }
3621        };
3622        let outcome = self.apply_sqlite_image(
3623            &img,
3624            &table,
3625            first_fresh_page,
3626            &effective,
3627            row_count,
3628            as_of_commit_seq,
3629            scope_digest,
3630        );
3631        drop(img);
3632        let _ = std::fs::remove_file(&path);
3633        outcome
3634    }
3635
3636    #[allow(clippy::too_many_arguments)]
3637    fn apply_sqlite_image(
3638        &mut self,
3639        img: &rusqlite::Connection,
3640        table: &crate::schema::TableSchema,
3641        first_fresh_page: bool,
3642        effective: &[(String, Vec<String>)],
3643        row_count: i64,
3644        as_of_commit_seq: i64,
3645        scope_digest: &str,
3646    ) -> Result<u32, SectionError> {
3647        let invalid = |detail: String| {
3648            SectionError::Abort(
3649                "sync.invalid_request".to_owned(),
3650                format!("sqlite segment rejected: {detail} (§5.3)"),
3651            )
3652        };
3653
3654        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
3655        //    one row).
3656        type MetaRow = (i64, String, i64, i64, String, i64, i64);
3657        let meta: MetaRow = img
3658            .query_row(
3659                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
3660                        \"scopeDigest\", \"rowCount\",
3661                        (SELECT count(*) FROM _syncular_segment)
3662                 FROM _syncular_segment",
3663                [],
3664                |row| {
3665                    Ok((
3666                        row.get(0)?,
3667                        row.get(1)?,
3668                        row.get(2)?,
3669                        row.get(3)?,
3670                        row.get(4)?,
3671                        row.get(5)?,
3672                        row.get(6)?,
3673                    ))
3674                },
3675            )
3676            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
3677        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
3678        if meta_count != 1 {
3679            return Err(invalid(format!(
3680                "_syncular_segment must contain exactly one row, found {meta_count}"
3681            )));
3682        }
3683        if format != 1 {
3684            return Err(invalid(format!("format {format}")));
3685        }
3686        if meta_table != table.name {
3687            return Err(invalid(format!("image table {meta_table:?}")));
3688        }
3689        if schema_version != i64::from(self.schema.version) {
3690            return Err(invalid(format!("schemaVersion {schema_version}")));
3691        }
3692        if pin != as_of_commit_seq {
3693            return Err(invalid(format!("asOfCommitSeq {pin}")));
3694        }
3695        if digest != scope_digest {
3696            return Err(invalid("scopeDigest mismatch".to_owned()));
3697        }
3698        if meta_rows != row_count {
3699            return Err(invalid(format!("rowCount {meta_rows}")));
3700        }
3701
3702        // 2. Column names and order vs the generated schema (§5.3 rule 3).
3703        let mut names: Vec<String> = Vec::new();
3704        {
3705            let mut stmt = img
3706                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
3707                .map_err(|_| invalid("image data table missing".to_owned()))?;
3708            let mut rows = stmt
3709                .query([])
3710                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
3711            while let Some(row) = rows
3712                .next()
3713                .map_err(|_| invalid("image data table unreadable".to_owned()))?
3714            {
3715                names.push(
3716                    row.get::<_, String>(1)
3717                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
3718                );
3719            }
3720        }
3721        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
3722        expected.push("_syncular_version");
3723        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
3724            return Err(SectionError::Abort(
3725                "sync.schema_mismatch".to_owned(),
3726                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
3727            ));
3728        }
3729
3730        // 3. §5.6 first-page clear (fail closed without a mapping), then
3731        //    replace-or-upsert with the image-carried server versions.
3732        if first_fresh_page {
3733            self.purge_scope_rows(&table.name, effective)
3734                .map_err(|()| SectionError::FailClosed)?;
3735        }
3736        // One cached INSERT statement on our side, one SELECT cursor on the
3737        // image side; every cell is validated against the declared column
3738        // type and bound BORROWED (no per-cell allocation, no per-row
3739        // statement re-preparation) — the Rust analogue of the TS client's
3740        // single `INSERT OR REPLACE … SELECT` bulk copy.
3741        self.overlay_dirty.set(true);
3742        // A fresh whole-table load pays secondary-index maintenance per row;
3743        // dropping the base half's NON-unique indexes for the load and
3744        // recreating them after replaces that with one bulk sort per index.
3745        // Unique indexes stay in place — `INSERT OR REPLACE` clobbers
3746        // through them, so they are semantics, not just speed. The DDL rides
3747        // the open section savepoint (§1.4): an abort rolls the drop back.
3748        let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
3749            table.indexes.iter().filter(|i| !i.unique).collect()
3750        } else {
3751            Vec::new()
3752        };
3753        for index in &bulk_indexes {
3754            let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
3755            self.conn
3756                .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
3757                .map_err(|e| invalid(e.to_string()))?;
3758        }
3759        let insert = self.insert_row_sql(&base_table(&table.name), table);
3760        let applied = {
3761            let mut ins = self
3762                .conn
3763                .prepare_cached(&insert)
3764                .map_err(|e| invalid(e.to_string()))?;
3765            let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
3766            let mut stmt = img
3767                .prepare(&format!(
3768                    "SELECT {} FROM {}",
3769                    column_list.join(", "),
3770                    quote_ident(&table.name)
3771                ))
3772                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
3773            let mut rows = stmt
3774                .query([])
3775                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
3776            let version_index = table.columns.len();
3777            let mut applied = 0u32;
3778            while let Some(row) = rows
3779                .next()
3780                .map_err(|_| invalid("image row unreadable".to_owned()))?
3781            {
3782                for (i, column) in table.columns.iter().enumerate() {
3783                    let cell = row
3784                        .get_ref(i)
3785                        .map_err(|_| invalid("image row unreadable".to_owned()))?;
3786                    let param = image_cell_param(column, cell).map_err(&invalid)?;
3787                    ins.raw_bind_parameter(i + 1, param)
3788                        .map_err(|e| invalid(e.to_string()))?;
3789                }
3790                let version: i64 = row
3791                    .get(version_index)
3792                    .map_err(|_| invalid("image row unreadable".to_owned()))?;
3793                if version < 1 {
3794                    return Err(invalid(format!(
3795                        "row _syncular_version must be >= 1, got {version}"
3796                    )));
3797                }
3798                ins.raw_bind_parameter(version_index + 1, version)
3799                    .map_err(|e| invalid(e.to_string()))?;
3800                ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
3801                applied += 1;
3802            }
3803            applied
3804        };
3805        for index in &bulk_indexes {
3806            let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
3807            let cols_sql = index
3808                .columns
3809                .iter()
3810                .map(|c| quote_ident(c))
3811                .collect::<Vec<_>>()
3812                .join(", ");
3813            self.conn
3814                .execute(
3815                    &format!(
3816                        "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
3817                        base_table(&table.name)
3818                    ),
3819                    [],
3820                )
3821                .map_err(|e| invalid(e.to_string()))?;
3822        }
3823        if i64::from(applied) != row_count {
3824            return Err(invalid(format!(
3825                "image holds {applied} rows, descriptor says {row_count}"
3826            )));
3827        }
3828        Ok(applied)
3829    }
3830
3831    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
3832
3833    /// Delete base rows matching the effective scopes; `Err(())` = no local
3834    /// scope-column mapping for a key (the fail-closed case).
3835    fn purge_scope_rows(
3836        &mut self,
3837        table_name: &str,
3838        effective: &[(String, Vec<String>)],
3839    ) -> Result<(), ()> {
3840        if effective.is_empty() {
3841            return Ok(());
3842        }
3843        let table = self.schema.table(table_name).ok_or(())?.clone();
3844        let mut clauses = Vec::new();
3845        let mut params: Vec<SqlValue> = Vec::new();
3846        for (variable, values) in effective {
3847            let column = table.scope_column(variable).ok_or(())?;
3848            let placeholders: Vec<String> = values
3849                .iter()
3850                .map(|v| {
3851                    params.push(SqlValue::Text(v.clone()));
3852                    "?".to_owned()
3853                })
3854                .collect();
3855            clauses.push(format!(
3856                "{} IN ({})",
3857                quote_ident(column),
3858                placeholders.join(", ")
3859            ));
3860        }
3861        let sql = format!(
3862            "DELETE FROM {} WHERE {}",
3863            base_table(table_name),
3864            clauses.join(" AND ")
3865        );
3866        self.overlay_dirty.set(true);
3867        self.conn
3868            .execute(&sql, rusqlite::params_from_iter(params))
3869            .map_err(|_| ())?;
3870        Ok(())
3871    }
3872
3873    /// §3.3: drop pending commits whose upserts provably land in the
3874    /// revoked effective scopes — whole-commit, never per-operation.
3875    fn drop_doomed_outbox(&mut self, table_name: &str, effective: &[(String, Vec<String>)]) {
3876        if effective.is_empty() {
3877            return;
3878        }
3879        let Some(table) = self.schema.table(table_name).cloned() else {
3880            return;
3881        };
3882        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
3883        for (variable, values) in effective {
3884            match table.scope_column(variable) {
3885                Some(column) => mappings.push((column, values)),
3886                None => return, // not provable without a mapping
3887            }
3888        }
3889        let doomed: Vec<String> = self
3890            .outbox
3891            .iter()
3892            .filter(|commit| {
3893                commit.ops.iter().any(|op| {
3894                    op.upsert
3895                        && op.table == table_name
3896                        && op.values.as_ref().is_some_and(|values| {
3897                            mappings
3898                                .iter()
3899                                .all(|(column, allowed)| match values.get(*column) {
3900                                    Some(Value::String(s)) => allowed.contains(s),
3901                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
3902                                    _ => false,
3903                                })
3904                        })
3905                })
3906            })
3907            .map(|c| c.client_commit_id.clone())
3908            .collect();
3909        for id in doomed {
3910            self.overlay_dirty.set(true);
3911            self.outbox.retain(|c| c.client_commit_id != id);
3912            self.persist_outbox_delete(&id);
3913        }
3914    }
3915
3916    // -- blobs (§5.9) ----------------------------------------------------------------
3917
3918    /// §5.9.7: hash bytes into the content address, cache them, queue the
3919    /// upload (flushed before the next push, B4). Returns the canonical
3920    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
3921    /// column value.
3922    pub fn upload_blob(
3923        &mut self,
3924        bytes: &[u8],
3925        media_type: Option<String>,
3926        name: Option<String>,
3927    ) -> Result<Value, String> {
3928        let blob_id = blob_id_for(bytes);
3929        let now = self.clock_now_ms();
3930        self.conn
3931            .execute(
3932                "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
3933                 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
3934                rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
3935            )
3936            .map_err(|e| e.to_string())?;
3937        self.conn
3938            .execute(
3939                "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
3940                rusqlite::params![blob_id, media_type, now],
3941            )
3942            .map_err(|e| e.to_string())?;
3943        // §5.9.7 B1: a staged upload is pinned (in _syncular_blob_uploads), so
3944        // the trim never evicts it; other zero-ref bodies may be over the cap.
3945        self.enforce_blob_cache_cap();
3946        let mut obj = Map::new();
3947        obj.insert("blobId".to_owned(), Value::from(blob_id));
3948        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
3949        if let Some(mt) = media_type {
3950            obj.insert("mediaType".to_owned(), Value::from(mt));
3951        }
3952        if let Some(n) = name {
3953            obj.insert("name".to_owned(), Value::from(n));
3954        }
3955        Ok(Value::Object(obj))
3956    }
3957
3958    /// §5.9.7: resolve blob bytes — a content-addressed cache hit serves
3959    /// with no fetch (B1); a miss downloads (§5.9.5), verifies the address,
3960    /// caches, and returns `{blobId, byteLength, bytes:{$bytes:hex}}`.
3961    pub fn fetch_blob(
3962        &mut self,
3963        transport: &mut dyn Transport,
3964        blob_id_or_ref: &str,
3965    ) -> Result<Value, (String, String)> {
3966        let simple = |m: String| ("client.failed".to_owned(), m);
3967        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
3968            blob_id_or_ref.to_owned()
3969        } else {
3970            let value: Value = serde_json::from_str(blob_id_or_ref)
3971                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
3972            value
3973                .get("blobId")
3974                .and_then(Value::as_str)
3975                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
3976                .to_owned()
3977        };
3978        if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
3979            return Ok(cached);
3980        }
3981        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
3982        // blob.not_found) verbatim so the harness can assert on it. The
3983        // authorized endpoint serves bytes inline OR (always-issue, presign
3984        // configured) a signed url the client fetches directly — no host auth,
3985        // no fall-through: failure => re-request (the caller's next fetch_blob).
3986        let bytes = match transport
3987            .blob_download(&blob_id)
3988            .map_err(|e| (e.code, e.message))?
3989        {
3990            BlobDownload::Bytes(bytes) => bytes,
3991            BlobDownload::Url {
3992                url,
3993                url_expires_at_ms,
3994            } => {
3995                // §5.9.5: MUST NOT start a fetch at/past expiry.
3996                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
3997                    return Err((
3998                        "sync.segment_expired".to_owned(),
3999                        format!(
4000                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
4001                        ),
4002                    ));
4003                }
4004                transport
4005                    .fetch_blob_url(&url)
4006                    .map_err(|e| (e.code, e.message))?
4007            }
4008        };
4009        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
4010        if blob_id_for(&bytes) != blob_id {
4011            return Err(simple(format!(
4012                "blob content address mismatch for {blob_id}"
4013            )));
4014        }
4015        let now = self.clock_now_ms();
4016        self.conn
4017            .execute(
4018                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
4019                rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
4020            )
4021            .map_err(|e| simple(e.to_string()))?;
4022        self.enforce_blob_cache_cap();
4023        self.get_cached_blob(&blob_id)
4024            .map_err(simple)?
4025            .ok_or_else(|| simple("blob cache write failed".to_owned()))
4026    }
4027
4028    fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
4029        // §5.9.7 B1 LRU: a cache-hit read touches "recently used" so a hot
4030        // image survives a cap trim.
4031        let _ = self.conn.execute(
4032            "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
4033            rusqlite::params![self.clock_now_ms(), blob_id],
4034        );
4035        let mut stmt = self
4036            .conn
4037            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
4038            .map_err(|e| e.to_string())?;
4039        let mut rows = stmt
4040            .query(rusqlite::params![blob_id])
4041            .map_err(|e| e.to_string())?;
4042        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
4043            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
4044            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
4045            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
4046            let mut obj = Map::new();
4047            obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
4048            obj.insert("byteLength".to_owned(), Value::from(byte_length));
4049            let mut bytes_obj = Map::new();
4050            bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(&bytes)));
4051            obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
4052            if let Some(mt) = media_type {
4053                obj.insert("mediaType".to_owned(), Value::from(mt));
4054            }
4055            return Ok(Some(Value::Object(obj)));
4056        }
4057        Ok(None)
4058    }
4059
4060    /// §5.9.7 B4: upload every queued blob before push.
4061    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
4062        let pending: Vec<(String, Option<String>)> = {
4063            let mut stmt = self
4064                .conn
4065                .prepare(
4066                    "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
4067                )
4068                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
4069            let rows = stmt
4070                .query_map([], |row| {
4071                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
4072                })
4073                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
4074            rows.filter_map(Result::ok).collect()
4075        };
4076        for (blob_id, media_type) in pending {
4077            let bytes: Option<Vec<u8>> = self
4078                .conn
4079                .query_row(
4080                    "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
4081                    rusqlite::params![blob_id],
4082                    |row| row.get(0),
4083                )
4084                .ok();
4085            if let Some(bytes) = bytes {
4086                self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
4087            }
4088            let _ = self.conn.execute(
4089                "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
4090                rusqlite::params![blob_id],
4091            );
4092        }
4093        Ok(())
4094    }
4095
4096    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
4097    /// grant when the transport supports it, else streaming through the direct
4098    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
4099    /// PUTs direct with no host auth; on a grant PUT failure the client streams
4100    /// through the direct endpoint — a *different, host-authenticated
4101    /// capability*, not a fall-through of the grant's authority.
4102    fn upload_one(
4103        &self,
4104        transport: &mut dyn Transport,
4105        blob_id: &str,
4106        bytes: &[u8],
4107        media_type: Option<&str>,
4108    ) -> Result<(), TransportError> {
4109        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
4110            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
4111            BlobUploadGrant::Url {
4112                url,
4113                url_expires_at_ms,
4114            } => {
4115                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
4116                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
4117                    return Ok(());
4118                }
4119                // Failed/expired grant PUT — stream through the direct endpoint.
4120            }
4121            BlobUploadGrant::None => {
4122                // No presign store — stream through the direct endpoint.
4123            }
4124        }
4125        transport.blob_upload(blob_id, bytes, media_type)
4126    }
4127
4128    /// §5.9.7 B1 size cap + LRU eviction: when the sum of cached body sizes
4129    /// exceeds `blob_cache_max_bytes`, evict zero-ref, non-pinned bodies in
4130    /// least-recently-used order until back under the cap. NEVER evicts a
4131    /// referenced body (refcount > 0) nor a pending-upload-pinned body — if all
4132    /// over-cap bodies are referenced or pinned, the cache stays over the cap
4133    /// (correctness beats the cap). B3 re-enables the fetch for any evicted
4134    /// zero-ref body, so eviction only costs a future re-download. No-op if the
4135    /// cap is unset.
4136    fn enforce_blob_cache_cap(&self) {
4137        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
4138            return;
4139        };
4140        let mut total: i64 = self
4141            .conn
4142            .query_row(
4143                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
4144                [],
4145                |row| row.get(0),
4146            )
4147            .unwrap_or(0);
4148        if total <= max_bytes {
4149            return;
4150        }
4151        let candidates: Vec<(String, i64)> = {
4152            let Ok(mut stmt) = self.conn.prepare(
4153                "SELECT blob_id, byte_length FROM _syncular_blobs
4154                 WHERE refcount = 0
4155                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
4156                 ORDER BY last_used_ms ASC, created_at_ms ASC",
4157            ) else {
4158                return;
4159            };
4160            let Ok(rows) = stmt.query_map([], |row| {
4161                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
4162            }) else {
4163                return;
4164            };
4165            rows.filter_map(Result::ok).collect()
4166        };
4167        for (blob_id, byte_length) in candidates {
4168            if total <= max_bytes {
4169                break;
4170            }
4171            let _ = self.conn.execute(
4172                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
4173                rusqlite::params![blob_id],
4174            );
4175            total -= byte_length;
4176        }
4177    }
4178
4179    /// §5.9.7 B1/B2: recompute cache refcounts from live `blob_ref` columns
4180    /// in the BASE tables; `delete_orphans` deletes zero-ref bodies not
4181    /// pinned by a pending upload (the revocation side, B2).
4182    fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
4183        if !self.schema_has_blobs() {
4184            return;
4185        }
4186        let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
4187        for table in self.schema.tables.clone() {
4188            let blob_cols: Vec<String> = table
4189                .columns
4190                .iter()
4191                .filter(|c| c.ty == ColumnType::BlobRef)
4192                .map(|c| c.name.clone())
4193                .collect();
4194            for column in blob_cols {
4195                let sql = format!(
4196                    "SELECT {} FROM {} WHERE {} IS NOT NULL",
4197                    quote_ident(&column),
4198                    base_table(&table.name),
4199                    quote_ident(&column)
4200                );
4201                let Ok(mut stmt) = self.conn.prepare(&sql) else {
4202                    continue;
4203                };
4204                let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
4205                    continue;
4206                };
4207                for raw in rows.flatten().flatten() {
4208                    if let Ok(value) = serde_json::from_str::<Value>(&raw) {
4209                        if let Some(id) = value.get("blobId").and_then(Value::as_str) {
4210                            *counts.entry(id.to_owned()).or_insert(0) += 1;
4211                        }
4212                    }
4213                }
4214            }
4215        }
4216        let _ = self
4217            .conn
4218            .execute("UPDATE _syncular_blobs SET refcount = 0", []);
4219        for (blob_id, count) in &counts {
4220            let _ = self.conn.execute(
4221                "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
4222                rusqlite::params![count, blob_id],
4223            );
4224        }
4225        if delete_orphans {
4226            let _ = self.conn.execute(
4227                "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
4228                [],
4229            );
4230        }
4231    }
4232
4233    // -- local row storage ----------------------------------------------------------
4234
4235    /// The cached per-table `INSERT OR REPLACE` SQL for `full_table` (see
4236    /// the `insert_sql` field: built once, reused per row).
4237    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
4238        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
4239            return sql.clone();
4240        }
4241        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
4242        columns.push(quote_ident("_syncular_version"));
4243        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
4244        let sql = format!(
4245            "INSERT OR REPLACE INTO {full_table} ({}) VALUES ({})",
4246            columns.join(", "),
4247            placeholders.join(", ")
4248        );
4249        self.insert_sql
4250            .borrow_mut()
4251            .insert(full_table.to_owned(), sql.clone());
4252        sql
4253    }
4254
4255    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
4256        self.overlay_dirty.set(true);
4257        self.write_row(&base_table(table_name), table_name, row, version)
4258    }
4259
4260    fn write_row(
4261        &self,
4262        full_table: &str,
4263        table_name: &str,
4264        row: &Row,
4265        version: i64,
4266    ) -> Result<(), String> {
4267        let table = self
4268            .schema
4269            .table(table_name)
4270            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
4271        let sql = self.insert_row_sql(full_table, table);
4272        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
4273        let params = row
4274            .iter()
4275            .map(RowParam::Cell)
4276            .chain(std::iter::once(RowParam::Version(version)));
4277        stmt.execute(rusqlite::params_from_iter(params))
4278            .map_err(|e| e.to_string())?;
4279        Ok(())
4280    }
4281
4282    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
4283        let table = self
4284            .schema
4285            .table(table_name)
4286            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
4287        self.overlay_dirty.set(true);
4288        let sql = format!(
4289            "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
4290            base_table(table_name),
4291            quote_ident(&table.primary_key)
4292        );
4293        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
4294        stmt.execute(rusqlite::params![row_id])
4295            .map_err(|e| e.to_string())?;
4296        Ok(())
4297    }
4298
4299    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
4300    /// the outbox changed since the last rebuild (the no-op sync round).
4301    fn rebuild_overlay_if_dirty(&mut self) {
4302        if self.overlay_dirty.get() {
4303            self.rebuild_overlay();
4304        }
4305    }
4306
4307    /// §7.1: local reads see outbox state applied optimistically — rebuild
4308    /// every visible table as (base server state) + (pending outbox replay
4309    /// on top). Optimistic rows carry version `-1`.
4310    fn rebuild_overlay(&mut self) {
4311        self.exec("SAVEPOINT syncular_overlay");
4312        for table in self.schema.tables.clone() {
4313            let visible = visible_table(&table.name);
4314            let base = base_table(&table.name);
4315            self.exec(&format!("DELETE FROM {visible}"));
4316            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
4317        }
4318        for commit in self.outbox.clone() {
4319            for op in &commit.ops {
4320                let Some(table) = self.schema.table(&op.table).cloned() else {
4321                    continue;
4322                };
4323                if op.upsert {
4324                    let Some(values) = op.values.as_ref() else {
4325                        continue;
4326                    };
4327                    let mut row: Row = Vec::with_capacity(table.columns.len());
4328                    let mut ok = true;
4329                    for column in &table.columns {
4330                        match json_to_column_value(column, values.get(&column.name)) {
4331                            Ok(v) => row.push(v),
4332                            Err(_) => {
4333                                ok = false;
4334                                break;
4335                            }
4336                        }
4337                    }
4338                    if ok {
4339                        let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
4340                    }
4341                } else {
4342                    let sql = format!(
4343                        "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
4344                        visible_table(&table.name),
4345                        quote_ident(&table.primary_key)
4346                    );
4347                    let _ = self.conn.execute(&sql, rusqlite::params![op.row_id]);
4348                }
4349            }
4350        }
4351        self.exec("RELEASE syncular_overlay");
4352        self.overlay_dirty.set(false);
4353    }
4354
4355    fn exec(&self, sql: &str) {
4356        let _ = self.conn.execute_batch(sql);
4357    }
4358
4359    // -- realtime (§8) ---------------------------------------------------------------
4360
4361    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
4362        transport
4363            .realtime_connect_for_client(&self.client_id)
4364            .map_err(|e| format!("{}: {}", e.code, e.message))?;
4365        self.realtime_connected = true;
4366        Ok(())
4367    }
4368
4369    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
4370        let _ = transport.realtime_close();
4371        self.realtime_connected = false;
4372        self.presence.clear(); // §8.6.1: presence is per-connection
4373    }
4374
4375    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
4376    /// document for `scope_key`. Requires a live socket; the document is
4377    /// ephemeral (lost on disconnect). Authorization is the connection's
4378    /// registration (§8.6.3) — an unheld key is rejected loudly by the
4379    /// server with `presence.forbidden`.
4380    pub fn set_presence(
4381        &mut self,
4382        transport: &mut dyn Transport,
4383        scope_key: &str,
4384        doc: Option<&Value>,
4385    ) -> Result<(), String> {
4386        if !self.realtime_connected {
4387            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
4388        }
4389        let text = encode_presence_publish(scope_key, doc);
4390        transport
4391            .realtime_send(&text)
4392            .map_err(|e| format!("{}: {}", e.code, e.message))
4393    }
4394
4395    /// §8.6: the peers currently present on a scope key (ephemeral).
4396    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
4397        self.presence
4398            .get(scope_key)
4399            .map(|peers| peers.values().cloned().collect())
4400            .unwrap_or_default()
4401    }
4402
4403    /// §8.6 apply an inbound presence fanout to the local map.
4404    fn apply_presence(
4405        &mut self,
4406        scope_key: String,
4407        kind: Option<PresenceKind>,
4408        actor_id: Option<String>,
4409        client_id: Option<String>,
4410        doc: Option<Value>,
4411        error: Option<String>,
4412    ) {
4413        // The publisher-directed error variant is out-of-band; nothing to
4414        // record in the peer map.
4415        if error.is_some() {
4416            return;
4417        }
4418        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
4419            return;
4420        };
4421        let peer_key = format!("{actor_id} {client_id}");
4422        match kind {
4423            PresenceKind::Leave => {
4424                if let Some(peers) = self.presence.get_mut(&scope_key) {
4425                    peers.remove(&peer_key);
4426                    if peers.is_empty() {
4427                        self.presence.remove(&scope_key);
4428                    }
4429                }
4430            }
4431            _ => {
4432                let doc = match doc {
4433                    Some(Value::Object(_)) => doc.unwrap(),
4434                    _ => return,
4435                };
4436                self.presence.entry(scope_key).or_default().insert(
4437                    peer_key,
4438                    PresencePeer {
4439                        actor_id,
4440                        client_id,
4441                        doc,
4442                    },
4443                );
4444            }
4445        }
4446    }
4447
4448    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
4449    pub fn on_realtime_text(&mut self, text: &str) {
4450        match parse_control(text) {
4451            Ok(ControlMessage::Hello { requires_sync, .. }) => {
4452                if requires_sync {
4453                    // §8.1: pull before trusting the socket for continuity.
4454                    self.set_sync_needed(true, true);
4455                }
4456            }
4457            Ok(ControlMessage::Presence {
4458                scope_key,
4459                kind,
4460                actor_id,
4461                client_id,
4462                doc,
4463                error,
4464                ..
4465            }) => {
4466                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
4467            }
4468            Ok(ControlMessage::Wake { .. }) => {
4469                // §8.3: any wake-up means "run a pull soon", never data.
4470                self.set_sync_needed(true, true);
4471            }
4472            _ => {}
4473        }
4474    }
4475
4476    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
4477    /// a pull response per section; an unapplied delta is a wake-up.
4478    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
4479        if self.stopped {
4480            return;
4481        }
4482        let message = match decode_message(bytes) {
4483            Ok(m) if m.msg_kind == MsgKind::Response => m,
4484            _ => {
4485                self.set_sync_needed(true, true);
4486                return;
4487            }
4488        };
4489        let mut frames = message.frames.into_iter();
4490        let mut applied_cursor: Option<i64> = None;
4491        let mut any_covered = false;
4492        let mut dropped = false;
4493        while let Some(frame) = frames.next() {
4494            let Frame::SubStart {
4495                id,
4496                status,
4497                effective_scopes,
4498                ..
4499            } = frame
4500            else {
4501                continue;
4502            };
4503            let mut body = Vec::new();
4504            let mut next_cursor: Option<i64> = None;
4505            for inner in frames.by_ref() {
4506                match inner {
4507                    Frame::SubEnd {
4508                        next_cursor: nc, ..
4509                    } => {
4510                        next_cursor = Some(nc);
4511                        break;
4512                    }
4513                    Frame::Unknown { .. } => {}
4514                    other => body.push(other),
4515                }
4516            }
4517            let Some(next_cursor) = next_cursor else {
4518                dropped = true;
4519                break;
4520            };
4521            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
4522                dropped = true;
4523                continue;
4524            };
4525            let sub = &self.subs[sub_index];
4526            // §8.2: only locally active, not mid-bootstrap subscriptions
4527            // apply; skipped sections are repaired by the next pull.
4528            if status != SubStatus::Active
4529                || sub.state != SubState::Active
4530                || sub.bootstrap_state.is_some()
4531                || !sub.synced_once
4532            {
4533                dropped = true;
4534                continue;
4535            }
4536            if next_cursor <= sub.cursor {
4537                // Idempotent redelivery of an already-covered window.
4538                any_covered = true;
4539                continue;
4540            }
4541            let previous_effective = self.subs[sub_index].effective.clone();
4542            let previous_cursor = self.subs[sub_index].cursor;
4543            if self.begin_observation("syncular_delta").is_err() {
4544                dropped = true;
4545                continue;
4546            }
4547            self.subs[sub_index].effective = Some(effective_scopes);
4548            let mut batch = ChangeAccumulator::default();
4549            let mut failed = false;
4550            for inner in body {
4551                if let Frame::Commit {
4552                    tables, changes, ..
4553                } = inner
4554                {
4555                    self.record_commit_changes(&mut batch, &tables, &changes);
4556                    if self.apply_commit_changes(&tables, &changes).is_err() {
4557                        failed = true;
4558                        break;
4559                    }
4560                }
4561            }
4562            if failed {
4563                self.rollback_observation("syncular_delta");
4564                self.subs[sub_index].effective = previous_effective;
4565                self.subs[sub_index].cursor = previous_cursor;
4566                self.overlay_dirty.set(true);
4567                self.rebuild_overlay();
4568                dropped = true;
4569                continue;
4570            }
4571            let sub = &mut self.subs[sub_index];
4572            sub.cursor = next_cursor;
4573            self.persist_sub(&self.subs[sub_index].clone());
4574            self.rebuild_overlay_if_dirty();
4575            if self.finish_observation("syncular_delta", batch).is_err() {
4576                self.rollback_observation("syncular_delta");
4577                self.subs[sub_index].effective = previous_effective;
4578                self.subs[sub_index].cursor = previous_cursor;
4579                self.overlay_dirty.set(true);
4580                self.rebuild_overlay();
4581                dropped = true;
4582                continue;
4583            }
4584            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
4585        }
4586        if let Some(cursor) = applied_cursor {
4587            self.reconcile_blob_refcounts(false);
4588            // §8.2 ack point: the highest applied SUB_END.nextCursor.
4589            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
4590            let _ = transport.realtime_send(&ack);
4591        } else if !any_covered || dropped {
4592            // §8.2: a delta not applied at all is treated as a wake-up.
4593            self.set_sync_needed(true, true);
4594        }
4595    }
4596
4597    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
4598    /// cursor across active, non-bootstrapping subscriptions that have
4599    /// synced at least once. No such subscription, no ack.
4600    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
4601        if !self.realtime_connected {
4602            return;
4603        }
4604        let floor = self
4605            .subs
4606            .iter()
4607            .filter(|s| {
4608                s.state == SubState::Active
4609                    && s.bootstrap_state.is_none()
4610                    && s.synced_once
4611                    && s.cursor >= 0
4612            })
4613            .map(|s| s.cursor)
4614            .min();
4615        if let Some(cursor) = floor {
4616            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
4617            let _ = transport.realtime_send(&ack);
4618        }
4619    }
4620}