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