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