Skip to main content

syncular_client/
client.rs

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