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