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::HashMap;
12
13use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
14use rusqlite::Connection;
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    ClientLimits, ConflictRecord, LeaseState, Mutation, PresencePeer, RejectionRecord, RowState,
27    SchemaFloor, SubscriptionStateView, SyncOutcome, SyncReport, WindowBase,
28};
29use crate::schema::{parse_schema_json, ClientSchema};
30use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
31use crate::values::{
32    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
33    json_to_column_value, json_to_scope_map, render_row_id_json, scope_map_to_json, sort_scope_map,
34};
35
36/// §4.2 default: the rows baseline plus sqlite images (§5.3) — rusqlite
37/// can always import an image, so the premier path is advertised unless
38/// the host overrides `limits.accept`. Bit 3 (signed URLs, §5.4) is
39/// added per transport capability at request-build time.
40const DEFAULT_ACCEPT: u8 = 0b0111;
41const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
42const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
43const ACCEPT_SQLITE: u8 = 1 << 2;
44const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
45
46/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
47const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
48/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
49/// the new schema after a bump. Never a wire code (§10.3).
50const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum SubState {
54    Active,
55    Revoked,
56    Failed,
57}
58
59impl SubState {
60    fn name(self) -> &'static str {
61        match self {
62            SubState::Active => "active",
63            SubState::Revoked => "revoked",
64            SubState::Failed => "failed",
65        }
66    }
67}
68
69#[derive(Debug, Clone)]
70struct Subscription {
71    id: String,
72    table: String,
73    requested: Vec<(String, Vec<String>)>,
74    params: Option<String>,
75    cursor: i64,
76    /// §4.7 resume token, round-tripped opaquely.
77    bootstrap_state: Option<String>,
78    state: SubState,
79    reason_code: Option<String>,
80    /// Last effective scopes echoed while active (§3.3: persisted for the
81    /// purge contract; each active echo replaces it).
82    effective: Option<Vec<(String, Vec<String>)>>,
83    synced_once: bool,
84}
85
86#[derive(Debug, Clone)]
87struct OutboxOp {
88    upsert: bool,
89    table: String,
90    row_id: String,
91    base_version: Option<i64>,
92    /// Schema-agnostic local form (§0): driver JSON values, encoded with
93    /// the current codec at send time.
94    values: Option<Map<String, Value>>,
95}
96
97#[derive(Debug, Clone)]
98struct OutboxCommit {
99    client_commit_id: String,
100    ops: Vec<OutboxOp>,
101}
102
103/// Section outcome distinguishing the §5.6 subscription-local fail-closed
104/// path from a round-aborting failure (§1.4 rule 5).
105enum SectionError {
106    FailClosed,
107    Abort(String, String),
108}
109
110struct RequestMeta {
111    pushed_ids: Vec<String>,
112    /// Subscription id → the request carried `cursor < 0` and no resume
113    /// token (§5.6 first-page detection: a *fresh* bootstrap).
114    fresh: Vec<(String, bool)>,
115    accept: u8,
116    /// §6.1 splitBatch: outbox commits held back from THIS request because
117    /// the running operation count reached the push cap — the next round
118    /// pushes them (`sync_needed` stays set while any remain).
119    deferred_commits: usize,
120}
121
122/// §6.1: the server caps total operations per request (reference default
123/// 500) and rejects the whole batch with `sync.too_many_operations`; the
124/// client "splits and retries". Splitting happens at build time: commits are
125/// included IN ORDER until the operation budget is spent, the rest wait for
126/// the next round.
127const PUSH_OPS_PER_REQUEST: usize = 500;
128
129pub struct SyncClient {
130    conn: Connection,
131    schema: ClientSchema,
132    client_id: String,
133    limits: ClientLimits,
134    subs: Vec<Subscription>,
135    outbox: Vec<OutboxCommit>,
136    conflicts: Vec<ConflictRecord>,
137    rejections: Vec<RejectionRecord>,
138    schema_floor: Option<SchemaFloor>,
139    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
140    lease_state: Option<LeaseState>,
141    /// §1.6: the schema-floor response stops syncing until an upgrade.
142    stopped: bool,
143    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
144    upgrading: bool,
145    /// §8.4 coalesced sync-needed signal.
146    sync_needed: bool,
147    realtime_connected: bool,
148    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
149    presence: HashMap<String, HashMap<String, PresencePeer>>,
150    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
151    /// host may pin it (conformance runs on a virtual clock).
152    now_ms: Option<i64>,
153    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
154    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
155    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
156    encryption: crate::values::EncryptionConfig,
157    /// Per-table `INSERT OR REPLACE` SQL, built once per (full table name) —
158    /// the row write path runs per row during bootstrap (§5.6), so the SQL
159    /// string (and, via `prepare_cached`, its compiled statement) is reused
160    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
161    /// schema reset (the column lists may have changed).
162    insert_sql: RefCell<HashMap<String, String>>,
163    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
164    /// diverged from the visible overlay since the last rebuild. Lets a
165    /// no-op sync round skip the full base→visible copy.
166    overlay_dirty: Cell<bool>,
167}
168
169fn quote_ident(name: &str) -> String {
170    format!("\"{}\"", name.replace('"', "\"\""))
171}
172
173fn base_table(name: &str) -> String {
174    quote_ident(&format!("_syncular_base_{name}"))
175}
176
177/// §4.8: a stable, server-opaque key for a window base — table + variable +
178/// canonical fixed scopes. Two `set_window` calls with the same base
179/// address the same registry rows.
180/// §4.8 deferred eviction record: (sub id, table, effective scope map).
181type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
182
183fn window_base_key(base: &WindowBase) -> String {
184    format!(
185        "{} {} {}",
186        base.table,
187        base.variable,
188        canonical_scope_json(&base.fixed_scopes)
189    )
190}
191
192/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
193fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
194    let mut scopes = base.fixed_scopes.clone();
195    scopes.retain(|(k, _)| k != &base.variable);
196    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
197    scopes
198}
199
200/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
201/// echoed not interpreted by the server, so the exact hash is client
202/// convention; SHA-256 matches the SPEC's worked example.
203fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
204    let canonical = canonical_scope_json(&unit_scopes(base, unit));
205    let digest = Sha256::digest(canonical.as_bytes());
206    let hex = bytes_to_hex(&digest);
207    format!("w:{}:{}", base.table, &hex[..16])
208}
209
210fn visible_table(name: &str) -> String {
211    quote_ident(name)
212}
213
214/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
215/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
216fn is_synced_table_name(name: &str) -> bool {
217    if name.starts_with("sqlite_") {
218        return false;
219    }
220    if name.starts_with("_syncular_base_") {
221        return true; // the base half of a synced table pair
222    }
223    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
224    !name.starts_with("_syncular_")
225}
226
227/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
228fn blob_id_for(bytes: &[u8]) -> String {
229    let digest = Sha256::digest(bytes);
230    format!("sha256:{}", bytes_to_hex(&digest))
231}
232
233/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
234/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
235/// per row on the §5.6 bootstrap path), scalars bind owned.
236enum RowParam<'a> {
237    Cell(&'a Option<ColumnValue>),
238    Version(i64),
239}
240
241impl rusqlite::ToSql for RowParam<'_> {
242    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
243        Ok(match self {
244            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
245            RowParam::Cell(cell) => match cell {
246                None => ToSqlOutput::Owned(SqlValue::Null),
247                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
248                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
249                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
250                Some(ColumnValue::Boolean(b)) => {
251                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
252                }
253                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
254                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
255                }
256                // §5.10: crdt bytes store as BLOB, like bytes.
257                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
258                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
259                }
260            },
261        })
262    }
263}
264
265/// §5.3 image cell → bind parameter, strict per the declared column type
266/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
267/// nullable). Mismatches are image-producer violations. Returns the cell
268/// borrowed when the stored representation already matches what the row
269/// codec would write, or the normalized scalar (boolean → 0/1, float from
270/// INTEGER → REAL) otherwise — the local write is byte-identical to the
271/// old convert-then-insert path without allocating per cell.
272fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
273    use ssp2::segment::ColumnType;
274    let mismatch = || {
275        Err(format!(
276            "image column {:?} holds a value of the wrong type",
277            column.name
278        ))
279    };
280    match value {
281        ValueRef::Null => {
282            if !column.nullable {
283                return Err(format!(
284                    "image column {:?} is NULL but not nullable",
285                    column.name
286                ));
287            }
288            Ok(ToSqlOutput::Owned(SqlValue::Null))
289        }
290        ValueRef::Integer(i) => match column.ty {
291            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
292            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
293            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
294            _ => mismatch(),
295        },
296        ValueRef::Real(_) => match column.ty {
297            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
298            _ => mismatch(),
299        },
300        ValueRef::Text(t) => {
301            std::str::from_utf8(t)
302                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
303            match column.ty {
304                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
305                    Ok(ToSqlOutput::Borrowed(value))
306                }
307                _ => mismatch(),
308            }
309        }
310        ValueRef::Blob(_) => match column.ty {
311            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
312            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
313            _ => mismatch(),
314        },
315    }
316}
317
318fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
319    use rusqlite::types::ValueRef;
320    match value {
321        ValueRef::Null => Value::Null,
322        ValueRef::Integer(i) => match column.ty {
323            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
324            ssp2::segment::ColumnType::Float => {
325                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
326            }
327            _ => Value::from(i),
328        },
329        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
330        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
331        ValueRef::Blob(b) => {
332            let mut map = Map::new();
333            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
334            Value::Object(map)
335        }
336    }
337}
338
339/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
340/// Objects are only accepted in the `{"$bytes": hex}` byte-envelope form.
341fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
342    Ok(match value {
343        Value::Null => SqlValue::Null,
344        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
345        Value::Number(n) => {
346            if let Some(i) = n.as_i64() {
347                SqlValue::Integer(i)
348            } else if let Some(f) = n.as_f64() {
349                SqlValue::Real(f)
350            } else {
351                return Err(format!("query param number {n} is out of range"));
352            }
353        }
354        Value::String(s) => SqlValue::Text(s.clone()),
355        Value::Object(_) => {
356            let hex = value
357                .get("$bytes")
358                .and_then(Value::as_str)
359                .ok_or_else(|| "query object param must be a {\"$bytes\": hex} value".to_owned())?;
360            SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
361        }
362        Value::Array(_) => return Err("query array params are not supported".to_owned()),
363    })
364}
365
366/// Map a rusqlite value with no schema column to consult (arbitrary query
367/// output): integers/reals/text pass through by stored affinity, blobs ride
368/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
369/// schema column type to recover booleans/floats/json.
370fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
371    use rusqlite::types::ValueRef;
372    match value {
373        ValueRef::Null => Value::Null,
374        ValueRef::Integer(i) => Value::from(i),
375        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
376        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
377        ValueRef::Blob(b) => {
378            let mut map = Map::new();
379            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
380            Value::Object(map)
381        }
382    }
383}
384
385impl SyncClient {
386    pub fn new(
387        client_id: String,
388        schema_json: &Value,
389        limits: ClientLimits,
390    ) -> Result<Self, String> {
391        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
392        Self::with_connection(client_id, schema_json, limits, conn)
393    }
394
395    /// Build a client backed by an on-disk SQLite database at `path` — the
396    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
397    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
398    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
399    /// of the command router's dependency set (the router only holds a path).
400    pub fn open_path(
401        client_id: String,
402        schema_json: &Value,
403        limits: ClientLimits,
404        path: &str,
405    ) -> Result<Self, String> {
406        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
407        Self::with_connection(client_id, schema_json, limits, conn)
408    }
409
410    /// Build a client over a caller-supplied rusqlite connection — the seam a
411    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
412    /// with an on-disk database (`Connection::open(path)`) rather than the
413    /// default `:memory:`. The connection MUST be fresh (no pre-existing
414    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
415    /// the same file across process restarts reuses the persisted rows.
416    pub fn with_connection(
417        client_id: String,
418        schema_json: &Value,
419        limits: ClientLimits,
420        conn: Connection,
421    ) -> Result<Self, String> {
422        let schema = parse_schema_json(schema_json)?;
423        let client = SyncClient {
424            conn,
425            schema,
426            client_id,
427            limits,
428            subs: Vec::new(),
429            outbox: Vec::new(),
430            conflicts: Vec::new(),
431            rejections: Vec::new(),
432            schema_floor: None,
433            lease_state: None,
434            stopped: false,
435            upgrading: false,
436            sync_needed: false,
437            realtime_connected: false,
438            presence: HashMap::new(),
439            now_ms: None,
440            encryption: crate::values::EncryptionConfig::default(),
441            insert_sql: RefCell::new(HashMap::new()),
442            overlay_dirty: Cell::new(false),
443        };
444        // The row write path leans on the prepared-statement cache (two
445        // insert statements per synced table, plus the bookkeeping
446        // statements); size it so a multi-table schema never thrashes.
447        client
448            .conn
449            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
450        client.create_tables()?;
451        Ok(client)
452    }
453
454    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
455    /// against this instead of system time (conformance virtual clock).
456    pub fn set_now_ms(&mut self, now_ms: i64) {
457        self.now_ms = Some(now_ms);
458    }
459
460    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
461    /// The command router parses these from the `create` command's
462    /// `encryption` config (keys as `{$bytes: hex}`).
463    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
464        self.encryption = encryption;
465    }
466
467    fn clock_now_ms(&self) -> i64 {
468        self.now_ms.unwrap_or_else(|| {
469            std::time::SystemTime::now()
470                .duration_since(std::time::UNIX_EPOCH)
471                .map(|d| d.as_millis() as i64)
472                .unwrap_or(0)
473        })
474    }
475
476    fn create_tables(&self) -> Result<(), String> {
477        self.create_synced_tables()?;
478        // Durable client bookkeeping (outbox + subscription + meta).
479        self.conn
480            .execute_batch(
481                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
482                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
483                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
484                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
485                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
486                 CREATE TABLE IF NOT EXISTS _syncular_meta (
487                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
488                 CREATE TABLE IF NOT EXISTS _syncular_windows (
489                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
490                   PRIMARY KEY (base, unit));
491                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
492                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
493                   effective_scopes TEXT NOT NULL);",
494            )
495            .map_err(|e| e.to_string())?;
496        // §7.4.1: seed the persisted local schema-version marker on first
497        // create (a fresh install is already at its generated version).
498        if self.get_meta(LOCAL_SCHEMA_VERSION_KEY).is_none() {
499            self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
500        }
501        // §5.9.7 blob cache + pending-upload queue (created only when the
502        // schema declares blob_ref columns; harmless otherwise). IF NOT EXISTS
503        // so a reopened on-disk DB reuses the persisted bodies across restarts
504        // (the §5.9.7 B1 storage model: bytes live as BLOBs in the client DB).
505        if self.schema_has_blobs() {
506            self.conn
507                .execute_batch(
508                    "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
509                       bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
510                       media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
511                       created_at_ms INTEGER NOT NULL,
512                       last_used_ms INTEGER NOT NULL DEFAULT 0);
513                     CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
514                       media_type TEXT, created_at_ms INTEGER NOT NULL);",
515                )
516                .map_err(|e| e.to_string())?;
517            // Migrate a cache created before the §5.9.7 B1 LRU column
518            // (additive; the duplicate-column error on an already-migrated DB
519            // is swallowed).
520            let _ = self.conn.execute_batch(
521                "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
522            );
523        }
524        Ok(())
525    }
526
527    /// True iff any synced table declares a `blob_ref` column (§5.9).
528    fn schema_has_blobs(&self) -> bool {
529        self.schema
530            .tables
531            .iter()
532            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
533    }
534
535    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
536
537    fn get_meta(&self, key: &str) -> Option<String> {
538        self.conn
539            .query_row(
540                "SELECT value FROM _syncular_meta WHERE key = ?1",
541                rusqlite::params![key],
542                |row| row.get::<_, String>(0),
543            )
544            .ok()
545    }
546
547    fn set_meta(&self, key: &str, value: &str) {
548        let _ = self.conn.execute(
549            "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
550            rusqlite::params![key, value],
551        );
552    }
553
554    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
555    pub fn upgrading(&self) -> bool {
556        self.upgrading
557    }
558
559    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
560    /// keeping this client's local database (identity, outbox, tables). The
561    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
562    /// version changed. Mirrors the TS client's boot-time detection —
563    /// the Rust core has no persistent restart, so recreation IS the boot.
564    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
565        let new_schema = parse_schema_json(schema_json)?;
566        let marker: Option<i32> = self
567            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
568            .and_then(|v| v.parse().ok());
569        self.schema = new_schema;
570        if marker == Some(self.schema.version) {
571            // No version change — nothing to reset.
572            return Ok(());
573        }
574        self.run_schema_reset()
575    }
576
577    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
578    /// and leaseState. Drops/recreates every synced table from the new
579    /// schema, resets subscription sync-state (keeping registrations), clears
580    /// the schema-floor stop state, rewrites the marker, drops outbox commits
581    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
582    fn run_schema_reset(&mut self) -> Result<(), String> {
583        self.upgrading = true;
584        // The per-table insert SQL is derived from the OLD column lists.
585        self.insert_sql.borrow_mut().clear();
586        self.overlay_dirty.set(true);
587        // Drop every synced table (base + visible) that currently exists —
588        // discovered from sqlite_master so a bump that adds/removes tables is
589        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
590        // and the blob cache) are preserved; base tables are `_syncular_base_*`
591        // so they are matched explicitly, not by the bookkeeping filter.
592        let existing: Vec<String> = {
593            let mut stmt = self
594                .conn
595                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
596                .map_err(|e| e.to_string())?;
597            let rows = stmt
598                .query_map([], |row| row.get::<_, String>(0))
599                .map_err(|e| e.to_string())?;
600            rows.filter_map(Result::ok)
601                .filter(|name| is_synced_table_name(name))
602                .collect()
603        };
604        for name in existing {
605            let _ = self
606                .conn
607                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), []);
608        }
609        // Recreate the synced tables from the NEW schema.
610        self.create_synced_tables()?;
611        // Reset every subscription's sync-state, keeping the registration.
612        for sub in &mut self.subs {
613            sub.cursor = -1;
614            sub.bootstrap_state = None;
615            sub.effective = None;
616            sub.state = SubState::Active;
617            sub.reason_code = None;
618            sub.synced_once = false;
619        }
620        let subs = self.subs.clone();
621        for sub in &subs {
622            self.persist_sub(sub);
623        }
624        // The stop state is over: this client now ships a servable schema.
625        self.stopped = false;
626        self.schema_floor = None;
627        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
628        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
629        // §7.4.4: drop outbox commits that cannot re-encode under the new
630        // schema (a referenced column/table the bump removed), surfacing each
631        // as a `sync.outbox_incompatible` rejection.
632        self.drop_incompatible_outbox();
633        // Re-apply the surviving outbox optimistically over the empty tables.
634        self.rebuild_overlay();
635        Ok(())
636    }
637
638    /// §7.4.4: a persisted upsert whose values reference a column the current
639    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
640    /// and raise a client-local `sync.outbox_incompatible` rejection.
641    fn drop_incompatible_outbox(&mut self) {
642        let schema = &self.schema;
643        let mut incompatible: Vec<String> = Vec::new();
644        self.outbox.retain(|commit| {
645            let bad = commit.ops.iter().any(|op| {
646                if !op.upsert {
647                    return false;
648                }
649                match schema.table(&op.table) {
650                    None => true,
651                    Some(table) => op.values.as_ref().is_some_and(|values| {
652                        values
653                            .keys()
654                            .any(|key| !table.columns.iter().any(|c| &c.name == key))
655                    }),
656                }
657            });
658            if bad {
659                incompatible.push(commit.client_commit_id.clone());
660            }
661            !bad
662        });
663        for client_commit_id in incompatible {
664            self.persist_outbox_delete(&client_commit_id);
665            self.rejections.push(RejectionRecord {
666                client_commit_id,
667                op_index: 0,
668                code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
669                retryable: false,
670            });
671        }
672    }
673
674    /// §7.4.3: (re)create the base + visible table pair for every synced
675    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
676    fn create_synced_tables(&self) -> Result<(), String> {
677        for table in &self.schema.tables {
678            // The base half + the visible half form the synced-table pair. An
679            // index name is global in SQLite, so the base half's indexes are
680            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
681            for (full, index_prefix) in [
682                (base_table(&table.name), "_syncular_base_"),
683                (visible_table(&table.name), ""),
684            ] {
685                let mut cols: Vec<String> =
686                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
687                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
688                let sql = format!(
689                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
690                    cols.join(", "),
691                    quote_ident(&table.primary_key)
692                );
693                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
694                // Local secondary indexes (CREATE INDEX subset). Created on
695                // both halves so mirror reads hit an index on either. Runs on
696                // both the initial create and the §7.4.3 reset recreate path.
697                for index in &table.indexes {
698                    let unique = if index.unique { "UNIQUE " } else { "" };
699                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
700                    let cols_sql = index
701                        .columns
702                        .iter()
703                        .map(|c| quote_ident(c))
704                        .collect::<Vec<_>>()
705                        .join(", ");
706                    let index_sql = format!(
707                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
708                    );
709                    self.conn
710                        .execute(&index_sql, [])
711                        .map_err(|e| e.to_string())?;
712                }
713            }
714        }
715        Ok(())
716    }
717
718    // -- persistence write-through --------------------------------------------
719
720    fn persist_sub(&self, sub: &Subscription) {
721        let state = serde_json::json!({
722            "requested": scope_map_to_json(&sub.requested),
723            "params": sub.params,
724            "cursor": sub.cursor,
725            "bootstrapState": sub.bootstrap_state,
726            "status": sub.state.name(),
727            "reasonCode": sub.reason_code,
728            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
729            "syncedOnce": sub.synced_once,
730        });
731        let _ = self.conn.execute(
732            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
733            rusqlite::params![sub.id, sub.table, state.to_string()],
734        );
735    }
736
737    fn persist_outbox_insert(&self, commit: &OutboxCommit) {
738        let ops: Vec<Value> = commit
739            .ops
740            .iter()
741            .map(|op| {
742                serde_json::json!({
743                    "op": if op.upsert { "upsert" } else { "delete" },
744                    "table": op.table,
745                    "rowId": op.row_id,
746                    "baseVersion": op.base_version,
747                    "values": op.values.clone().map(Value::Object),
748                })
749            })
750            .collect();
751        let _ = self.conn.execute(
752            "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
753            rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
754        );
755    }
756
757    fn persist_outbox_delete(&self, client_commit_id: &str) {
758        let _ = self.conn.execute(
759            "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
760            rusqlite::params![client_commit_id],
761        );
762    }
763
764    // -- driver surface ---------------------------------------------------------
765
766    pub fn subscribe(
767        &mut self,
768        id: String,
769        table: String,
770        scopes: Vec<(String, Vec<String>)>,
771        params: Option<String>,
772    ) -> Result<(), String> {
773        if self.schema.table(&table).is_none() {
774            return Err(format!("unknown table {table:?}"));
775        }
776        let sub = Subscription {
777            id: id.clone(),
778            table,
779            requested: scopes,
780            params,
781            cursor: -1,
782            bootstrap_state: None,
783            state: SubState::Active,
784            reason_code: None,
785            effective: None,
786            synced_once: false,
787        };
788        self.persist_sub(&sub);
789        if let Some(existing) = self.subs.iter_mut().find(|s| s.id == id) {
790            *existing = sub;
791        } else {
792            self.subs.push(sub);
793        }
794        Ok(())
795    }
796
797    pub fn unsubscribe(&mut self, id: &str) {
798        self.subs.retain(|s| s.id != id);
799        let _ = self.conn.execute(
800            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
801            rusqlite::params![id],
802        );
803    }
804
805    // -- windowed subscriptions (§4.8) ------------------------------------------
806
807    /// §4.8: set the live window units for a base — a value-sharded family
808    /// of subscriptions, one per unit. Added units get fresh subscriptions
809    /// (image-lane bootstrap on the next sync); removed units are
810    /// unsubscribed and evicted, fused in one local transaction (E1–E4).
811    /// Idempotent; re-entry cancels any deferred eviction.
812    pub fn set_window(&mut self, base: &WindowBase, units: &[String]) -> Result<(), String> {
813        let table = self
814            .schema
815            .table(&base.table)
816            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
817        if table.scope_column(&base.variable).is_none() {
818            return Err(format!(
819                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
820                base.table, base.variable
821            ));
822        }
823        let base_key = window_base_key(base);
824        let wanted: std::collections::HashSet<&String> = units.iter().collect();
825        let live = self.load_window_units(&base_key);
826
827        // Widen: units wanted but not live → fresh subscription + registry row.
828        for unit in units {
829            if live.iter().any(|(u, _)| u == unit) {
830                continue;
831            }
832            let sub_id = derive_sub_id(base, unit);
833            self.delete_pending_evict(&sub_id);
834            self.insert_window_unit(&base_key, unit, &sub_id);
835            self.subscribe(
836                sub_id,
837                base.table.clone(),
838                unit_scopes(base, unit),
839                base.params.clone(),
840            )?;
841        }
842
843        // Shrink: units live but not wanted → unsubscribe fused with eviction.
844        for (unit, sub_id) in live {
845            if wanted.contains(&unit) {
846                continue;
847            }
848            self.evict_unit(&base_key, base, &unit, &sub_id);
849        }
850        Ok(())
851    }
852
853    /// §4.8 completeness oracle (I3): the windowed-in units for a base.
854    pub fn window_state(&self, base: &WindowBase) -> Vec<String> {
855        self.load_window_units(&window_base_key(base))
856            .into_iter()
857            .map(|(unit, _)| unit)
858            .collect()
859    }
860
861    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
862        let mut stmt = match self
863            .conn
864            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
865        {
866            Ok(stmt) => stmt,
867            Err(_) => return Vec::new(),
868        };
869        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
870            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
871        });
872        match rows {
873            Ok(rows) => rows.filter_map(Result::ok).collect(),
874            Err(_) => Vec::new(),
875        }
876    }
877
878    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
879        let _ = self.conn.execute(
880            "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
881            rusqlite::params![base_key, unit, sub_id],
882        );
883    }
884
885    fn delete_window_unit(&self, base_key: &str, unit: &str) {
886        let _ = self.conn.execute(
887            "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
888            rusqlite::params![base_key, unit],
889        );
890    }
891
892    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
893    /// Deletes the unit's rows except those pinned by a pending outbox
894    /// commit (E1); records a deferred eviction if any pin remains; discards
895    /// the subscription's cursor/resume/effective-echo (E3) and version
896    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
897    /// nothing.
898    fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
899        let effective = self
900            .subs
901            .iter()
902            .find(|s| s.id == sub_id)
903            .and_then(|s| s.effective.clone())
904            .unwrap_or_else(|| unit_scopes(base, unit));
905        let pinned = self.pinned_row_ids(&base.table);
906        let deferred = self
907            .evict_scope_rows(&base.table, &effective, &pinned)
908            .unwrap_or(false);
909        self.delete_window_unit(base_key, unit);
910        self.unsubscribe(sub_id);
911        if deferred {
912            self.save_pending_evict(sub_id, &base.table, &effective);
913        } else {
914            self.delete_pending_evict(sub_id);
915        }
916        self.rebuild_overlay();
917    }
918
919    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
920    /// primary keys; returns `Ok(true)` iff a pinned row was left behind (so
921    /// the eviction must be deferred). `Err(())` = fail-closed (no mapping).
922    fn evict_scope_rows(
923        &mut self,
924        table_name: &str,
925        effective: &[(String, Vec<String>)],
926        pinned: &std::collections::HashSet<String>,
927    ) -> Result<bool, ()> {
928        if effective.is_empty() {
929            return Ok(false);
930        }
931        let table = self.schema.table(table_name).ok_or(())?.clone();
932        let mut clauses = Vec::new();
933        let mut params: Vec<SqlValue> = Vec::new();
934        for (variable, values) in effective {
935            let column = table.scope_column(variable).ok_or(())?;
936            let placeholders: Vec<String> = values
937                .iter()
938                .map(|v| {
939                    params.push(SqlValue::Text(v.clone()));
940                    "?".to_owned()
941                })
942                .collect();
943            clauses.push(format!(
944                "{} IN ({})",
945                quote_ident(column),
946                placeholders.join(", ")
947            ));
948        }
949        let mut sql = format!(
950            "DELETE FROM {} WHERE {}",
951            base_table(table_name),
952            clauses.join(" AND ")
953        );
954        if !pinned.is_empty() {
955            let pk = quote_ident(&table.primary_key);
956            let holes: Vec<String> = pinned
957                .iter()
958                .map(|id| {
959                    params.push(SqlValue::Text(id.clone()));
960                    "?".to_owned()
961                })
962                .collect();
963            sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
964        }
965        self.overlay_dirty.set(true);
966        self.conn
967            .execute(&sql, rusqlite::params_from_iter(params))
968            .map_err(|_| ())?;
969        if pinned.is_empty() {
970            return Ok(false);
971        }
972        // A pin defers the eviction only if a pinned row actually falls
973        // inside this unit's effective scopes — re-select the survivors.
974        let mut where_params: Vec<SqlValue> = Vec::new();
975        let mut where_clauses = Vec::new();
976        for (variable, values) in effective {
977            let column = table.scope_column(variable).ok_or(())?;
978            let placeholders: Vec<String> = values
979                .iter()
980                .map(|v| {
981                    where_params.push(SqlValue::Text(v.clone()));
982                    "?".to_owned()
983                })
984                .collect();
985            where_clauses.push(format!(
986                "{} IN ({})",
987                quote_ident(column),
988                placeholders.join(", ")
989            ));
990        }
991        let pk = quote_ident(&table.primary_key);
992        let select = format!(
993            "SELECT {} FROM {} WHERE {}",
994            pk,
995            base_table(table_name),
996            where_clauses.join(" AND ")
997        );
998        let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
999        let survivors: Vec<String> = stmt
1000            .query_map(rusqlite::params_from_iter(where_params), |row| {
1001                row.get::<_, String>(0)
1002            })
1003            .map_err(|_| ())?
1004            .filter_map(Result::ok)
1005            .collect();
1006        Ok(survivors.iter().any(|id| pinned.contains(id)))
1007    }
1008
1009    /// §4.8 E1: retry deferred evictions after the outbox drains. No
1010    /// pending records (the common case) means nothing to retry — and no
1011    /// overlay rebuild.
1012    fn drain_pending_evictions(&mut self) {
1013        let pending = self.load_pending_evictions();
1014        if pending.is_empty() {
1015            return;
1016        }
1017        for (sub_id, table_name, effective) in pending {
1018            if self.schema.table(&table_name).is_none() {
1019                self.delete_pending_evict(&sub_id);
1020                continue;
1021            }
1022            let pinned = self.pinned_row_ids(&table_name);
1023            let deferred = self
1024                .evict_scope_rows(&table_name, &effective, &pinned)
1025                .unwrap_or(false);
1026            if !deferred {
1027                self.delete_pending_evict(&sub_id);
1028            }
1029        }
1030        self.rebuild_overlay_if_dirty();
1031    }
1032
1033    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
1034    /// commit — rows that MUST NOT be evicted until the commit drains.
1035    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
1036        let mut pinned = std::collections::HashSet::new();
1037        for commit in &self.outbox {
1038            for op in &commit.ops {
1039                if op.table == table {
1040                    pinned.insert(op.row_id.clone());
1041                }
1042            }
1043        }
1044        pinned
1045    }
1046
1047    fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
1048        let _ = self.conn.execute(
1049            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
1050               VALUES (?1, ?2, ?3)",
1051            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
1052        );
1053    }
1054
1055    fn delete_pending_evict(&self, sub_id: &str) {
1056        let _ = self.conn.execute(
1057            "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
1058            rusqlite::params![sub_id],
1059        );
1060    }
1061
1062    fn load_pending_evictions(&self) -> Vec<PendingEvict> {
1063        let mut stmt = match self
1064            .conn
1065            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
1066        {
1067            Ok(stmt) => stmt,
1068            Err(_) => return Vec::new(),
1069        };
1070        let rows = stmt.query_map([], |row| {
1071            Ok((
1072                row.get::<_, String>(0)?,
1073                row.get::<_, String>(1)?,
1074                row.get::<_, String>(2)?,
1075            ))
1076        });
1077        let mut out = Vec::new();
1078        if let Ok(rows) = rows {
1079            for entry in rows.filter_map(Result::ok) {
1080                let (sub_id, table, json) = entry;
1081                if let Ok(value) = serde_json::from_str::<Value>(&json) {
1082                    if let Ok(effective) = json_to_scope_map(&value) {
1083                        out.push((sub_id, table, effective));
1084                    }
1085                }
1086            }
1087        }
1088        out
1089    }
1090
1091    /// Record one atomic local commit (§7.1) and apply it optimistically.
1092    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
1093        if mutations.is_empty() {
1094            return Err("a commit must contain at least one operation (§6.1)".to_owned());
1095        }
1096        let mut ops = Vec::with_capacity(mutations.len());
1097        for mutation in mutations {
1098            match mutation {
1099                Mutation::Upsert {
1100                    table,
1101                    values,
1102                    base_version,
1103                } => {
1104                    let schema_table = self
1105                        .schema
1106                        .table(&table)
1107                        .ok_or_else(|| format!("unknown table {table:?}"))?;
1108                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
1109                    // Validate the payload encodes with the current codec
1110                    // (and, §5.11, that the encrypt seam has its keys).
1111                    encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
1112                    ops.push(OutboxOp {
1113                        upsert: true,
1114                        table,
1115                        row_id,
1116                        base_version,
1117                        values: Some(values),
1118                    });
1119                }
1120                Mutation::Delete {
1121                    table,
1122                    row_id,
1123                    base_version,
1124                } => {
1125                    if self.schema.table(&table).is_none() {
1126                        return Err(format!("unknown table {table:?}"));
1127                    }
1128                    ops.push(OutboxOp {
1129                        upsert: false,
1130                        table,
1131                        row_id,
1132                        base_version,
1133                        values: None,
1134                    });
1135                }
1136            }
1137        }
1138        let commit = OutboxCommit {
1139            client_commit_id: uuid::Uuid::new_v4().to_string(),
1140            ops,
1141        };
1142        self.persist_outbox_insert(&commit);
1143        let id = commit.client_commit_id.clone();
1144        self.outbox.push(commit);
1145        self.overlay_dirty.set(true);
1146        self.rebuild_overlay();
1147        Ok(id)
1148    }
1149
1150    pub fn pending_commit_ids(&self) -> Vec<String> {
1151        self.outbox
1152            .iter()
1153            .map(|c| c.client_commit_id.clone())
1154            .collect()
1155    }
1156
1157    pub fn conflicts(&self) -> &[ConflictRecord] {
1158        &self.conflicts
1159    }
1160
1161    pub fn rejections(&self) -> &[RejectionRecord] {
1162        &self.rejections
1163    }
1164
1165    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
1166        self.schema_floor.as_ref()
1167    }
1168
1169    /// §7.3.5: the client's opaque auth-lease state, if any.
1170    pub fn lease_state(&self) -> Option<&LeaseState> {
1171        self.lease_state.as_ref()
1172    }
1173
1174    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
1175    /// the two lease codes set it; other errors leave leaseState untouched.
1176    fn record_lease_error(&mut self, code: &str) {
1177        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
1178            return;
1179        }
1180        let mut next = self.lease_state.clone().unwrap_or_default();
1181        next.error_code = Some(code.to_owned());
1182        self.lease_state = Some(next);
1183    }
1184
1185    pub fn sync_needed(&self) -> bool {
1186        self.sync_needed
1187    }
1188
1189    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
1190        let sub = self.subs.iter().find(|s| s.id == id)?;
1191        Some(SubscriptionStateView {
1192            id: sub.id.clone(),
1193            table: sub.table.clone(),
1194            status: sub.state.name().to_owned(),
1195            cursor: sub.cursor,
1196            has_resume_token: sub.bootstrap_state.is_some(),
1197            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
1198            reason_code: sub.reason_code.clone(),
1199        })
1200    }
1201
1202    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
1203        let schema_table = self
1204            .schema
1205            .table(table)
1206            .ok_or_else(|| format!("unknown table {table:?}"))?;
1207        let sql = format!(
1208            "SELECT * FROM {} ORDER BY {} ASC",
1209            visible_table(table),
1210            quote_ident(&schema_table.primary_key)
1211        );
1212        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
1213        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
1214        let mut out = Vec::new();
1215        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
1216            let mut values = Map::new();
1217            for (i, column) in schema_table.columns.iter().enumerate() {
1218                let value = row.get_ref(i).map_err(|e| e.to_string())?;
1219                values.insert(column.name.clone(), sql_ref_to_json(column, value));
1220            }
1221            let version: i64 = row
1222                .get(schema_table.columns.len())
1223                .map_err(|e| e.to_string())?;
1224            let row_id = match values.get(&schema_table.primary_key) {
1225                Some(Value::String(s)) => s.clone(),
1226                Some(Value::Number(n)) => n.to_string(),
1227                Some(Value::Bool(b)) => b.to_string(),
1228                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
1229            };
1230            out.push(RowState {
1231                row_id,
1232                version,
1233                values,
1234            });
1235        }
1236        Ok(out)
1237    }
1238
1239    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
1240    //
1241    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
1242    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
1243    // the op with `yrs`, re-encodes the whole doc state, and pushes it as a
1244    // baseVersion-less upsert through the ordinary `mutate` path (§5.10.3
1245    // "crdt-only divergence merges cleanly"). No local merge — merging is
1246    // server-side; the overlay's last-write-wins re-materializes the edit
1247    // immediately (optimistic apply, §7.1) and the server-merged bytes arrive
1248    // on the next pull, idempotently. Byte-compatible with `@syncular/crdt-yjs`.
1249
1250    /// The current stored value of a `crdt` column for one row — the visible
1251    /// (optimistic) bytes, or `None` when the row is absent or the column is
1252    /// NULL (the empty document, §5.10.1). Errors if the column is not a
1253    /// `crdt` column (guards the app against a typo'd column name).
1254    #[cfg(feature = "crdt-yjs")]
1255    fn crdt_column_bytes(
1256        &self,
1257        table: &str,
1258        row_id: &str,
1259        column: &str,
1260    ) -> Result<Option<Vec<u8>>, String> {
1261        let schema_table = self
1262            .schema
1263            .table(table)
1264            .ok_or_else(|| format!("unknown table {table:?}"))?;
1265        let col = schema_table
1266            .columns
1267            .iter()
1268            .find(|c| c.name == column)
1269            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
1270        if col.ty != ColumnType::Crdt {
1271            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
1272        }
1273        let sql = format!(
1274            "SELECT {} FROM {} WHERE CAST({} AS TEXT) = ?1",
1275            quote_ident(column),
1276            visible_table(table),
1277            quote_ident(&schema_table.primary_key)
1278        );
1279        let bytes: Option<Vec<u8>> = self
1280            .conn
1281            .query_row(&sql, rusqlite::params![row_id], |row| {
1282                row.get::<_, Option<Vec<u8>>>(0)
1283            })
1284            .map_err(|e| match e {
1285                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
1286                other => other.to_string(),
1287            })?;
1288        Ok(bytes)
1289    }
1290
1291    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
1292    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
1293    /// An absent row / NULL column is the empty document (empty string).
1294    #[cfg(feature = "crdt-yjs")]
1295    pub fn crdt_text(
1296        &self,
1297        table: &str,
1298        row_id: &str,
1299        column: &str,
1300        name: &str,
1301    ) -> Result<String, String> {
1302        let bytes = self
1303            .crdt_column_bytes(table, row_id, column)?
1304            .unwrap_or_default();
1305        crate::crdt::text(&bytes, name)
1306    }
1307
1308    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and push
1309    /// the resulting full-state update through the normal (baseVersion-less)
1310    /// mutate path. Returns the enqueued `clientCommitId`.
1311    #[cfg(feature = "crdt-yjs")]
1312    pub fn crdt_insert_text(
1313        &mut self,
1314        table: &str,
1315        row_id: &str,
1316        column: &str,
1317        name: &str,
1318        index: u32,
1319        value: &str,
1320    ) -> Result<String, String> {
1321        let current = self
1322            .crdt_column_bytes(table, row_id, column)?
1323            .unwrap_or_default();
1324        let update = crate::crdt::insert_text(&current, name, index, value)?;
1325        self.crdt_push_update(table, row_id, column, &update)
1326    }
1327
1328    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and push
1329    /// the resulting full-state update. Returns the enqueued `clientCommitId`.
1330    #[cfg(feature = "crdt-yjs")]
1331    pub fn crdt_delete_text(
1332        &mut self,
1333        table: &str,
1334        row_id: &str,
1335        column: &str,
1336        name: &str,
1337        index: u32,
1338        len: u32,
1339    ) -> Result<String, String> {
1340        let current = self
1341            .crdt_column_bytes(table, row_id, column)?
1342            .unwrap_or_default();
1343        let update = crate::crdt::delete_text(&current, name, index, len)?;
1344        self.crdt_push_update(table, row_id, column, &update)
1345    }
1346
1347    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
1348    /// `crdt` column's current state and push the resulting full state. The
1349    /// app authored the update with its own `yrs` model. Returns the enqueued
1350    /// `clientCommitId`.
1351    #[cfg(feature = "crdt-yjs")]
1352    pub fn crdt_apply_update(
1353        &mut self,
1354        table: &str,
1355        row_id: &str,
1356        column: &str,
1357        update: &[u8],
1358    ) -> Result<String, String> {
1359        let current = self
1360            .crdt_column_bytes(table, row_id, column)?
1361            .unwrap_or_default();
1362        let next = crate::crdt::apply_update(&current, update)?;
1363        self.crdt_push_update(table, row_id, column, &next)
1364    }
1365
1366    /// Shared tail of the crdt edit methods: build the full-row upsert that
1367    /// carries the new crdt bytes and enqueue it. The row's other columns are
1368    /// preserved from the current visible row (so a crdt edit does not clobber
1369    /// the LWW columns); a brand-new row is seeded with just the primary key +
1370    /// crdt column. Pushed baseVersion-less (§5.10.3 crdt-only-divergence rule).
1371    #[cfg(feature = "crdt-yjs")]
1372    fn crdt_push_update(
1373        &mut self,
1374        table: &str,
1375        row_id: &str,
1376        column: &str,
1377        crdt_bytes: &[u8],
1378    ) -> Result<String, String> {
1379        let schema_table = self
1380            .schema
1381            .table(table)
1382            .ok_or_else(|| format!("unknown table {table:?}"))?
1383            .clone();
1384        // The current visible row's values (preserving LWW columns), or a
1385        // fresh row keyed by row_id if it does not exist yet.
1386        let mut values: Map<String, Value> = self
1387            .read_rows(table)?
1388            .into_iter()
1389            .find(|r| r.row_id == row_id)
1390            .map(|r| r.values)
1391            .unwrap_or_else(|| {
1392                let mut map = Map::new();
1393                map.insert(
1394                    schema_table.primary_key.clone(),
1395                    Value::from(row_id.to_owned()),
1396                );
1397                map
1398            });
1399        // Replace the crdt column with the new bytes in the driver envelope.
1400        let mut bytes_obj = Map::new();
1401        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
1402        values.insert(column.to_owned(), Value::Object(bytes_obj));
1403        self.mutate(vec![Mutation::Upsert {
1404            table: table.to_owned(),
1405            values,
1406            base_version: None,
1407        }])
1408    }
1409
1410    /// Run an arbitrary read-only SQL query against the local database and
1411    /// return each row as a `column-name → JSON value` map. This is the seam
1412    /// the React `useSyncQuery` live-query API needs (it takes app-authored
1413    /// SQL over the visible tables/views, not a fixed table read like
1414    /// [`read_rows`]).
1415    ///
1416    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
1417    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
1418    /// same envelope the command surface uses everywhere else. Output BLOB
1419    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
1420    ///
1421    /// The result column typing is dynamic (SQLite's stored affinity), because
1422    /// arbitrary SQL can alias, join, and compute — there is no schema column
1423    /// to consult per output cell, unlike [`read_rows`].
1424    pub fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Map<String, Value>>, String> {
1425        let bound: Vec<SqlValue> = params
1426            .iter()
1427            .map(json_param_to_sql)
1428            .collect::<Result<_, _>>()?;
1429        let mut stmt = self.conn.prepare(sql).map_err(|e| e.to_string())?;
1430        let column_names: Vec<String> =
1431            stmt.column_names().into_iter().map(str::to_owned).collect();
1432        let bound_refs: Vec<&dyn rusqlite::ToSql> =
1433            bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
1434        let mut sql_rows = stmt
1435            .query(bound_refs.as_slice())
1436            .map_err(|e| e.to_string())?;
1437        let mut out = Vec::new();
1438        while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
1439            let mut record = Map::new();
1440            for (i, name) in column_names.iter().enumerate() {
1441                let value = row.get_ref(i).map_err(|e| e.to_string())?;
1442                record.insert(name.clone(), sql_ref_to_json_dynamic(value));
1443            }
1444            out.push(record);
1445        }
1446        Ok(out)
1447    }
1448
1449    // -- request building ---------------------------------------------------------
1450
1451    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
1452        let mut frames = vec![Frame::ReqHeader {
1453            client_id: self.client_id.clone(),
1454            schema_version: self.schema.version,
1455        }];
1456        let mut pushed_ids = Vec::new();
1457        let mut ops_in_request = 0usize;
1458        let mut deferred_commits = 0usize;
1459        for (index, commit) in self.outbox.iter().enumerate() {
1460            // §6.1 splitBatch: stop at the operation cap — commits apply in
1461            // order, so everything from the first non-fitting commit on is
1462            // deferred to the next round. A single over-cap commit still goes
1463            // alone (commits are atomic and cannot be split).
1464            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
1465                deferred_commits = self.outbox.len() - index;
1466                break;
1467            }
1468            ops_in_request += commit.ops.len();
1469            let operations = commit
1470                .ops
1471                .iter()
1472                .map(|op| {
1473                    let payload = op.values.as_ref().and_then(|values| {
1474                        let table = self.schema.table(&op.table)?;
1475                        // §0: outbox entries encode at send time with the
1476                        // current codec (validated at mutate()). §5.11:
1477                        // encrypted columns are encrypted here.
1478                        encode_row_json(table, &op.row_id, values, &self.encryption).ok()
1479                    });
1480                    ssp2::model::Operation {
1481                        table: op.table.clone(),
1482                        row_id: op.row_id.clone(),
1483                        op: if op.upsert { Op::Upsert } else { Op::Delete },
1484                        base_version: op.base_version,
1485                        payload,
1486                    }
1487                })
1488                .collect();
1489            frames.push(Frame::PushCommit {
1490                client_commit_id: commit.client_commit_id.clone(),
1491                operations,
1492            });
1493            pushed_ids.push(commit.client_commit_id.clone());
1494        }
1495        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
1496        // bare URL — capability negotiation, decided per transport.
1497        let accept = self.limits.accept.unwrap_or(if url_capable {
1498            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
1499        } else {
1500            DEFAULT_ACCEPT
1501        });
1502        frames.push(Frame::PullHeader {
1503            limit_commits: self.limits.limit_commits.unwrap_or(0),
1504            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
1505            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
1506            accept,
1507        });
1508        let mut fresh = Vec::new();
1509        for sub in &self.subs {
1510            if sub.state != SubState::Active {
1511                continue;
1512            }
1513            let mut scopes = sub.requested.clone();
1514            sort_scope_map(&mut scopes);
1515            frames.push(Frame::Subscription {
1516                id: sub.id.clone(),
1517                table: sub.table.clone(),
1518                scopes,
1519                params: sub.params.clone().map(RawJson),
1520                cursor: sub.cursor,
1521                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
1522            });
1523            fresh.push((
1524                sub.id.clone(),
1525                sub.cursor < 0 && sub.bootstrap_state.is_none(),
1526            ));
1527        }
1528        let message = Message {
1529            msg_kind: MsgKind::Request,
1530            frames,
1531        };
1532        (
1533            message,
1534            RequestMeta {
1535                pushed_ids,
1536                fresh,
1537                accept,
1538                deferred_commits,
1539            },
1540        )
1541    }
1542
1543    // -- sync -------------------------------------------------------------------
1544
1545    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
1546        if self.stopped {
1547            // §1.6: the client stopped at the schema floor; syncing is inert
1548            // until an upgrade. The outbox is preserved for replay.
1549            return SyncOutcome::Ok(SyncReport {
1550                schema_floor: self.schema_floor.clone(),
1551                ..SyncReport::default()
1552            });
1553        }
1554        // §8.4: the coalesced sync-needed signal clears when a pull round
1555        // BEGINS, so a wake-up landing mid-round survives it.
1556        self.sync_needed = false;
1557        // §5.9.7 B4: upload pending blobs before pushing the referencing
1558        // rows, so the server-side existence check (§6.6) passes.
1559        if self.schema_has_blobs() {
1560            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
1561                return SyncOutcome::Failed {
1562                    error_code: code,
1563                    message,
1564                };
1565            }
1566        }
1567        let (message, meta) = self.build_request(transport.supports_url_fetch());
1568        let request_bytes = encode_message(&message);
1569        // §8.7: rounds ride the socket whenever it is connected (one
1570        // loop, no fallback pair); the transport seam stays bytes-in /
1571        // bytes-out either way. Registration-at-round-end is server-side.
1572        let round = if self.realtime_connected {
1573            transport.realtime_sync(&request_bytes)
1574        } else {
1575            transport.sync(&request_bytes)
1576        };
1577        let response_bytes = match round {
1578            Ok(bytes) => bytes,
1579            Err(TransportError { code, message }) => {
1580                // §7.3.5: a request-level lease code stops-and-surfaces —
1581                // record it in leaseState (no local-data purge, §7.3.4).
1582                self.record_lease_error(&code);
1583                return SyncOutcome::Failed {
1584                    error_code: code,
1585                    message,
1586                };
1587            }
1588        };
1589        let response = match decode_message(&response_bytes) {
1590            Ok(message) => message,
1591            Err(error) => {
1592                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
1593                // responses abort without persisting anything.
1594                return SyncOutcome::Failed {
1595                    error_code: error.code.as_str().to_owned(),
1596                    message: error.detail,
1597                };
1598            }
1599        };
1600        if response.msg_kind != MsgKind::Response {
1601            return SyncOutcome::Failed {
1602                error_code: "sync.invalid_request".to_owned(),
1603                message: "expected a response message".to_owned(),
1604            };
1605        }
1606        let outcome = self.process_response(transport, response, &meta);
1607        if meta.deferred_commits > 0 {
1608            // §6.1 splitBatch: commits past the operation cap wait for the
1609            // next round — keep the host's sync signal raised until then.
1610            self.sync_needed = true;
1611        }
1612        outcome
1613    }
1614
1615    pub fn sync_until_idle(
1616        &mut self,
1617        transport: &mut dyn Transport,
1618        max_rounds: Option<u32>,
1619    ) -> SyncOutcome {
1620        let rounds = max_rounds.unwrap_or(12).max(1);
1621        let mut aggregate = SyncReport::default();
1622        for _ in 0..rounds {
1623            match self.sync(transport) {
1624                SyncOutcome::Failed {
1625                    error_code,
1626                    message,
1627                } => {
1628                    return SyncOutcome::Failed {
1629                        error_code,
1630                        message,
1631                    };
1632                }
1633                SyncOutcome::Ok(report) => {
1634                    aggregate.pushed += report.pushed;
1635                    aggregate.applied.extend(report.applied.iter().cloned());
1636                    aggregate.rejected.extend(report.rejected.iter().cloned());
1637                    aggregate.retryable.extend(report.retryable.iter().cloned());
1638                    aggregate.conflicts += report.conflicts;
1639                    aggregate.commits_applied += report.commits_applied;
1640                    aggregate.segment_rows_applied += report.segment_rows_applied;
1641                    aggregate.bootstrapping = report.bootstrapping.clone();
1642                    aggregate.resets.extend(report.resets.iter().cloned());
1643                    aggregate.revoked.extend(report.revoked.iter().cloned());
1644                    aggregate.failed.extend(report.failed.iter().cloned());
1645                    if report.schema_floor.is_some() {
1646                        aggregate.schema_floor = report.schema_floor.clone();
1647                    }
1648                    // §4.5: pull again whenever the response contained
1649                    // commits or segments; resets re-bootstrap; a pending
1650                    // resume token continues paging (§4.7); a raised
1651                    // sync-needed signal covers §6.1 splitBatch remainders
1652                    // (deferred outbox commits push on the next round).
1653                    let more = !report.bootstrapping.is_empty()
1654                        || report.commits_applied > 0
1655                        || report.segment_rows_applied > 0
1656                        || !report.resets.is_empty()
1657                        || self.sync_needed;
1658                    if !more {
1659                        break;
1660                    }
1661                }
1662            }
1663        }
1664        SyncOutcome::Ok(aggregate)
1665    }
1666
1667    fn process_response(
1668        &mut self,
1669        transport: &mut dyn Transport,
1670        response: Message,
1671        meta: &RequestMeta,
1672    ) -> SyncOutcome {
1673        let mut report = SyncReport {
1674            pushed: meta.pushed_ids.len() as u32,
1675            ..SyncReport::default()
1676        };
1677        let mut frames = response.frames.into_iter();
1678        match frames.next() {
1679            Some(Frame::RespHeader {
1680                required_schema_version,
1681                latest_schema_version,
1682            }) => {
1683                if let Some(required) = required_schema_version {
1684                    // §1.6 schema-floor response: nothing else is processed;
1685                    // stop syncing and surface the upgrade requirement.
1686                    let floor = SchemaFloor {
1687                        required_schema_version: Some(required),
1688                        latest_schema_version,
1689                    };
1690                    self.schema_floor = Some(floor.clone());
1691                    self.stopped = true;
1692                    report.schema_floor = Some(floor);
1693                    return SyncOutcome::Ok(report);
1694                }
1695            }
1696            _ => {
1697                return SyncOutcome::Failed {
1698                    error_code: "sync.invalid_request".to_owned(),
1699                    message: "response does not start with RESP_HEADER".to_owned(),
1700                };
1701            }
1702        }
1703
1704        let mut failure: Option<(String, String)> = None;
1705        while let Some(frame) = frames.next() {
1706            match frame {
1707                Frame::PushResult {
1708                    client_commit_id,
1709                    status,
1710                    commit_seq: _,
1711                    results,
1712                } => {
1713                    self.handle_push_result(&client_commit_id, status, &results, &mut report);
1714                }
1715                Frame::SubStart {
1716                    id,
1717                    status,
1718                    reason_code,
1719                    effective_scopes,
1720                    bootstrap: _,
1721                } => {
1722                    let mut body = Vec::new();
1723                    let mut sub_end: Option<(i64, Option<String>)> = None;
1724                    for inner in frames.by_ref() {
1725                        match inner {
1726                            Frame::SubEnd {
1727                                next_cursor,
1728                                bootstrap_state,
1729                            } => {
1730                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
1731                                break;
1732                            }
1733                            Frame::Unknown { .. } => {}
1734                            other => body.push(other),
1735                        }
1736                    }
1737                    let Some((next_cursor, bootstrap_state)) = sub_end else {
1738                        failure = Some((
1739                            "sync.invalid_request".to_owned(),
1740                            "subscription section without SUB_END".to_owned(),
1741                        ));
1742                        break;
1743                    };
1744                    if let Err(SectionError::Abort(code, message)) = self.process_section(
1745                        transport,
1746                        &id,
1747                        status,
1748                        &reason_code,
1749                        effective_scopes,
1750                        body,
1751                        next_cursor,
1752                        bootstrap_state,
1753                        meta,
1754                        &mut report,
1755                    ) {
1756                        failure = Some((code, message));
1757                        break;
1758                    }
1759                }
1760                Frame::Lease {
1761                    lease_id,
1762                    expires_at_ms,
1763                } => {
1764                    // §7.3.5: persist the opaque lease; a fresh lease clears
1765                    // any prior lease error (the outage/revocation is over).
1766                    self.lease_state = Some(LeaseState {
1767                        lease_id: Some(lease_id),
1768                        expires_at_ms: Some(expires_at_ms),
1769                        error_code: None,
1770                    });
1771                }
1772                Frame::Error { code, message, .. } => {
1773                    // §1.6: the whole request failed; already-completed
1774                    // subscriptions keep their applied data and cursors.
1775                    failure = Some((code, message));
1776                    break;
1777                }
1778                Frame::Unknown { .. } => {}
1779                _ => {
1780                    failure = Some((
1781                        "sync.invalid_request".to_owned(),
1782                        "unexpected frame in response".to_owned(),
1783                    ));
1784                    break;
1785                }
1786            }
1787        }
1788
1789        // §7.1: reconciliation is outbox replay on top — whenever server
1790        // data has been applied, including a round that aborted mid-way.
1791        // Both the rebuild and the refcount pass derive from base + outbox
1792        // state, so a round that changed neither skips them.
1793        if self.overlay_dirty.get() {
1794            self.rebuild_overlay();
1795            // §5.9.7 B1: refcounts follow live rows after every apply (benign
1796            // — zero-ref bodies are retained as LRU entries, not deleted here).
1797            self.reconcile_blob_refcounts(false);
1798        }
1799
1800        if let Some((error_code, message)) = failure {
1801            return SyncOutcome::Failed {
1802                error_code,
1803                message,
1804            };
1805        }
1806        // §4.8 E1: the push half may have drained commits that pinned rows of
1807        // a shrunk window unit — retry any deferred evictions now.
1808        self.drain_pending_evictions();
1809        self.ack_after_pull(transport);
1810        // §7.4.5: the reset is over once the first post-reset pull round
1811        // leaves no subscription mid-bootstrap — the tables are rebuilt.
1812        if self.upgrading && report.bootstrapping.is_empty() {
1813            self.upgrading = false;
1814        }
1815        SyncOutcome::Ok(report)
1816    }
1817
1818    // -- push results (§6.3, §7.2) ------------------------------------------------
1819
1820    fn handle_push_result(
1821        &mut self,
1822        client_commit_id: &str,
1823        status: PushStatus,
1824        results: &[OpResult],
1825        report: &mut SyncReport,
1826    ) {
1827        let Some(index) = self
1828            .outbox
1829            .iter()
1830            .position(|c| c.client_commit_id == client_commit_id)
1831        else {
1832            return;
1833        };
1834        // Every non-retryable outcome below removes the commit from the
1835        // outbox, so the optimistic overlay must be rebuilt.
1836        self.overlay_dirty.set(true);
1837        match status {
1838            PushStatus::Applied | PushStatus::Cached => {
1839                // §7.2: a lost ack replays as `cached` — proceed as if the
1840                // ack had arrived.
1841                report.applied.push(client_commit_id.to_owned());
1842                self.outbox.remove(index);
1843                self.persist_outbox_delete(client_commit_id);
1844            }
1845            PushStatus::Rejected => {
1846                let terminating = results
1847                    .iter()
1848                    .find(|r| !matches!(r, OpResult::Applied { .. }));
1849                match terminating {
1850                    Some(OpResult::Conflict {
1851                        op_index,
1852                        code,
1853                        message: _,
1854                        server_version,
1855                        server_row,
1856                    }) => {
1857                        let commit = &self.outbox[index];
1858                        let (table, row_id) = commit
1859                            .ops
1860                            .get(*op_index as usize)
1861                            .map(|op| (op.table.clone(), op.row_id.clone()))
1862                            .unwrap_or_default();
1863                        let server_row_json = self
1864                            .schema
1865                            .table(&table)
1866                            .and_then(|t| {
1867                                decode_row_bytes(t, server_row, &self.encryption)
1868                                    .ok()
1869                                    .map(|row| (t, row))
1870                            })
1871                            .map(|(t, row)| {
1872                                let mut map = Map::new();
1873                                for (i, column) in t.columns.iter().enumerate() {
1874                                    map.insert(
1875                                        column.name.clone(),
1876                                        column_value_to_json(row.get(i).unwrap_or(&None)),
1877                                    );
1878                                }
1879                                map
1880                            })
1881                            .unwrap_or_default();
1882                        self.conflicts.push(ConflictRecord {
1883                            client_commit_id: client_commit_id.to_owned(),
1884                            op_index: *op_index,
1885                            table,
1886                            row_id,
1887                            code: code.clone(),
1888                            server_version: *server_version,
1889                            server_row: server_row_json,
1890                        });
1891                        report.conflicts += 1;
1892                        report.rejected.push(client_commit_id.to_owned());
1893                        self.outbox.remove(index);
1894                        self.persist_outbox_delete(client_commit_id);
1895                    }
1896                    Some(OpResult::Error {
1897                        op_index,
1898                        code,
1899                        message: _,
1900                        retryable,
1901                    }) => {
1902                        if code == "sync.idempotency_cache_miss" {
1903                            // §6.3/§7.2: a serving failure, not an outcome —
1904                            // the commit stays queued for an identical retry.
1905                            report.retryable.push(client_commit_id.to_owned());
1906                        } else {
1907                            self.rejections.push(RejectionRecord {
1908                                client_commit_id: client_commit_id.to_owned(),
1909                                op_index: *op_index,
1910                                code: code.clone(),
1911                                retryable: *retryable,
1912                            });
1913                            report.rejected.push(client_commit_id.to_owned());
1914                            self.outbox.remove(index);
1915                            self.persist_outbox_delete(client_commit_id);
1916                        }
1917                    }
1918                    _ => {
1919                        report.rejected.push(client_commit_id.to_owned());
1920                        self.outbox.remove(index);
1921                        self.persist_outbox_delete(client_commit_id);
1922                    }
1923                }
1924            }
1925        }
1926    }
1927
1928    // -- subscription sections ------------------------------------------------------
1929
1930    #[allow(clippy::too_many_arguments)]
1931    fn process_section(
1932        &mut self,
1933        transport: &mut dyn Transport,
1934        id: &str,
1935        status: SubStatus,
1936        reason_code: &str,
1937        effective_scopes: Vec<(String, Vec<String>)>,
1938        body: Vec<Frame>,
1939        next_cursor: i64,
1940        bootstrap_state: Option<String>,
1941        meta: &RequestMeta,
1942        report: &mut SyncReport,
1943    ) -> Result<(), SectionError> {
1944        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
1945            return Ok(()); // unknown echo: ignore
1946        };
1947        match status {
1948            SubStatus::Revoked => {
1949                // §3.3: stop pulling, purge exactly the last effective grant.
1950                let (table, effective) = {
1951                    let sub = &self.subs[sub_index];
1952                    (sub.table.clone(), sub.effective.clone().unwrap_or_default())
1953                };
1954                let purged = self.purge_scope_rows(&table, &effective);
1955                let sub = &mut self.subs[sub_index];
1956                match purged {
1957                    Ok(()) => {
1958                        sub.state = SubState::Revoked;
1959                        sub.reason_code = Some(if reason_code.is_empty() {
1960                            "sync.scope_revoked".to_owned()
1961                        } else {
1962                            reason_code.to_owned()
1963                        });
1964                        report.revoked.push(id.to_owned());
1965                        let doomed_effective = effective;
1966                        let sub_table = table;
1967                        self.persist_sub(&self.subs[sub_index].clone());
1968                        self.drop_doomed_outbox(&sub_table, &doomed_effective);
1969                        // §5.9.7 B2: revocation deletes now-unauthorized blob
1970                        // bodies (evicted ≠ revoked).
1971                        self.reconcile_blob_refcounts(true);
1972                    }
1973                    Err(()) => {
1974                        // §3.3 fail closed: no local mapping — never clear by
1975                        // approximation; fatal configuration error.
1976                        sub.state = SubState::Failed;
1977                        sub.reason_code = Some("sync.scope_revoked".to_owned());
1978                        report.failed.push(id.to_owned());
1979                        self.persist_sub(&self.subs[sub_index].clone());
1980                    }
1981                }
1982                Ok(())
1983            }
1984            SubStatus::Reset => {
1985                // §4.6: discard cursor + bootstrap state, keep local rows —
1986                // reset is a staleness signal, not a purge signal.
1987                let sub = &mut self.subs[sub_index];
1988                sub.cursor = -1;
1989                sub.bootstrap_state = None;
1990                report.resets.push(id.to_owned());
1991                self.persist_sub(&self.subs[sub_index].clone());
1992                Ok(())
1993            }
1994            SubStatus::Active => {
1995                let fresh = meta
1996                    .fresh
1997                    .iter()
1998                    .find(|(fid, _)| fid == id)
1999                    .map(|(_, f)| *f)
2000                    .unwrap_or(false);
2001                // §3.3: each active echo replaces the persisted copy.
2002                self.subs[sub_index].effective = Some(effective_scopes);
2003                self.exec("SAVEPOINT syncular_section");
2004                let outcome =
2005                    self.apply_section_body(transport, sub_index, body, fresh, meta, report);
2006                match outcome {
2007                    Ok(()) => {
2008                        self.exec("RELEASE syncular_section");
2009                        let sub = &mut self.subs[sub_index];
2010                        // §1.4: durable cursor/resume state persists only at
2011                        // SUB_END.
2012                        sub.cursor = next_cursor;
2013                        sub.bootstrap_state = bootstrap_state;
2014                        sub.synced_once = true;
2015                        if sub.bootstrap_state.is_some() {
2016                            report.bootstrapping.push(id.to_owned());
2017                        }
2018                        self.persist_sub(&self.subs[sub_index].clone());
2019                        Ok(())
2020                    }
2021                    Err(SectionError::FailClosed) => {
2022                        // §5.6: subscription-local; the rest of the response
2023                        // still applies. SUB_END values are NOT persisted.
2024                        self.exec("ROLLBACK TO syncular_section");
2025                        self.exec("RELEASE syncular_section");
2026                        let sub = &mut self.subs[sub_index];
2027                        sub.state = SubState::Failed;
2028                        sub.reason_code = Some("sync.scope_revoked".to_owned());
2029                        report.failed.push(id.to_owned());
2030                        self.persist_sub(&self.subs[sub_index].clone());
2031                        Ok(())
2032                    }
2033                    Err(SectionError::Abort(code, message)) => {
2034                        // §1.4 rule 5: roll back the open subscription; do
2035                        // not persist its SUB_END values.
2036                        self.exec("ROLLBACK TO syncular_section");
2037                        self.exec("RELEASE syncular_section");
2038                        Err(SectionError::Abort(code, message))
2039                    }
2040                }
2041            }
2042        }
2043    }
2044
2045    fn apply_section_body(
2046        &mut self,
2047        transport: &mut dyn Transport,
2048        sub_index: usize,
2049        body: Vec<Frame>,
2050        fresh: bool,
2051        meta: &RequestMeta,
2052        report: &mut SyncReport,
2053    ) -> Result<(), SectionError> {
2054        let mut saw_segment = false;
2055        for frame in body {
2056            match frame {
2057                Frame::Commit {
2058                    tables, changes, ..
2059                } => {
2060                    self.apply_commit_changes(&tables, &changes)
2061                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
2062                    report.commits_applied += 1;
2063                }
2064                Frame::SegmentInline { payload } => {
2065                    let segment = decode_rows_segment(&payload)
2066                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
2067                    let first = !saw_segment;
2068                    saw_segment = true;
2069                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
2070                    report.segment_rows_applied += applied;
2071                }
2072                Frame::SegmentRef {
2073                    segment_id,
2074                    media_type,
2075                    table,
2076                    row_count,
2077                    as_of_commit_seq,
2078                    scope_digest,
2079                    row_cursor,
2080                    next_row_cursor,
2081                    url,
2082                    url_expires_at_ms,
2083                    ..
2084                } => {
2085                    // §4.2: reject a descriptor whose mediaType was not
2086                    // advertised — never skip or guess.
2087                    let advertised = match media_type {
2088                        MediaType::Rows => {
2089                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
2090                                || meta.accept & ACCEPT_INLINE_ROWS != 0
2091                        }
2092                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
2093                    };
2094                    if !advertised {
2095                        return Err(SectionError::Abort(
2096                            "sync.invalid_request".to_owned(),
2097                            format!(
2098                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
2099                                media_type.name()
2100                            ),
2101                        ));
2102                    }
2103                    let bytes = if let Some(url) = url {
2104                        // §5.4: a url-carrying descriptor MUST be fetched
2105                        // from that URL; failure invalidates the whole
2106                        // descriptor (no fall-through to §5.5 — re-pull
2107                        // recovers, §1.4 rule 5).
2108                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
2109                            return Err(SectionError::Abort(
2110                                "sync.invalid_request".to_owned(),
2111                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
2112                                    .to_owned(),
2113                            ));
2114                        }
2115                        // §5.4: MUST NOT start a fetch at/past expiry.
2116                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
2117                            return Err(SectionError::Abort(
2118                                "sync.segment_expired".to_owned(),
2119                                format!(
2120                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
2121                                ),
2122                            ));
2123                        }
2124                        transport
2125                            .fetch_url(&url)
2126                            .map_err(|e| SectionError::Abort(e.code, e.message))?
2127                    } else {
2128                        let requested_scopes_json =
2129                            canonical_scope_json(&self.subs[sub_index].requested);
2130                        transport
2131                            .download_segment(&SegmentRequest {
2132                                segment_id: segment_id.clone(),
2133                                table,
2134                                requested_scopes_json,
2135                            })
2136                            .map_err(|e| SectionError::Abort(e.code, e.message))?
2137                    };
2138                    // §5.1: verify the content address before applying.
2139                    let digest = Sha256::digest(&bytes);
2140                    let expected = segment_id
2141                        .strip_prefix("sha256:")
2142                        .unwrap_or(segment_id.as_str());
2143                    if bytes_to_hex(&digest) != expected {
2144                        return Err(SectionError::Abort(
2145                            "sync.invalid_request".to_owned(),
2146                            "segment bytes do not match the content address (§5.1)".to_owned(),
2147                        ));
2148                    }
2149                    if media_type == MediaType::Sqlite {
2150                        // §5.3: images are whole-table — a paged sqlite
2151                        // descriptor is invalid.
2152                        if row_cursor.is_some() || next_row_cursor.is_some() {
2153                            return Err(SectionError::Abort(
2154                                "sync.invalid_request".to_owned(),
2155                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
2156                                    .to_owned(),
2157                            ));
2158                        }
2159                        let first = !saw_segment;
2160                        saw_segment = true;
2161                        let applied = self.apply_sqlite_segment(
2162                            sub_index,
2163                            &bytes,
2164                            fresh && first,
2165                            row_count,
2166                            as_of_commit_seq,
2167                            &scope_digest,
2168                        )?;
2169                        report.segment_rows_applied += applied;
2170                    } else {
2171                        let segment = decode_rows_segment(&bytes).map_err(|e| {
2172                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
2173                        })?;
2174                        let first = row_cursor.is_none();
2175                        saw_segment = true;
2176                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
2177                        report.segment_rows_applied += applied;
2178                    }
2179                }
2180                Frame::Unknown { .. } => {}
2181                _ => {
2182                    return Err(SectionError::Abort(
2183                        "sync.invalid_request".to_owned(),
2184                        "unexpected frame inside a subscription section".to_owned(),
2185                    ));
2186                }
2187            }
2188        }
2189        Ok(())
2190    }
2191
2192    fn apply_commit_changes(
2193        &mut self,
2194        tables: &[String],
2195        changes: &[ssp2::model::Change],
2196    ) -> Result<(), (String, String)> {
2197        for change in changes {
2198            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
2199                (
2200                    "sync.invalid_request".to_owned(),
2201                    "change tableIndex out of range".to_owned(),
2202                )
2203            })?;
2204            let table = self.schema.table(table_name).ok_or_else(|| {
2205                (
2206                    "sync.schema_mismatch".to_owned(),
2207                    format!("change targets unknown table {table_name:?}"),
2208                )
2209            })?;
2210            match change.op {
2211                Op::Upsert => {
2212                    let payload = change.row.as_ref().ok_or_else(|| {
2213                        (
2214                            "sync.invalid_request".to_owned(),
2215                            "upsert change without row payload".to_owned(),
2216                        )
2217                    })?;
2218                    // §5.11: decrypt encrypted columns on apply.
2219                    let row = decode_row_bytes(table, payload, &self.encryption)
2220                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2221                    let version = change.row_version.unwrap_or(0);
2222                    let table_name = table.name.clone();
2223                    self.write_base_row(&table_name, &row, version)
2224                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2225                }
2226                Op::Delete => {
2227                    self.delete_base_row(table_name, &change.row_id)
2228                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2229                }
2230            }
2231        }
2232        Ok(())
2233    }
2234
2235    /// §5.6 segment application: validate against the generated schema,
2236    /// clear the grant on a fresh bootstrap's first page (fail closed
2237    /// without a mapping), then replace-or-upsert each row with its
2238    /// segment-carried server version (§5.2).
2239    fn apply_segment(
2240        &mut self,
2241        sub_index: usize,
2242        segment: &RowsSegment,
2243        first_fresh_page: bool,
2244    ) -> Result<u32, SectionError> {
2245        let (sub_table, effective) = {
2246            let sub = &self.subs[sub_index];
2247            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
2248        };
2249        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
2250            SectionError::Abort(
2251                "sync.schema_mismatch".to_owned(),
2252                format!("subscription table {sub_table:?} is not in the client schema"),
2253            )
2254        })?;
2255        // §5.2: the column table validates against the generated schema —
2256        // order, names, types, nullability; mismatch is fatal. §5.11: the
2257        // server sends the WIRE types (bytes for an encrypted column), so
2258        // validate against wire_columns.
2259        let matches = segment.table == table.name
2260            && segment.schema_version == self.schema.version
2261            && segment.columns.len() == table.wire_columns.len()
2262            && segment
2263                .columns
2264                .iter()
2265                .zip(table.wire_columns.iter())
2266                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
2267        if !matches {
2268            return Err(SectionError::Abort(
2269                "sync.schema_mismatch".to_owned(),
2270                "segment column table does not match the generated schema (§5.2)".to_owned(),
2271            ));
2272        }
2273        if first_fresh_page {
2274            // §5.6: delete local rows for the subscription's scope so
2275            // removed rows don't survive re-bootstrap; fail closed at the
2276            // clear too.
2277            self.purge_scope_rows(&table.name, &effective)
2278                .map_err(|()| SectionError::FailClosed)?;
2279        }
2280        let mut applied = 0u32;
2281        for block in &segment.blocks {
2282            for row in block {
2283                // §5.11: a bootstrap segment carries ciphertext for encrypted
2284                // columns; decrypt to plaintext before the local write. A
2285                // plaintext table writes the decoded row directly (no per-row
2286                // clone on the hot bootstrap path).
2287                let decrypted;
2288                let values = if table.has_encrypted_columns() {
2289                    let mut values = row.values.clone();
2290                    crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
2291                        .map_err(|m| SectionError::Abort("client.decrypt_failed".to_owned(), m))?;
2292                    decrypted = values;
2293                    &decrypted
2294                } else {
2295                    &row.values
2296                };
2297                // §5.6: the row record's serverVersion is the row's
2298                // last-known server_version, same as a COMMIT rowVersion.
2299                self.write_base_row(&table.name, values, row.server_version)
2300                    .map_err(|m| SectionError::Abort("sync.invalid_request".to_owned(), m))?;
2301                applied += 1;
2302            }
2303        }
2304        Ok(applied)
2305    }
2306
2307    /// §5.3 sqlite-image application: validate the in-file metadata
2308    /// against the descriptor, validate column names/order against the
2309    /// generated schema, run the §5.6 first-page clear when fresh, then
2310    /// replace-or-upsert every image row with its `_syncular_version`.
2311    /// Mechanics: the image lands in a temp file read through a second
2312    /// rusqlite connection (semantics identical to ATTACH + INSERT…SELECT;
2313    /// ATTACH is unavailable inside the open section savepoint).
2314    fn apply_sqlite_segment(
2315        &mut self,
2316        sub_index: usize,
2317        bytes: &[u8],
2318        first_fresh_page: bool,
2319        row_count: i64,
2320        as_of_commit_seq: i64,
2321        scope_digest: &str,
2322    ) -> Result<u32, SectionError> {
2323        let invalid = |detail: &str| {
2324            SectionError::Abort(
2325                "sync.invalid_request".to_owned(),
2326                format!("sqlite segment rejected: {detail} (§5.3)"),
2327            )
2328        };
2329        let (sub_table, effective) = {
2330            let sub = &self.subs[sub_index];
2331            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
2332        };
2333        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
2334            SectionError::Abort(
2335                "sync.schema_mismatch".to_owned(),
2336                format!("subscription table {sub_table:?} is not in the client schema"),
2337            )
2338        })?;
2339
2340        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
2341        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
2342        let img = match rusqlite::Connection::open_with_flags(
2343            &path,
2344            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
2345        ) {
2346            Ok(conn) => conn,
2347            Err(_) => {
2348                let _ = std::fs::remove_file(&path);
2349                return Err(invalid("bytes do not open as a SQLite database"));
2350            }
2351        };
2352        let outcome = self.apply_sqlite_image(
2353            &img,
2354            &table,
2355            first_fresh_page,
2356            &effective,
2357            row_count,
2358            as_of_commit_seq,
2359            scope_digest,
2360        );
2361        drop(img);
2362        let _ = std::fs::remove_file(&path);
2363        outcome
2364    }
2365
2366    #[allow(clippy::too_many_arguments)]
2367    fn apply_sqlite_image(
2368        &mut self,
2369        img: &rusqlite::Connection,
2370        table: &crate::schema::TableSchema,
2371        first_fresh_page: bool,
2372        effective: &[(String, Vec<String>)],
2373        row_count: i64,
2374        as_of_commit_seq: i64,
2375        scope_digest: &str,
2376    ) -> Result<u32, SectionError> {
2377        let invalid = |detail: String| {
2378            SectionError::Abort(
2379                "sync.invalid_request".to_owned(),
2380                format!("sqlite segment rejected: {detail} (§5.3)"),
2381            )
2382        };
2383
2384        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
2385        //    one row).
2386        type MetaRow = (i64, String, i64, i64, String, i64, i64);
2387        let meta: MetaRow = img
2388            .query_row(
2389                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
2390                        \"scopeDigest\", \"rowCount\",
2391                        (SELECT count(*) FROM _syncular_segment)
2392                 FROM _syncular_segment",
2393                [],
2394                |row| {
2395                    Ok((
2396                        row.get(0)?,
2397                        row.get(1)?,
2398                        row.get(2)?,
2399                        row.get(3)?,
2400                        row.get(4)?,
2401                        row.get(5)?,
2402                        row.get(6)?,
2403                    ))
2404                },
2405            )
2406            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
2407        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
2408        if meta_count != 1 {
2409            return Err(invalid(format!(
2410                "_syncular_segment must contain exactly one row, found {meta_count}"
2411            )));
2412        }
2413        if format != 1 {
2414            return Err(invalid(format!("format {format}")));
2415        }
2416        if meta_table != table.name {
2417            return Err(invalid(format!("image table {meta_table:?}")));
2418        }
2419        if schema_version != i64::from(self.schema.version) {
2420            return Err(invalid(format!("schemaVersion {schema_version}")));
2421        }
2422        if pin != as_of_commit_seq {
2423            return Err(invalid(format!("asOfCommitSeq {pin}")));
2424        }
2425        if digest != scope_digest {
2426            return Err(invalid("scopeDigest mismatch".to_owned()));
2427        }
2428        if meta_rows != row_count {
2429            return Err(invalid(format!("rowCount {meta_rows}")));
2430        }
2431
2432        // 2. Column names and order vs the generated schema (§5.3 rule 3).
2433        let mut names: Vec<String> = Vec::new();
2434        {
2435            let mut stmt = img
2436                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
2437                .map_err(|_| invalid("image data table missing".to_owned()))?;
2438            let mut rows = stmt
2439                .query([])
2440                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2441            while let Some(row) = rows
2442                .next()
2443                .map_err(|_| invalid("image data table unreadable".to_owned()))?
2444            {
2445                names.push(
2446                    row.get::<_, String>(1)
2447                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
2448                );
2449            }
2450        }
2451        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
2452        expected.push("_syncular_version");
2453        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
2454            return Err(SectionError::Abort(
2455                "sync.schema_mismatch".to_owned(),
2456                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
2457            ));
2458        }
2459
2460        // 3. §5.6 first-page clear (fail closed without a mapping), then
2461        //    replace-or-upsert with the image-carried server versions.
2462        if first_fresh_page {
2463            self.purge_scope_rows(&table.name, effective)
2464                .map_err(|()| SectionError::FailClosed)?;
2465        }
2466        // One cached INSERT statement on our side, one SELECT cursor on the
2467        // image side; every cell is validated against the declared column
2468        // type and bound BORROWED (no per-cell allocation, no per-row
2469        // statement re-preparation) — the Rust analogue of the TS client's
2470        // single `INSERT OR REPLACE … SELECT` bulk copy.
2471        self.overlay_dirty.set(true);
2472        // A fresh whole-table load pays secondary-index maintenance per row;
2473        // dropping the base half's NON-unique indexes for the load and
2474        // recreating them after replaces that with one bulk sort per index.
2475        // Unique indexes stay in place — `INSERT OR REPLACE` clobbers
2476        // through them, so they are semantics, not just speed. The DDL rides
2477        // the open section savepoint (§1.4): an abort rolls the drop back.
2478        let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
2479            table.indexes.iter().filter(|i| !i.unique).collect()
2480        } else {
2481            Vec::new()
2482        };
2483        for index in &bulk_indexes {
2484            let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
2485            self.conn
2486                .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
2487                .map_err(|e| invalid(e.to_string()))?;
2488        }
2489        let insert = self.insert_row_sql(&base_table(&table.name), table);
2490        let applied = {
2491            let mut ins = self
2492                .conn
2493                .prepare_cached(&insert)
2494                .map_err(|e| invalid(e.to_string()))?;
2495            let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
2496            let mut stmt = img
2497                .prepare(&format!(
2498                    "SELECT {} FROM {}",
2499                    column_list.join(", "),
2500                    quote_ident(&table.name)
2501                ))
2502                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2503            let mut rows = stmt
2504                .query([])
2505                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2506            let version_index = table.columns.len();
2507            let mut applied = 0u32;
2508            while let Some(row) = rows
2509                .next()
2510                .map_err(|_| invalid("image row unreadable".to_owned()))?
2511            {
2512                for (i, column) in table.columns.iter().enumerate() {
2513                    let cell = row
2514                        .get_ref(i)
2515                        .map_err(|_| invalid("image row unreadable".to_owned()))?;
2516                    let param = image_cell_param(column, cell).map_err(&invalid)?;
2517                    ins.raw_bind_parameter(i + 1, param)
2518                        .map_err(|e| invalid(e.to_string()))?;
2519                }
2520                let version: i64 = row
2521                    .get(version_index)
2522                    .map_err(|_| invalid("image row unreadable".to_owned()))?;
2523                if version < 1 {
2524                    return Err(invalid(format!(
2525                        "row _syncular_version must be >= 1, got {version}"
2526                    )));
2527                }
2528                ins.raw_bind_parameter(version_index + 1, version)
2529                    .map_err(|e| invalid(e.to_string()))?;
2530                ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
2531                applied += 1;
2532            }
2533            applied
2534        };
2535        for index in &bulk_indexes {
2536            let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
2537            let cols_sql = index
2538                .columns
2539                .iter()
2540                .map(|c| quote_ident(c))
2541                .collect::<Vec<_>>()
2542                .join(", ");
2543            self.conn
2544                .execute(
2545                    &format!(
2546                        "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
2547                        base_table(&table.name)
2548                    ),
2549                    [],
2550                )
2551                .map_err(|e| invalid(e.to_string()))?;
2552        }
2553        if i64::from(applied) != row_count {
2554            return Err(invalid(format!(
2555                "image holds {applied} rows, descriptor says {row_count}"
2556            )));
2557        }
2558        Ok(applied)
2559    }
2560
2561    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
2562
2563    /// Delete base rows matching the effective scopes; `Err(())` = no local
2564    /// scope-column mapping for a key (the fail-closed case).
2565    fn purge_scope_rows(
2566        &mut self,
2567        table_name: &str,
2568        effective: &[(String, Vec<String>)],
2569    ) -> Result<(), ()> {
2570        if effective.is_empty() {
2571            return Ok(());
2572        }
2573        let table = self.schema.table(table_name).ok_or(())?.clone();
2574        let mut clauses = Vec::new();
2575        let mut params: Vec<SqlValue> = Vec::new();
2576        for (variable, values) in effective {
2577            let column = table.scope_column(variable).ok_or(())?;
2578            let placeholders: Vec<String> = values
2579                .iter()
2580                .map(|v| {
2581                    params.push(SqlValue::Text(v.clone()));
2582                    "?".to_owned()
2583                })
2584                .collect();
2585            clauses.push(format!(
2586                "{} IN ({})",
2587                quote_ident(column),
2588                placeholders.join(", ")
2589            ));
2590        }
2591        let sql = format!(
2592            "DELETE FROM {} WHERE {}",
2593            base_table(table_name),
2594            clauses.join(" AND ")
2595        );
2596        self.overlay_dirty.set(true);
2597        self.conn
2598            .execute(&sql, rusqlite::params_from_iter(params))
2599            .map_err(|_| ())?;
2600        Ok(())
2601    }
2602
2603    /// §3.3: drop pending commits whose upserts provably land in the
2604    /// revoked effective scopes — whole-commit, never per-operation.
2605    fn drop_doomed_outbox(&mut self, table_name: &str, effective: &[(String, Vec<String>)]) {
2606        if effective.is_empty() {
2607            return;
2608        }
2609        let Some(table) = self.schema.table(table_name).cloned() else {
2610            return;
2611        };
2612        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
2613        for (variable, values) in effective {
2614            match table.scope_column(variable) {
2615                Some(column) => mappings.push((column, values)),
2616                None => return, // not provable without a mapping
2617            }
2618        }
2619        let doomed: Vec<String> = self
2620            .outbox
2621            .iter()
2622            .filter(|commit| {
2623                commit.ops.iter().any(|op| {
2624                    op.upsert
2625                        && op.table == table_name
2626                        && op.values.as_ref().is_some_and(|values| {
2627                            mappings
2628                                .iter()
2629                                .all(|(column, allowed)| match values.get(*column) {
2630                                    Some(Value::String(s)) => allowed.contains(s),
2631                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
2632                                    _ => false,
2633                                })
2634                        })
2635                })
2636            })
2637            .map(|c| c.client_commit_id.clone())
2638            .collect();
2639        for id in doomed {
2640            self.overlay_dirty.set(true);
2641            self.outbox.retain(|c| c.client_commit_id != id);
2642            self.persist_outbox_delete(&id);
2643        }
2644    }
2645
2646    // -- blobs (§5.9) ----------------------------------------------------------------
2647
2648    /// §5.9.7: hash bytes into the content address, cache them, queue the
2649    /// upload (flushed before the next push, B4). Returns the canonical
2650    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
2651    /// column value.
2652    pub fn upload_blob(
2653        &mut self,
2654        bytes: &[u8],
2655        media_type: Option<String>,
2656        name: Option<String>,
2657    ) -> Result<Value, String> {
2658        let blob_id = blob_id_for(bytes);
2659        let now = self.clock_now_ms();
2660        self.conn
2661            .execute(
2662                "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
2663                 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
2664                rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
2665            )
2666            .map_err(|e| e.to_string())?;
2667        self.conn
2668            .execute(
2669                "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
2670                rusqlite::params![blob_id, media_type, now],
2671            )
2672            .map_err(|e| e.to_string())?;
2673        // §5.9.7 B1: a staged upload is pinned (in _syncular_blob_uploads), so
2674        // the trim never evicts it; other zero-ref bodies may be over the cap.
2675        self.enforce_blob_cache_cap();
2676        let mut obj = Map::new();
2677        obj.insert("blobId".to_owned(), Value::from(blob_id));
2678        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
2679        if let Some(mt) = media_type {
2680            obj.insert("mediaType".to_owned(), Value::from(mt));
2681        }
2682        if let Some(n) = name {
2683            obj.insert("name".to_owned(), Value::from(n));
2684        }
2685        Ok(Value::Object(obj))
2686    }
2687
2688    /// §5.9.7: resolve blob bytes — a content-addressed cache hit serves
2689    /// with no fetch (B1); a miss downloads (§5.9.5), verifies the address,
2690    /// caches, and returns `{blobId, byteLength, bytes:{$bytes:hex}}`.
2691    pub fn fetch_blob(
2692        &mut self,
2693        transport: &mut dyn Transport,
2694        blob_id_or_ref: &str,
2695    ) -> Result<Value, (String, String)> {
2696        let simple = |m: String| ("client.failed".to_owned(), m);
2697        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
2698            blob_id_or_ref.to_owned()
2699        } else {
2700            let value: Value = serde_json::from_str(blob_id_or_ref)
2701                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
2702            value
2703                .get("blobId")
2704                .and_then(Value::as_str)
2705                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
2706                .to_owned()
2707        };
2708        if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
2709            return Ok(cached);
2710        }
2711        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
2712        // blob.not_found) verbatim so the harness can assert on it. The
2713        // authorized endpoint serves bytes inline OR (always-issue, presign
2714        // configured) a signed url the client fetches directly — no host auth,
2715        // no fall-through: failure => re-request (the caller's next fetch_blob).
2716        let bytes = match transport
2717            .blob_download(&blob_id)
2718            .map_err(|e| (e.code, e.message))?
2719        {
2720            BlobDownload::Bytes(bytes) => bytes,
2721            BlobDownload::Url {
2722                url,
2723                url_expires_at_ms,
2724            } => {
2725                // §5.9.5: MUST NOT start a fetch at/past expiry.
2726                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
2727                    return Err((
2728                        "sync.segment_expired".to_owned(),
2729                        format!(
2730                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
2731                        ),
2732                    ));
2733                }
2734                transport
2735                    .fetch_blob_url(&url)
2736                    .map_err(|e| (e.code, e.message))?
2737            }
2738        };
2739        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
2740        if blob_id_for(&bytes) != blob_id {
2741            return Err(simple(format!(
2742                "blob content address mismatch for {blob_id}"
2743            )));
2744        }
2745        let now = self.clock_now_ms();
2746        self.conn
2747            .execute(
2748                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
2749                rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
2750            )
2751            .map_err(|e| simple(e.to_string()))?;
2752        self.enforce_blob_cache_cap();
2753        self.get_cached_blob(&blob_id)
2754            .map_err(simple)?
2755            .ok_or_else(|| simple("blob cache write failed".to_owned()))
2756    }
2757
2758    fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
2759        // §5.9.7 B1 LRU: a cache-hit read touches "recently used" so a hot
2760        // image survives a cap trim.
2761        let _ = self.conn.execute(
2762            "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
2763            rusqlite::params![self.clock_now_ms(), blob_id],
2764        );
2765        let mut stmt = self
2766            .conn
2767            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
2768            .map_err(|e| e.to_string())?;
2769        let mut rows = stmt
2770            .query(rusqlite::params![blob_id])
2771            .map_err(|e| e.to_string())?;
2772        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
2773            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
2774            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
2775            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
2776            let mut obj = Map::new();
2777            obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
2778            obj.insert("byteLength".to_owned(), Value::from(byte_length));
2779            let mut bytes_obj = Map::new();
2780            bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(&bytes)));
2781            obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
2782            if let Some(mt) = media_type {
2783                obj.insert("mediaType".to_owned(), Value::from(mt));
2784            }
2785            return Ok(Some(Value::Object(obj)));
2786        }
2787        Ok(None)
2788    }
2789
2790    /// §5.9.7 B4: upload every queued blob before push.
2791    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
2792        let pending: Vec<(String, Option<String>)> = {
2793            let mut stmt = self
2794                .conn
2795                .prepare(
2796                    "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
2797                )
2798                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
2799            let rows = stmt
2800                .query_map([], |row| {
2801                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
2802                })
2803                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
2804            rows.filter_map(Result::ok).collect()
2805        };
2806        for (blob_id, media_type) in pending {
2807            let bytes: Option<Vec<u8>> = self
2808                .conn
2809                .query_row(
2810                    "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
2811                    rusqlite::params![blob_id],
2812                    |row| row.get(0),
2813                )
2814                .ok();
2815            if let Some(bytes) = bytes {
2816                self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
2817            }
2818            let _ = self.conn.execute(
2819                "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
2820                rusqlite::params![blob_id],
2821            );
2822        }
2823        Ok(())
2824    }
2825
2826    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
2827    /// grant when the transport supports it, else streaming through the direct
2828    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
2829    /// PUTs direct with no host auth; on a grant PUT failure the client streams
2830    /// through the direct endpoint — a *different, host-authenticated
2831    /// capability*, not a fall-through of the grant's authority.
2832    fn upload_one(
2833        &self,
2834        transport: &mut dyn Transport,
2835        blob_id: &str,
2836        bytes: &[u8],
2837        media_type: Option<&str>,
2838    ) -> Result<(), TransportError> {
2839        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
2840            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
2841            BlobUploadGrant::Url {
2842                url,
2843                url_expires_at_ms,
2844            } => {
2845                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
2846                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
2847                    return Ok(());
2848                }
2849                // Failed/expired grant PUT — stream through the direct endpoint.
2850            }
2851            BlobUploadGrant::None => {
2852                // No presign store — stream through the direct endpoint.
2853            }
2854        }
2855        transport.blob_upload(blob_id, bytes, media_type)
2856    }
2857
2858    /// §5.9.7 B1 size cap + LRU eviction: when the sum of cached body sizes
2859    /// exceeds `blob_cache_max_bytes`, evict zero-ref, non-pinned bodies in
2860    /// least-recently-used order until back under the cap. NEVER evicts a
2861    /// referenced body (refcount > 0) nor a pending-upload-pinned body — if all
2862    /// over-cap bodies are referenced or pinned, the cache stays over the cap
2863    /// (correctness beats the cap). B3 re-enables the fetch for any evicted
2864    /// zero-ref body, so eviction only costs a future re-download. No-op if the
2865    /// cap is unset.
2866    fn enforce_blob_cache_cap(&self) {
2867        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
2868            return;
2869        };
2870        let mut total: i64 = self
2871            .conn
2872            .query_row(
2873                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
2874                [],
2875                |row| row.get(0),
2876            )
2877            .unwrap_or(0);
2878        if total <= max_bytes {
2879            return;
2880        }
2881        let candidates: Vec<(String, i64)> = {
2882            let Ok(mut stmt) = self.conn.prepare(
2883                "SELECT blob_id, byte_length FROM _syncular_blobs
2884                 WHERE refcount = 0
2885                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
2886                 ORDER BY last_used_ms ASC, created_at_ms ASC",
2887            ) else {
2888                return;
2889            };
2890            let Ok(rows) = stmt.query_map([], |row| {
2891                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2892            }) else {
2893                return;
2894            };
2895            rows.filter_map(Result::ok).collect()
2896        };
2897        for (blob_id, byte_length) in candidates {
2898            if total <= max_bytes {
2899                break;
2900            }
2901            let _ = self.conn.execute(
2902                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
2903                rusqlite::params![blob_id],
2904            );
2905            total -= byte_length;
2906        }
2907    }
2908
2909    /// §5.9.7 B1/B2: recompute cache refcounts from live `blob_ref` columns
2910    /// in the BASE tables; `delete_orphans` deletes zero-ref bodies not
2911    /// pinned by a pending upload (the revocation side, B2).
2912    fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
2913        if !self.schema_has_blobs() {
2914            return;
2915        }
2916        let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
2917        for table in self.schema.tables.clone() {
2918            let blob_cols: Vec<String> = table
2919                .columns
2920                .iter()
2921                .filter(|c| c.ty == ColumnType::BlobRef)
2922                .map(|c| c.name.clone())
2923                .collect();
2924            for column in blob_cols {
2925                let sql = format!(
2926                    "SELECT {} FROM {} WHERE {} IS NOT NULL",
2927                    quote_ident(&column),
2928                    base_table(&table.name),
2929                    quote_ident(&column)
2930                );
2931                let Ok(mut stmt) = self.conn.prepare(&sql) else {
2932                    continue;
2933                };
2934                let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
2935                    continue;
2936                };
2937                for raw in rows.flatten().flatten() {
2938                    if let Ok(value) = serde_json::from_str::<Value>(&raw) {
2939                        if let Some(id) = value.get("blobId").and_then(Value::as_str) {
2940                            *counts.entry(id.to_owned()).or_insert(0) += 1;
2941                        }
2942                    }
2943                }
2944            }
2945        }
2946        let _ = self
2947            .conn
2948            .execute("UPDATE _syncular_blobs SET refcount = 0", []);
2949        for (blob_id, count) in &counts {
2950            let _ = self.conn.execute(
2951                "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
2952                rusqlite::params![count, blob_id],
2953            );
2954        }
2955        if delete_orphans {
2956            let _ = self.conn.execute(
2957                "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
2958                [],
2959            );
2960        }
2961    }
2962
2963    // -- local row storage ----------------------------------------------------------
2964
2965    /// The cached per-table `INSERT OR REPLACE` SQL for `full_table` (see
2966    /// the `insert_sql` field: built once, reused per row).
2967    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
2968        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
2969            return sql.clone();
2970        }
2971        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
2972        columns.push(quote_ident("_syncular_version"));
2973        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
2974        let sql = format!(
2975            "INSERT OR REPLACE INTO {full_table} ({}) VALUES ({})",
2976            columns.join(", "),
2977            placeholders.join(", ")
2978        );
2979        self.insert_sql
2980            .borrow_mut()
2981            .insert(full_table.to_owned(), sql.clone());
2982        sql
2983    }
2984
2985    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
2986        self.overlay_dirty.set(true);
2987        self.write_row(&base_table(table_name), table_name, row, version)
2988    }
2989
2990    fn write_row(
2991        &self,
2992        full_table: &str,
2993        table_name: &str,
2994        row: &Row,
2995        version: i64,
2996    ) -> Result<(), String> {
2997        let table = self
2998            .schema
2999            .table(table_name)
3000            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
3001        let sql = self.insert_row_sql(full_table, table);
3002        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
3003        let params = row
3004            .iter()
3005            .map(RowParam::Cell)
3006            .chain(std::iter::once(RowParam::Version(version)));
3007        stmt.execute(rusqlite::params_from_iter(params))
3008            .map_err(|e| e.to_string())?;
3009        Ok(())
3010    }
3011
3012    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
3013        let table = self
3014            .schema
3015            .table(table_name)
3016            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
3017        self.overlay_dirty.set(true);
3018        let sql = format!(
3019            "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
3020            base_table(table_name),
3021            quote_ident(&table.primary_key)
3022        );
3023        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
3024        stmt.execute(rusqlite::params![row_id])
3025            .map_err(|e| e.to_string())?;
3026        Ok(())
3027    }
3028
3029    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
3030    /// the outbox changed since the last rebuild (the no-op sync round).
3031    fn rebuild_overlay_if_dirty(&mut self) {
3032        if self.overlay_dirty.get() {
3033            self.rebuild_overlay();
3034        }
3035    }
3036
3037    /// §7.1: local reads see outbox state applied optimistically — rebuild
3038    /// every visible table as (base server state) + (pending outbox replay
3039    /// on top). Optimistic rows carry version `-1`.
3040    fn rebuild_overlay(&mut self) {
3041        self.exec("SAVEPOINT syncular_overlay");
3042        for table in self.schema.tables.clone() {
3043            let visible = visible_table(&table.name);
3044            let base = base_table(&table.name);
3045            self.exec(&format!("DELETE FROM {visible}"));
3046            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
3047        }
3048        for commit in self.outbox.clone() {
3049            for op in &commit.ops {
3050                let Some(table) = self.schema.table(&op.table).cloned() else {
3051                    continue;
3052                };
3053                if op.upsert {
3054                    let Some(values) = op.values.as_ref() else {
3055                        continue;
3056                    };
3057                    let mut row: Row = Vec::with_capacity(table.columns.len());
3058                    let mut ok = true;
3059                    for column in &table.columns {
3060                        match json_to_column_value(column, values.get(&column.name)) {
3061                            Ok(v) => row.push(v),
3062                            Err(_) => {
3063                                ok = false;
3064                                break;
3065                            }
3066                        }
3067                    }
3068                    if ok {
3069                        let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
3070                    }
3071                } else {
3072                    let sql = format!(
3073                        "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
3074                        visible_table(&table.name),
3075                        quote_ident(&table.primary_key)
3076                    );
3077                    let _ = self.conn.execute(&sql, rusqlite::params![op.row_id]);
3078                }
3079            }
3080        }
3081        self.exec("RELEASE syncular_overlay");
3082        self.overlay_dirty.set(false);
3083    }
3084
3085    fn exec(&self, sql: &str) {
3086        let _ = self.conn.execute_batch(sql);
3087    }
3088
3089    // -- realtime (§8) ---------------------------------------------------------------
3090
3091    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
3092        transport
3093            .realtime_connect()
3094            .map_err(|e| format!("{}: {}", e.code, e.message))?;
3095        self.realtime_connected = true;
3096        Ok(())
3097    }
3098
3099    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
3100        let _ = transport.realtime_close();
3101        self.realtime_connected = false;
3102        self.presence.clear(); // §8.6.1: presence is per-connection
3103    }
3104
3105    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
3106    /// document for `scope_key`. Requires a live socket; the document is
3107    /// ephemeral (lost on disconnect). Authorization is the connection's
3108    /// registration (§8.6.3) — an unheld key is rejected loudly by the
3109    /// server with `presence.forbidden`.
3110    pub fn set_presence(
3111        &mut self,
3112        transport: &mut dyn Transport,
3113        scope_key: &str,
3114        doc: Option<&Value>,
3115    ) -> Result<(), String> {
3116        if !self.realtime_connected {
3117            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
3118        }
3119        let text = encode_presence_publish(scope_key, doc);
3120        transport
3121            .realtime_send(&text)
3122            .map_err(|e| format!("{}: {}", e.code, e.message))
3123    }
3124
3125    /// §8.6: the peers currently present on a scope key (ephemeral).
3126    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
3127        self.presence
3128            .get(scope_key)
3129            .map(|peers| peers.values().cloned().collect())
3130            .unwrap_or_default()
3131    }
3132
3133    /// §8.6 apply an inbound presence fanout to the local map.
3134    fn apply_presence(
3135        &mut self,
3136        scope_key: String,
3137        kind: Option<PresenceKind>,
3138        actor_id: Option<String>,
3139        client_id: Option<String>,
3140        doc: Option<Value>,
3141        error: Option<String>,
3142    ) {
3143        // The publisher-directed error variant is out-of-band; nothing to
3144        // record in the peer map.
3145        if error.is_some() {
3146            return;
3147        }
3148        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
3149            return;
3150        };
3151        let peer_key = format!("{actor_id} {client_id}");
3152        match kind {
3153            PresenceKind::Leave => {
3154                if let Some(peers) = self.presence.get_mut(&scope_key) {
3155                    peers.remove(&peer_key);
3156                    if peers.is_empty() {
3157                        self.presence.remove(&scope_key);
3158                    }
3159                }
3160            }
3161            _ => {
3162                let doc = match doc {
3163                    Some(Value::Object(_)) => doc.unwrap(),
3164                    _ => return,
3165                };
3166                self.presence.entry(scope_key).or_default().insert(
3167                    peer_key,
3168                    PresencePeer {
3169                        actor_id,
3170                        client_id,
3171                        doc,
3172                    },
3173                );
3174            }
3175        }
3176    }
3177
3178    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
3179    pub fn on_realtime_text(&mut self, text: &str) {
3180        match parse_control(text) {
3181            Ok(ControlMessage::Hello { requires_sync, .. }) => {
3182                if requires_sync {
3183                    // §8.1: pull before trusting the socket for continuity.
3184                    self.sync_needed = true;
3185                }
3186            }
3187            Ok(ControlMessage::Presence {
3188                scope_key,
3189                kind,
3190                actor_id,
3191                client_id,
3192                doc,
3193                error,
3194                ..
3195            }) => {
3196                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
3197            }
3198            Ok(ControlMessage::Wake { .. }) => {
3199                // §8.3: any wake-up means "run a pull soon", never data.
3200                self.sync_needed = true;
3201            }
3202            _ => {}
3203        }
3204    }
3205
3206    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
3207    /// a pull response per section; an unapplied delta is a wake-up.
3208    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
3209        if self.stopped {
3210            return;
3211        }
3212        let message = match decode_message(bytes) {
3213            Ok(m) if m.msg_kind == MsgKind::Response => m,
3214            _ => {
3215                self.sync_needed = true;
3216                return;
3217            }
3218        };
3219        let mut frames = message.frames.into_iter();
3220        let mut applied_cursor: Option<i64> = None;
3221        let mut any_covered = false;
3222        let mut dropped = false;
3223        while let Some(frame) = frames.next() {
3224            let Frame::SubStart {
3225                id,
3226                status,
3227                effective_scopes,
3228                ..
3229            } = frame
3230            else {
3231                continue;
3232            };
3233            let mut body = Vec::new();
3234            let mut next_cursor: Option<i64> = None;
3235            for inner in frames.by_ref() {
3236                match inner {
3237                    Frame::SubEnd {
3238                        next_cursor: nc, ..
3239                    } => {
3240                        next_cursor = Some(nc);
3241                        break;
3242                    }
3243                    Frame::Unknown { .. } => {}
3244                    other => body.push(other),
3245                }
3246            }
3247            let Some(next_cursor) = next_cursor else {
3248                dropped = true;
3249                break;
3250            };
3251            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
3252                dropped = true;
3253                continue;
3254            };
3255            let sub = &self.subs[sub_index];
3256            // §8.2: only locally active, not mid-bootstrap subscriptions
3257            // apply; skipped sections are repaired by the next pull.
3258            if status != SubStatus::Active
3259                || sub.state != SubState::Active
3260                || sub.bootstrap_state.is_some()
3261                || !sub.synced_once
3262            {
3263                dropped = true;
3264                continue;
3265            }
3266            if next_cursor <= sub.cursor {
3267                // Idempotent redelivery of an already-covered window.
3268                any_covered = true;
3269                continue;
3270            }
3271            self.subs[sub_index].effective = Some(effective_scopes);
3272            self.exec("SAVEPOINT syncular_delta");
3273            let mut failed = false;
3274            for inner in body {
3275                if let Frame::Commit {
3276                    tables, changes, ..
3277                } = inner
3278                {
3279                    if self.apply_commit_changes(&tables, &changes).is_err() {
3280                        failed = true;
3281                        break;
3282                    }
3283                }
3284            }
3285            if failed {
3286                self.exec("ROLLBACK TO syncular_delta");
3287                self.exec("RELEASE syncular_delta");
3288                dropped = true;
3289                continue;
3290            }
3291            self.exec("RELEASE syncular_delta");
3292            let sub = &mut self.subs[sub_index];
3293            sub.cursor = next_cursor;
3294            self.persist_sub(&self.subs[sub_index].clone());
3295            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
3296        }
3297        if let Some(cursor) = applied_cursor {
3298            self.rebuild_overlay();
3299            self.reconcile_blob_refcounts(false);
3300            // §8.2 ack point: the highest applied SUB_END.nextCursor.
3301            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
3302            let _ = transport.realtime_send(&ack);
3303        } else if !any_covered || dropped {
3304            // §8.2: a delta not applied at all is treated as a wake-up.
3305            self.sync_needed = true;
3306        }
3307    }
3308
3309    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
3310    /// cursor across active, non-bootstrapping subscriptions that have
3311    /// synced at least once. No such subscription, no ack.
3312    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
3313        if !self.realtime_connected {
3314            return;
3315        }
3316        let floor = self
3317            .subs
3318            .iter()
3319            .filter(|s| {
3320                s.state == SubState::Active
3321                    && s.bootstrap_state.is_none()
3322                    && s.synced_once
3323                    && s.cursor >= 0
3324            })
3325            .map(|s| s.cursor)
3326            .min();
3327        if let Some(cursor) = floor {
3328            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
3329            let _ = transport.realtime_send(&ack);
3330        }
3331    }
3332}