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