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 crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12use std::borrow::Cow;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17use std::sync::Arc;
18
19use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
20use rusqlite::{Connection, OpenFlags, OptionalExtension};
21use serde::{Deserialize, Serialize};
22use serde_json::{json, Map, Value};
23use sha2::{Digest, Sha256};
24use ssp2::decode::WIRE_VERSION;
25use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
26use ssp2::primitives::RawJson;
27use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
28use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
29
30use crate::api::{
31    AppliedServerCommit, AppliedServerCommitSource, ClientChangeBatch, ClientDiagnosticsHost,
32    ClientDiagnosticsLease, ClientDiagnosticsReplica, ClientDiagnosticsRequest,
33    ClientDiagnosticsSchema, ClientDiagnosticsSnapshot, ClientDiagnosticsStorage, ClientError,
34    ClientLimits, CommandEffects, CommitDelivery, CommitOperation, CommitOperationOutcome,
35    CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution, CommitOutcomeStatus,
36    ConflictRecord, CoverageSnapshot, CurrentBaseProof, CurrentBaseProofReason,
37    DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
38    DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
39    LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
40    PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
41    QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
42    RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
43    SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
44    SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
45    WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
46    MAX_DIAGNOSTIC_QUERY_FAILURES,
47};
48use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
49#[cfg(feature = "bench-internals")]
50use crate::bench::{Phase, Recorder};
51use crate::previous_version::{
52    capture_previous_version_from_replica, first_incompatibility,
53    reconcile_previous_version_at_boot, set_local_schema_descriptor,
54    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
55    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
56};
57use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
58use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
59use crate::values::{
60    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
61    encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
62    json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
63    sort_scope_map,
64};
65
66/// §4.2 default: rows and SQLite images, both applied in committed chunks.
67/// Bit 3 (signed URLs, §5.4) is
68/// added per transport capability at request-build time.
69const DEFAULT_ACCEPT: u8 = 0b0111;
70const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
71const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
72const ACCEPT_SQLITE: u8 = 1 << 2;
73const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
74const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
75
76/// §10.1 `recommendedAction` carried by a server HTTP error body.
77fn recommended_action_of(details: &Option<Value>) -> Option<String> {
78    details
79        .as_ref()?
80        .get("recommendedAction")?
81        .as_str()
82        .map(str::to_owned)
83}
84
85/// §2.2 scope migration: one commit carries the delete under the old scope and
86/// the upsert under the new one. A reader that holds both scopes receives the
87/// two changes in separate subscription sections, so section order would
88/// otherwise decide whether the row survives. Collect the `(commitSeq, table,
89/// rowId)` keys whose delete must be skipped because the same commit delivers
90/// the row's upsert in another applied section. A section that carries both
91/// changes itself keeps its own operation order.
92fn coalesced_delete_keys(
93    frames: &[Frame],
94    mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
95) -> HashSet<(i64, String, String)> {
96    let mut sections = Vec::new();
97    let mut open: Option<(usize, String, SubStatus)> = None;
98    for (index, frame) in frames.iter().enumerate() {
99        match frame {
100            Frame::SubStart { id, status, .. } => {
101                open = Some((index, id.clone(), *status));
102            }
103            Frame::SubEnd { next_cursor, .. } => {
104                if let Some((start, id, status)) = open.take() {
105                    sections.push((start, index, id, status, Some(*next_cursor)));
106                }
107            }
108            _ => {}
109        }
110    }
111    type Key = (i64, String, String);
112    let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
113    let mut both_in_frame: HashSet<Key> = HashSet::new();
114    let mut frame_ordinal = 0usize;
115    for (start, end, id, status, next_cursor) in sections {
116        if !applies(&id, status, next_cursor) {
117            continue;
118        }
119        for frame in &frames[start + 1..end] {
120            let Frame::Commit {
121                commit_seq,
122                tables,
123                changes,
124                ..
125            } = frame
126            else {
127                continue;
128            };
129            frame_ordinal += 1;
130            let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
131            for change in changes {
132                let Some(table) = tables.get(change.table_index as usize) else {
133                    continue;
134                };
135                let key = (*commit_seq, table.clone(), change.row_id.clone());
136                let entry = ops.entry(key).or_default();
137                let local_entry = local
138                    .entry((table.clone(), change.row_id.clone()))
139                    .or_default();
140                match change.op {
141                    Op::Upsert => {
142                        entry.0.insert(frame_ordinal);
143                        local_entry.0 = true;
144                    }
145                    Op::Delete => {
146                        entry.1.insert(frame_ordinal);
147                        local_entry.1 = true;
148                    }
149                }
150            }
151            for ((table, row_id), (has_upsert, has_delete)) in local {
152                if has_upsert && has_delete {
153                    both_in_frame.insert((*commit_seq, table, row_id));
154                }
155            }
156        }
157    }
158    ops.into_iter()
159        .filter_map(|(key, (upserts, deletes))| {
160            (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
161                .then_some(key)
162        })
163        .collect()
164}
165
166/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
167pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
168const LOCAL_REVISION_KEY: &str = "localRevision";
169const CLIENT_ID_KEY: &str = "clientId";
170const LEASE_STATE_KEY: &str = "leaseState";
171const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
172pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
173/// Persisted fail-closed quarantine marker: present while a security preflight
174/// is pending and `activateSecurity` has yet to run. Storing it in the replica
175/// keeps the gate with the data it protects, so a rebuilt host handle reopening
176/// the same file returns in preflight even though its in-memory state is fresh.
177const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
178const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
179const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
180/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
181/// the new schema after a bump. Never a wire code (§10.3).
182const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
183/// §5.11 client-local code: an encode at the push seam resolved no usable key
184/// id or named an unknown key. Never a wire code (§10.3).
185const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
186const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
187/// §7.4.4: static text for a persisted commit the current codec refuses.
188const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
189    "the persisted commit carries values the current codec refuses";
190/// §7.4.4: bounded reason on every intrinsic value drop.
191const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
192
193#[derive(Debug, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase", deny_unknown_fields)]
195struct PersistedLocalDataRebootstrapReceipt {
196    version: u8,
197    retained_commits: u64,
198    reset_subscriptions: u64,
199}
200
201fn invalid_local_rebootstrap_receipt() -> String {
202    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
203}
204
205fn encode_local_rebootstrap_receipt(
206    retained_commits: usize,
207    reset_subscriptions: usize,
208) -> Result<String, String> {
209    let retained_commits =
210        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
211    let reset_subscriptions =
212        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
213    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
214        return Err(invalid_local_rebootstrap_receipt());
215    }
216    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
217        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
218        retained_commits,
219        reset_subscriptions,
220    })
221    .map_err(|_| invalid_local_rebootstrap_receipt())
222}
223
224fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
225    // Pre-0.15.36 markers proved application but did not retain the receipt.
226    if value == "v1" {
227        return Ok((0, 0));
228    }
229    let receipt: PersistedLocalDataRebootstrapReceipt =
230        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
231    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
232        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
233        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
234    {
235        return Err(invalid_local_rebootstrap_receipt());
236    }
237    Ok((
238        usize::try_from(receipt.retained_commits)
239            .map_err(|_| invalid_local_rebootstrap_receipt())?,
240        usize::try_from(receipt.reset_subscriptions)
241            .map_err(|_| invalid_local_rebootstrap_receipt())?,
242    ))
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246enum SubState {
247    Active,
248    Revoked,
249    Failed,
250}
251
252#[cfg(test)]
253mod observation_tests;
254
255impl SubState {
256    fn name(self) -> &'static str {
257        match self {
258            SubState::Active => "active",
259            SubState::Revoked => "revoked",
260            SubState::Failed => "failed",
261        }
262    }
263
264    fn parse(value: &str) -> Self {
265        match value {
266            "revoked" => Self::Revoked,
267            "failed" => Self::Failed,
268            _ => Self::Active,
269        }
270    }
271}
272
273#[derive(Debug, Clone)]
274struct Subscription {
275    id: String,
276    table: String,
277    requested: Vec<(String, Vec<String>)>,
278    params: Option<String>,
279    cursor: i64,
280    /// §4.7 resume token, round-tripped opaquely.
281    bootstrap_state: Option<String>,
282    state: SubState,
283    reason_code: Option<String>,
284    /// Last effective scopes echoed while active (§3.3: persisted for the
285    /// purge contract; each active echo replaces it).
286    effective: Option<Vec<(String, Vec<String>)>>,
287    synced_once: bool,
288}
289
290impl Subscription {
291    /// §8.2: a delta section applies only to a locally active subscription
292    /// that holds a cursor and no resume token. A subscription at cursor -1
293    /// (never synced, reset, or rebootstrapped) bootstraps on its own pull.
294    fn takes_delta(&self) -> bool {
295        self.state == SubState::Active
296            && self.cursor >= 0
297            && self.bootstrap_state.is_none()
298            && self.synced_once
299    }
300}
301
302/// Internal state for hosts that perform network I/O outside the client owner.
303#[doc(hidden)]
304pub struct CurrentBasePreparation {
305    client_commit_id: String,
306    sequence: i64,
307    subscriptions: Vec<Subscription>,
308    transport_epoch: u64,
309    subscription_epoch: u64,
310    security_epoch: u64,
311}
312
313#[cfg(test)]
314mod row_ownership_tests;
315
316#[cfg(test)]
317mod subscription_rearm_tests;
318
319#[derive(Debug, Clone)]
320struct OutboxOp {
321    upsert: bool,
322    table: String,
323    row_id: String,
324    base_version: Option<i64>,
325    /// Schema-agnostic local form (§0): driver JSON values, encoded with
326    /// the current codec at send time. A key's presence IS the §6.1 sparse
327    /// presence set — both the overlay and the wire payload derive from it.
328    values: Option<Map<String, Value>>,
329    /// §7.1 insert intent: `mutate` recorded this upsert while the row was
330    /// absent from the visible replica. Replay and send-time encoding present
331    /// every current-schema column; a column `values` lacks is NULL.
332    insert: bool,
333}
334
335impl OutboxOp {
336    /// The values this upsert presents under the current `table` (§7.1). An
337    /// insert-intent operation presents every column, a missing one as NULL;
338    /// `None` when a missing column is non-nullable, which leaves the commit
339    /// unencodable (§7.4.4), or when the operation carries no values.
340    fn presented_values(&self, table: &TableSchema) -> Option<Cow<'_, Map<String, Value>>> {
341        let values = self.values.as_ref()?;
342        if !self.insert || table.columns.iter().all(|c| values.contains_key(&c.name)) {
343            return Some(Cow::Borrowed(values));
344        }
345        full_row_values(table, values.clone()).ok().map(Cow::Owned)
346    }
347}
348
349impl From<&OutboxOp> for CommitOperation {
350    fn from(operation: &OutboxOp) -> Self {
351        Self {
352            table: operation.table.clone(),
353            row_id: operation.row_id.clone(),
354            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
355            base_version: operation.base_version,
356            values: operation.values.clone(),
357            insert: operation.insert,
358        }
359    }
360}
361
362#[derive(Debug, Clone)]
363struct OutboxCommit {
364    client_commit_id: String,
365    ops: Vec<OutboxOp>,
366}
367
368/// One commit's `(table, value keys, insert intent)` per upsert operation — the classification
369/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
370/// can never disagree about which commits cannot re-encode.
371fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>, bool)> {
372    commit
373        .ops
374        .iter()
375        .filter(|operation| operation.upsert)
376        .map(|operation| {
377            (
378                operation.table.as_str(),
379                operation
380                    .values
381                    .as_ref()
382                    .map(|values| values.keys().map(String::as_str).collect())
383                    .unwrap_or_default(),
384                operation.insert,
385            )
386        })
387        .collect()
388}
389
390type FailedCommit = (OutboxCommit, Map<String, Value>);
391
392#[derive(Debug, Clone)]
393struct CompiledLocalDataPurgeTarget {
394    table: String,
395    selectors: Vec<(String, Vec<String>)>,
396}
397
398struct StoredCommitOutcomeRow {
399    sequence: i64,
400    client_commit_id: String,
401    status: String,
402    recorded_at_ms: i64,
403    results_json: String,
404    operations_json: Option<String>,
405    resolution: String,
406    resolved_at_ms: Option<i64>,
407    replacement_client_commit_id: Option<String>,
408}
409
410impl StoredCommitOutcomeRow {
411    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
412        Ok(Self {
413            sequence: row.get(0)?,
414            client_commit_id: row.get(1)?,
415            status: row.get(2)?,
416            recorded_at_ms: row.get(3)?,
417            results_json: row.get(4)?,
418            operations_json: row.get(5)?,
419            resolution: row.get(6)?,
420            resolved_at_ms: row.get(7)?,
421            replacement_client_commit_id: row.get(8)?,
422        })
423    }
424}
425
426/// Section outcome distinguishing the §5.6 subscription-local fail-closed
427/// path from a round-aborting failure (§1.4 rule 5).
428enum SectionError {
429    FailClosed,
430    Abort(String, String),
431    Transport(TransportError),
432}
433
434#[derive(Debug)]
435pub(crate) struct RequestMeta {
436    pushed_ids: Vec<String>,
437    /// Subscription id → the request carried `cursor < 0` and no resume
438    /// token (§5.6 first-page detection: a *fresh* bootstrap).
439    fresh: Vec<(String, bool)>,
440    pub(crate) accept: u8,
441    /// §6.1 splitBatch: outbox commits held back from THIS request because
442    /// the running operation count reached the push cap — the next round
443    /// pushes them (`sync_needed` stays set while any remain).
444    deferred_commits: usize,
445}
446
447#[derive(Default)]
448struct ChangeAccumulator {
449    /// None means table-wide; Some is the exact scoped domain.
450    tables: BTreeMap<String, Option<BTreeSet<String>>>,
451    windows: BTreeMap<(String, String), BTreeSet<String>>,
452    status: bool,
453    conflicts: bool,
454    rejections: bool,
455    outcomes: bool,
456}
457
458impl ChangeAccumulator {
459    fn table(&mut self, table: &str) {
460        self.tables.insert(table.to_owned(), None);
461    }
462
463    fn scope(&mut self, table: &str, key: String) {
464        match self.tables.get_mut(table) {
465            Some(None) => {}
466            Some(Some(keys)) => {
467                keys.insert(key);
468            }
469            None => {
470                self.tables
471                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
472            }
473        }
474    }
475
476    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
477        self.windows
478            .entry((base_key.to_owned(), table.to_owned()))
479            .or_default()
480            .insert(unit.to_owned());
481    }
482
483    fn touched(&self) -> bool {
484        !self.tables.is_empty()
485            || !self.windows.is_empty()
486            || self.status
487            || self.conflicts
488            || self.rejections
489            || self.outcomes
490    }
491}
492
493/// §6.1: the server caps total operations per request (reference default
494/// 500) and rejects the whole batch with `sync.too_many_operations`; the
495/// client "splits and retries". Splitting happens at build time: commits are
496/// included IN ORDER until the operation budget is spent, the rest wait for
497/// the next round.
498const PUSH_OPS_PER_REQUEST: usize = 500;
499const MAX_LOCAL_PURGE_TARGETS: usize = 64;
500const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
501const MAX_LOCAL_PURGE_VALUES: usize = 128;
502const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
503pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
504
505/// Dirty visible tables; `None` means a whole-replica recovery/reset.
506struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
507
508impl OverlayDirty {
509    fn clean() -> Self {
510        Self(RefCell::new(Some(BTreeSet::new())))
511    }
512    fn get(&self) -> bool {
513        self.0
514            .borrow()
515            .as_ref()
516            .is_none_or(|tables| !tables.is_empty())
517    }
518    fn set(&self, dirty: bool) {
519        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
520    }
521    fn table(&self, table: &str) {
522        if let Some(tables) = self.0.borrow_mut().as_mut() {
523            tables.insert(table.to_owned());
524        }
525    }
526    fn snapshot(&self) -> Option<BTreeSet<String>> {
527        self.0.borrow().clone()
528    }
529    fn restore(&self, tables: Option<BTreeSet<String>>) {
530        *self.0.borrow_mut() = tables;
531    }
532}
533
534/// A copy-on-write in-memory mirror: a [`Rollback`] shares it, and the first
535/// write after a capture copies it once.
536#[derive(Debug, Clone, Default)]
537struct Shared<T>(Arc<T>);
538
539impl<T> std::ops::Deref for Shared<T> {
540    type Target = T;
541    fn deref(&self) -> &T {
542        &self.0
543    }
544}
545
546impl<T: Clone> std::ops::DerefMut for Shared<T> {
547    fn deref_mut(&mut self) -> &mut T {
548        Arc::make_mut(&mut self.0)
549    }
550}
551
552impl<T> From<T> for Shared<T> {
553    fn from(value: T) -> Self {
554        Self(Arc::new(value))
555    }
556}
557
558/// The in-memory mirrors of durable state, captured before a local
559/// transaction and restored when it rolls back. Queues only grow inside a
560/// transaction, so their lengths suffice.
561#[derive(Debug, Clone)]
562struct Rollback {
563    subs: Shared<Vec<Subscription>>,
564    outbox: Shared<Vec<Arc<OutboxCommit>>>,
565    failed_commits: Shared<Vec<FailedCommit>>,
566    conflicts: Shared<Vec<ConflictRecord>>,
567    rejections: Shared<Vec<RejectionRecord>>,
568    schema_floor: Option<SchemaFloor>,
569    lease_state: Option<LeaseState>,
570    stopped: bool,
571    upgrading: bool,
572    sync_needed: bool,
573    active_round: Option<uuid::Uuid>,
574    overlay_dirty: Option<BTreeSet<String>>,
575    change_queue: usize,
576    sync_intent_queue: usize,
577    last_change: Option<DiagnosticLastChange>,
578}
579
580pub struct SyncClient {
581    #[cfg(feature = "bench-internals")]
582    benchmark_phases: Recorder,
583    conn: Connection,
584    schema: ClientSchema,
585    client_id: String,
586    limits: ClientLimits,
587    subs: Shared<Vec<Subscription>>,
588    outbox: Shared<Vec<Arc<OutboxCommit>>>,
589    failed_commits: Shared<Vec<FailedCommit>>,
590    retain_failed_commits: bool,
591    conflicts: Shared<Vec<ConflictRecord>>,
592    rejections: Shared<Vec<RejectionRecord>>,
593    schema_floor: Option<SchemaFloor>,
594    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
595    lease_state: Option<LeaseState>,
596    /// §1.6: the schema-floor response stops syncing until an upgrade.
597    stopped: bool,
598    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
599    upgrading: bool,
600    /// §8.4 coalesced sync-needed signal.
601    sync_needed: bool,
602    /// §8.8: how sync rounds treat the realtime binding, and its exact
603    /// availability state with the evidence behind a lost or refused one.
604    realtime_policy: RealtimePolicy,
605    realtime_state: RealtimeState,
606    realtime_reason_code: Option<String>,
607    realtime_retry_delay_ms: Option<u64>,
608    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
609    presence: HashMap<String, HashMap<String, PresencePeer>>,
610    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
611    /// host may pin it (conformance runs on a virtual clock).
612    now_ms: Option<i64>,
613    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
614    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
615    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
616    encryption: crate::values::EncryptionConfig,
617    /// Fail-closed host bootstrap gate. While set, command hosts permit only
618    /// status/lifecycle inspection and an exact authorized local purge.
619    security_preflight: bool,
620    security_epoch: u64,
621    transport_enabled: bool,
622    transport_epoch: u64,
623    subscription_epoch: u64,
624    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
625    /// means the feature is off; the descriptor write and the container orphan
626    /// sweep still happen.
627    previous_version: Option<PreviousVersionContextConfig>,
628    /// Per-table primary-key upsert SQL, built once per (full table name) —
629    /// the row write path runs per row during bootstrap (§5.6), so the SQL
630    /// string (and, via `prepare_cached`, its compiled statement) is reused
631    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
632    /// schema reset (the column lists may have changed).
633    insert_sql: RefCell<HashMap<String, String>>,
634    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
635    /// reset and restart explicitly invalidate every table.
636    overlay_dirty: OverlayDirty,
637    // First SQLite failure of the current operation, before savepoint cleanup.
638    storage_failure: RefCell<Option<QueryReadFailure>>,
639    /// Test-only structural performance signal: response processing must not
640    /// turn a batch of acknowledgements into one full overlay rebuild each.
641    #[cfg(test)]
642    overlay_rebuild_count: Cell<usize>,
643    #[cfg(test)]
644    acknowledged_replay_count: Cell<usize>,
645    /// Test-only structural performance signal: outcome retention is enforced
646    /// once per response rather than once per acknowledgement.
647    #[cfg(test)]
648    outcome_prune_count: Cell<usize>,
649    /// Exact observer-transaction output drained by command/FFI hosts.
650    progress: ProgressObserver,
651    change_queue: VecDeque<ClientChangeBatch>,
652    sync_intent_queue: VecDeque<SyncIntent>,
653    /// Explicit exponential retry policy for transient transport failures.
654    retry_delay_ms: u64,
655    /// §7.6: the background retry the current round scheduled, if any.
656    round_retry_delay_ms: Option<u64>,
657    /// A round whose response never completed (§1.5): the client keeps its
658    /// code and reports it on every following request until a round receives a
659    /// complete, decodable response. The server already recorded the round, so
660    /// nothing else can tell it the round failed on this side.
661    round_failure: Option<String>,
662    active_round: Option<uuid::Uuid>,
663    last_round: Option<DiagnosticLastRound>,
664    last_change: Option<DiagnosticLastChange>,
665    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
666    query_failures: Vec<DiagnosticQueryFailure>,
667    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
668    coalesced: Option<CoalescedApply>,
669    /// A coalesced apply transaction of the current response failed; every
670    /// later observation of that response fails until it ends.
671    coalesced_failure: Option<String>,
672}
673
674/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
675/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
676/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
677struct CoalescedApply {
678    rows: usize,
679    rollback: Rollback,
680}
681
682const COALESCED_APPLY_ROWS: usize = 1024;
683
684pub(crate) fn quote_ident(name: &str) -> String {
685    format!("\"{}\"", name.replace('"', "\"\""))
686}
687/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
688/// previous-version module reads and writes the SAME records as the client
689/// instead of growing a parallel meta layer.
690pub(crate) fn meta_get(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
691    conn.query_row(
692        "SELECT value FROM _syncular_meta WHERE key = ?1",
693        rusqlite::params![key],
694        |row| row.get::<_, String>(0),
695    )
696    .optional()
697}
698
699pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
700    conn.execute(
701        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
702        rusqlite::params![key, value],
703    )
704    .map(|_| ())
705}
706
707pub(crate) fn meta_delete(conn: &Connection, key: &str) -> rusqlite::Result<()> {
708    conn.execute(
709        "DELETE FROM _syncular_meta WHERE key = ?1",
710        rusqlite::params![key],
711    )
712    .map(|_| ())
713}
714
715/// Seek by the existing key while retaining the exact legacy text match.
716fn row_id_predicate(table: &TableSchema) -> String {
717    let key = quote_ident(&table.primary_key);
718    let text_match = format!("CAST({key} AS TEXT) = ?1");
719    match table.columns[table.pk_index].ty {
720        ColumnType::String | ColumnType::Json => {
721            format!("{key} = ?1 AND {text_match}")
722        }
723        // Native columns have no declared SQLite affinity. Match the stored
724        // integer bind type explicitly. Unary + removes expression affinity so
725        // SQLite can seek the typeless key; the text check preserves exact IDs.
726        ColumnType::Integer | ColumnType::Boolean => {
727            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
728        }
729        // REAL-to-text conversion can round distinct values to the same text.
730        // Narrowing those matches by numeric equality would change behavior.
731        _ => text_match,
732    }
733}
734
735/// §3.3/§4.8/§5.6 row ownership: rows whose local scope columns match
736/// `effective` and that no entry of `owners` matches. `owners` holds the
737/// effective scopes of every other active subscription on the table that
738/// holds rows; a scoped delete never removes a row one of them still covers.
739/// Scope values bind by the column's declared type, as `row_id_predicate`
740/// does. `Ok(None)` matches nothing (an empty map or value list); `Err(())`
741/// is a missing local scope-column mapping (the fail-closed case).
742fn local_scope_predicate(
743    table: &TableSchema,
744    effective: &[(String, Vec<String>)],
745    owners: &[Vec<(String, Vec<String>)>],
746) -> Result<Option<(String, Vec<SqlValue>)>, ()> {
747    if effective.is_empty() || effective.iter().any(|(_, values)| values.is_empty()) {
748        return Ok(None);
749    }
750    let mut clauses = Vec::new();
751    let mut params = Vec::new();
752    for (variable, values) in effective {
753        let name = table.scope_column(variable).ok_or(())?;
754        let column = table
755            .columns
756            .iter()
757            .find(|column| column.name == name)
758            .ok_or(())?;
759        let key = quote_ident(name);
760        let holes = vec!["?"; values.len()].join(", ");
761        clauses.push(if column.ty == ColumnType::Integer {
762            params.extend(values.iter().cloned().map(SqlValue::Text));
763            let seek = vec!["+CAST(? AS INTEGER)"; values.len()].join(", ");
764            format!("{key} IN ({seek}) AND CAST({key} AS TEXT) IN ({holes})")
765        } else {
766            format!("{key} IN ({holes})")
767        });
768        params.extend(values.iter().cloned().map(SqlValue::Text));
769    }
770    for owner in owners {
771        if let Some((sql, owner_params)) = local_scope_predicate(table, owner, &[])? {
772            clauses.push(format!("NOT ({sql})"));
773            params.extend(owner_params);
774        }
775    }
776    Ok(Some((clauses.join(" AND "), params)))
777}
778
779/// [`local_scope_predicate`] over decoded row values (retained rows).
780fn owns_local_row(
781    table: &TableSchema,
782    effective: &[(String, Vec<String>)],
783    owners: &[Vec<(String, Vec<String>)>],
784    values: &Map<String, Value>,
785) -> bool {
786    let matches = |scopes: &[(String, Vec<String>)]| {
787        !scopes.is_empty()
788            && scopes.iter().all(|(variable, allowed)| {
789                table
790                    .scope_column(variable)
791                    .and_then(|column| values.get(column))
792                    .filter(|value| !value.is_null())
793                    .is_some_and(|value| {
794                        allowed.contains(
795                            &value
796                                .as_str()
797                                .map_or_else(|| value.to_string(), str::to_owned),
798                        )
799                    })
800            })
801    };
802    matches(effective) && !owners.iter().any(|owner| matches(owner))
803}
804
805fn is_local_operation_code_like(value: &str) -> bool {
806    let bytes = value.as_bytes();
807    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
808        && bytes
809            .iter()
810            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
811}
812
813fn base_table(name: &str) -> String {
814    quote_ident(&format!("_syncular_base_{name}"))
815}
816
817/// §4.8: a stable, server-opaque key for a window base — table + variable +
818/// canonical fixed scopes. Two `set_window` calls with the same base
819/// address the same registry rows.
820/// §4.8 deferred eviction record: (sub id, table, effective scope map).
821type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
822
823fn window_base_key(base: &WindowBase) -> String {
824    format!(
825        "{}\0{}\0{}",
826        base.table,
827        base.variable,
828        canonical_scope_json(&base.fixed_scopes)
829    )
830}
831
832/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
833fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
834    let mut scopes = base.fixed_scopes.clone();
835    scopes.retain(|(k, _)| k != &base.variable);
836    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
837    scopes
838}
839
840/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
841/// echoed not interpreted by the server, so the exact hash is client
842/// convention; SHA-256 matches the SPEC's worked example.
843fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
844    let canonical = canonical_scope_json(&unit_scopes(base, unit));
845    let digest = Sha256::digest(canonical.as_bytes());
846    let hex = bytes_to_hex(&digest);
847    format!("w:{}:{}", base.table, &hex[..16])
848}
849
850fn visible_table(name: &str) -> String {
851    quote_ident(name)
852}
853
854const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
855
856/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
857/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
858fn is_synced_table_name(name: &str) -> bool {
859    if name.starts_with("sqlite_") {
860        return false;
861    }
862    if name.starts_with("_syncular_base_") {
863        return true; // the base half of a synced table pair
864    }
865    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
866    !name.starts_with("_syncular_")
867}
868
869/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
870fn blob_id_for(bytes: &[u8]) -> String {
871    let digest = Sha256::digest(bytes);
872    format!("sha256:{}", bytes_to_hex(&digest))
873}
874
875/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
876/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
877/// per row on the §5.6 bootstrap path), scalars bind owned.
878enum RowParam<'a> {
879    Cell(&'a Option<ColumnValue>),
880    Version(i64),
881}
882
883impl rusqlite::ToSql for RowParam<'_> {
884    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
885        Ok(match self {
886            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
887            RowParam::Cell(cell) => match cell {
888                None => ToSqlOutput::Owned(SqlValue::Null),
889                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
890                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
891                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
892                Some(ColumnValue::Boolean(b)) => {
893                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
894                }
895                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
896                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
897                }
898                // §5.10: crdt bytes store as BLOB, like bytes.
899                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
900                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
901                }
902            },
903        })
904    }
905}
906
907// Retain a typed primary key across the current import chunk without copying row payloads.
908fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
909    match param.to_sql().map_err(|error| error.to_string())? {
910        ToSqlOutput::Owned(value) => Ok(value),
911        ToSqlOutput::Borrowed(value) => {
912            SqlValue::try_from(value).map_err(|error| error.to_string())
913        }
914        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
915    }
916}
917
918/// §5.3 image cell → bind parameter, strict per the declared column type
919/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
920/// nullable). Mismatches are image-producer violations. Returns the cell
921/// borrowed when the stored representation already matches what the row
922/// codec would write, or the normalized scalar (boolean → 0/1, float from
923/// INTEGER → REAL) otherwise — the local write is byte-identical to the
924/// old convert-then-insert path without allocating per cell.
925fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
926    use ssp2::segment::ColumnType;
927    let mismatch = || {
928        Err(format!(
929            "image column {:?} holds a value of the wrong type",
930            column.name
931        ))
932    };
933    match value {
934        ValueRef::Null => {
935            if !column.nullable {
936                return Err(format!(
937                    "image column {:?} is NULL but not nullable",
938                    column.name
939                ));
940            }
941            Ok(ToSqlOutput::Owned(SqlValue::Null))
942        }
943        ValueRef::Integer(i) => match column.ty {
944            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
945            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
946            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
947            _ => mismatch(),
948        },
949        ValueRef::Real(_) => match column.ty {
950            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
951            _ => mismatch(),
952        },
953        ValueRef::Text(t) => {
954            std::str::from_utf8(t)
955                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
956            match column.ty {
957                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
958                    Ok(ToSqlOutput::Borrowed(value))
959                }
960                _ => mismatch(),
961            }
962        }
963        ValueRef::Blob(_) => match column.ty {
964            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
965            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
966            _ => mismatch(),
967        },
968    }
969}
970
971pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
972    use rusqlite::types::ValueRef;
973    match value {
974        ValueRef::Null => Value::Null,
975        ValueRef::Integer(i) => match column.ty {
976            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
977            ssp2::segment::ColumnType::Float => {
978                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
979            }
980            _ => Value::from(i),
981        },
982        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
983        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
984        ValueRef::Blob(b) => {
985            let mut map = Map::new();
986            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
987            Value::Object(map)
988        }
989    }
990}
991
992/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
993/// Objects are accepted in lossless `{"$bytes": hex}` and
994/// `{"$bigint": decimal}` envelope forms.
995fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
996    Ok(match value {
997        Value::Null => SqlValue::Null,
998        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
999        Value::Number(n) => {
1000            if let Some(i) = n.as_i64() {
1001                SqlValue::Integer(i)
1002            } else if let Some(f) = n.as_f64() {
1003                SqlValue::Real(f)
1004            } else {
1005                return Err(format!("query param number {n} is out of range"));
1006            }
1007        }
1008        Value::String(s) => SqlValue::Text(s.clone()),
1009        Value::Object(_) => {
1010            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
1011                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
1012            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
1013                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
1014                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
1015                })?)
1016            } else {
1017                return Err(
1018                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
1019                        .to_owned(),
1020                );
1021            }
1022        }
1023        Value::Array(_) => return Err("query array params are not supported".to_owned()),
1024    })
1025}
1026
1027/// Map a rusqlite value with no schema column to consult (arbitrary query
1028/// output): integers/reals/text pass through by stored affinity, blobs ride
1029/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
1030/// schema column type to recover booleans/floats/json.
1031fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
1032    use rusqlite::types::ValueRef;
1033    match value {
1034        ValueRef::Null => Value::Null,
1035        ValueRef::Integer(i) => {
1036            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
1037            // ordinary UI-sized integers ergonomic and envelope only values
1038            // beyond JavaScript's safe range.
1039            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
1040            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
1041                Value::from(i)
1042            } else {
1043                let mut map = Map::new();
1044                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
1045                Value::Object(map)
1046            }
1047        }
1048        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
1049        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
1050        ValueRef::Blob(b) => {
1051            let mut map = Map::new();
1052            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
1053            Value::Object(map)
1054        }
1055    }
1056}
1057
1058/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
1059fn query_connection(
1060    conn: &Connection,
1061    sql: &str,
1062    params: &[QueryValue],
1063) -> Result<Vec<QueryRow>, QueryReadFailure> {
1064    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
1065    let mut stmt = crate::query_guard::prepare_read_only(conn, &lowered_sql)?;
1066    let bound: Vec<SqlValue> = params
1067        .iter()
1068        .map(json_param_to_sql)
1069        .collect::<Result<_, _>>()?;
1070    // §2.4: an unaliased physical version column stays engine-internal, so a
1071    // `SELECT *` row feeds straight back into mutation values. An explicit
1072    // alias (`_sync_version AS v`) returns it as ordinary query data.
1073    let columns: Vec<(usize, String)> = stmt
1074        .column_names()
1075        .enumerate()
1076        .filter(|(_, name)| *name != "_syncular_version")
1077        .map(|(i, name)| (i, name.to_owned()))
1078        .collect();
1079    let bound_refs: Vec<&dyn rusqlite::ToSql> =
1080        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
1081    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
1082    let mut out = Vec::new();
1083    while let Some(row) = sql_rows.next()? {
1084        let mut record = Map::new();
1085        for (i, name) in &columns {
1086            let value = row.get_ref(*i)?;
1087            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
1088        }
1089        out.push(record);
1090    }
1091    Ok(out)
1092}
1093
1094fn persisted_window_state(
1095    conn: &Connection,
1096    base: &WindowBase,
1097) -> Result<WindowState, QueryReadFailure> {
1098    let mut stmt = conn.prepare(
1099        "SELECT windows.unit, subscriptions.state_json
1100           FROM _syncular_windows AS windows
1101           JOIN _syncular_subscriptions AS subscriptions
1102             ON subscriptions.id = windows.sub_id
1103          WHERE windows.base = ?1
1104          ORDER BY windows.unit ASC",
1105    )?;
1106    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
1107        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1108    })?;
1109    let mut units = Vec::new();
1110    let mut pending = Vec::new();
1111    for row in rows {
1112        let (unit, raw) = row?;
1113        let state: Value = serde_json::from_str(&raw)
1114            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
1115        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
1116            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
1117            || state
1118                .get("bootstrapState")
1119                .is_some_and(|value| !value.is_null());
1120        if is_pending {
1121            pending.push(unit.clone());
1122        }
1123        units.push(unit);
1124    }
1125    Ok(WindowState { units, pending })
1126}
1127
1128/// One read-only statement in a §7.5 snapshot read.
1129#[derive(Debug, Clone, Copy)]
1130pub struct SnapshotStatement<'a> {
1131    pub sql: &'a str,
1132    pub params: &'a [Value],
1133}
1134
1135/// A §7.5 atomic read request: read-only statements, window coverage,
1136/// subscription catch-up states, and commit deliveries, all resolved from one
1137/// SQLite read transaction and one revision.
1138#[derive(Debug, Clone, Default)]
1139pub struct SnapshotReadRequest<'a> {
1140    pub statements: Vec<SnapshotStatement<'a>>,
1141    pub coverage: &'a [WindowCoverage],
1142    pub subscriptions: Vec<String>,
1143    pub commit_ids: Vec<String>,
1144}
1145
1146#[cfg(test)]
1147thread_local! {
1148    /// Test-only rendezvous that runs between the statements of one snapshot
1149    /// read. A test uses it to commit on another connection mid-read and prove
1150    /// the read transaction's snapshot and revision stay stable.
1151    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
1152        std::cell::RefCell::new(None);
1153}
1154
1155#[cfg(test)]
1156fn run_snapshot_read_hook() {
1157    SNAPSHOT_READ_HOOK.with(|slot| {
1158        if let Some(hook) = slot.borrow_mut().take() {
1159            hook();
1160        }
1161    });
1162}
1163
1164/// §7.5: resolve a request over one SQLite read transaction. The revision,
1165/// every statement's rows, coverage, catch-up states, and deliveries are
1166/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
1167/// read back and releases the connection.
1168fn snapshot_read_connection(
1169    conn: &Connection,
1170    request: &SnapshotReadRequest<'_>,
1171) -> Result<SnapshotRead, QueryReadFailure> {
1172    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
1173    let corrupt = |message: &str| QueryReadFailure {
1174        code: Some("sync.local_corrupt"),
1175        sqlite_code: None,
1176        sqlite_message: None,
1177        rollback_failure: None,
1178        message: message.to_owned(),
1179    };
1180    let stored_failure = |error: rusqlite::Error, message: &str| match error {
1181        rusqlite::Error::Utf8Error(..)
1182        | rusqlite::Error::InvalidColumnType(..)
1183        | rusqlite::Error::FromSqlConversionFailure(..)
1184        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
1185        _ => QueryReadFailure::from(error),
1186    };
1187    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
1188        // The durable revision as a canonical decimal `u64`; a missing marker
1189        // is the legacy zero, and any other non-canonical value is corrupt.
1190        let revision: Option<String> = conn
1191            .query_row(
1192                "SELECT value FROM _syncular_meta WHERE key = ?1",
1193                rusqlite::params![LOCAL_REVISION_KEY],
1194                |row| row.get(0),
1195            )
1196            .optional()
1197            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
1198        let revision = match revision {
1199            None => "0".to_owned(),
1200            Some(value)
1201                if value == "0"
1202                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
1203                        && value.bytes().all(|b| b.is_ascii_digit())
1204                        && value.parse::<u64>().is_ok()) =>
1205            {
1206                value
1207            }
1208            Some(_) => return Err(corrupt("persisted local revision is invalid")),
1209        };
1210        let mut queries = Vec::with_capacity(request.statements.len());
1211        for statement in &request.statements {
1212            queries.push(query_connection(conn, statement.sql, statement.params)?);
1213            #[cfg(test)]
1214            run_snapshot_read_hook();
1215        }
1216        let mut pending = Vec::new();
1217        let mut missing = Vec::new();
1218        for requested in request.coverage {
1219            let base_key = window_base_key(&requested.base);
1220            let state = persisted_window_state(conn, &requested.base)?;
1221            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
1222                let reference = WindowUnitRef {
1223                    base_key: base_key.clone(),
1224                    unit: unit.clone(),
1225                };
1226                if !state.units.iter().any(|held| held == &unit) {
1227                    missing.push(reference);
1228                } else if state.pending.iter().any(|held| held == &unit) {
1229                    pending.push(reference);
1230                }
1231            }
1232        }
1233        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
1234        for id in &request.subscriptions {
1235            let row: Option<(String, String)> = conn
1236                .query_row(
1237                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
1238                    rusqlite::params![id],
1239                    |row| Ok((row.get(0)?, row.get(1)?)),
1240                )
1241                .optional()
1242                .map_err(|error| {
1243                    stored_failure(error, "persisted subscription state is invalid")
1244                })?;
1245            let Some((table, raw)) = row else {
1246                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
1247                continue;
1248            };
1249            // Every persisted field is required to be well typed; a corrupt
1250            // record fails typed instead of inventing a state.
1251            let state: Value = serde_json::from_str(&raw)
1252                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
1253            let status = match state.get("status").and_then(Value::as_str) {
1254                Some(value @ ("active" | "revoked" | "failed")) => value,
1255                _ => return Err(corrupt("persisted subscription state is invalid")),
1256            };
1257            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
1258                return Err(corrupt("persisted subscription state is invalid"));
1259            };
1260            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
1261                return Err(corrupt("persisted subscription state is invalid"));
1262            }
1263            let effective_scopes = state
1264                .get("effectiveScopes")
1265                .filter(|value| !value.is_null());
1266            if let Some(scopes) = effective_scopes {
1267                json_to_scope_map(scopes)
1268                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
1269            }
1270            let has_resume_token = match state.get("bootstrapState") {
1271                None | Some(Value::Null) => false,
1272                Some(Value::String(_)) => true,
1273                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
1274            };
1275            // A present reasonCode must be a string; a non-string value is a
1276            // corrupt record, never silently dropped.
1277            let reason_code = match state.get("reasonCode") {
1278                None | Some(Value::Null) => None,
1279                Some(Value::String(value)) => Some(value.clone()),
1280                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
1281            };
1282            let active = status == "active";
1283            subscriptions.push(SubscriptionCatchup::Known {
1284                id: id.clone(),
1285                table,
1286                status: status.to_owned(),
1287                cursor,
1288                has_resume_token,
1289                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
1290                known_pending_pages: active && (has_resume_token || cursor < 0),
1291                effective_scopes: effective_scopes.cloned(),
1292                reason_code,
1293            });
1294        }
1295        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
1296        for id in &request.commit_ids {
1297            let pending: bool = conn.query_row(
1298                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
1299                rusqlite::params![id],
1300                |row| row.get(0),
1301            )?;
1302            if pending {
1303                deliveries.push(CommitDelivery::Pending {
1304                    client_commit_id: id.clone(),
1305                });
1306                continue;
1307            }
1308            let row = conn
1309                .query_row(
1310                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
1311                            resolution, resolved_at_ms, replacement_client_commit_id
1312                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
1313                    rusqlite::params![id],
1314                    StoredCommitOutcomeRow::from_row,
1315                )
1316                .optional()
1317                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
1318            deliveries.push(match row {
1319                Some(row) => CommitDelivery::Known {
1320                    client_commit_id: id.clone(),
1321                    outcome: SyncClient::outcome_from_row(row)
1322                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
1323                },
1324                None => CommitDelivery::Unknown {
1325                    client_commit_id: id.clone(),
1326                },
1327            });
1328        }
1329        Ok(SnapshotRead {
1330            revision,
1331            queries,
1332            coverage: CoverageSnapshot {
1333                complete: pending.is_empty() && missing.is_empty(),
1334                pending,
1335                missing,
1336            },
1337            subscriptions,
1338            deliveries,
1339        })
1340    })();
1341    match result {
1342        Ok(read) => {
1343            conn.execute_batch("RELEASE syncular_snapshot_read")?;
1344            Ok(read)
1345        }
1346        Err(mut error) => {
1347            if let Err(rollback) = conn
1348                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
1349            {
1350                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
1351            }
1352            Err(error)
1353        }
1354    }
1355}
1356
1357fn snapshot_connection(
1358    conn: &Connection,
1359    sql: &str,
1360    params: &[Value],
1361    coverage: &[WindowCoverage],
1362) -> Result<QuerySnapshot, QueryReadFailure> {
1363    let read = snapshot_read_connection(
1364        conn,
1365        &SnapshotReadRequest {
1366            statements: vec![SnapshotStatement { sql, params }],
1367            coverage,
1368            subscriptions: Vec::new(),
1369            commit_ids: Vec::new(),
1370        },
1371    )?;
1372    Ok(QuerySnapshot {
1373        revision: read.revision,
1374        rows: read
1375            .queries
1376            .into_iter()
1377            .next()
1378            .expect("single-query snapshot returns one result"),
1379        coverage: read.coverage,
1380    })
1381}
1382
1383/// A long-lived read-only SQLite sidecar for latency-critical native views.
1384/// Network rounds stay serialized on the mutable core owner, while atomic
1385/// query snapshots use this independent connection and therefore never queue
1386/// behind HTTP/WebSocket latency.
1387pub struct FileQuerySnapshotReader {
1388    path: String,
1389    conn: Option<Connection>,
1390}
1391
1392impl FileQuerySnapshotReader {
1393    #[must_use]
1394    pub fn new(path: impl Into<String>) -> Self {
1395        Self {
1396            path: path.into(),
1397            conn: None,
1398        }
1399    }
1400
1401    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
1402        if self.conn.is_none() {
1403            let conn = Connection::open_with_flags(
1404                &self.path,
1405                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1406            )
1407            .map_err(QueryReadFailure::from)?;
1408            conn.busy_timeout(std::time::Duration::from_millis(250))
1409                .map_err(QueryReadFailure::from)?;
1410            self.conn = Some(conn);
1411        }
1412        self.conn
1413            .as_ref()
1414            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
1415    }
1416
1417    /// One atomic read over the shared primitive: every statement, coverage,
1418    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
1419    pub fn snapshot_read(
1420        &mut self,
1421        request: &SnapshotReadRequest<'_>,
1422    ) -> Result<SnapshotRead, QueryReadFailure> {
1423        snapshot_read_connection(self.connection()?, request)
1424    }
1425
1426    /// The current durable local revision. A host can skip a read when it is
1427    /// unchanged; a corrupt or unreadable marker fails typed.
1428    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
1429        Ok(self
1430            .snapshot_read(&SnapshotReadRequest::default())?
1431            .revision)
1432    }
1433
1434    /// One subscription's persisted catch-up state (see the §7.5 contract).
1435    pub fn subscription_catchup(
1436        &mut self,
1437        id: &str,
1438    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
1439        let read = self.snapshot_read(&SnapshotReadRequest {
1440            subscriptions: vec![id.to_owned()],
1441            ..Default::default()
1442        })?;
1443        Ok(read
1444            .subscriptions
1445            .into_iter()
1446            .next()
1447            .expect("one requested catch-up"))
1448    }
1449
1450    /// One client commit's delivery status: pending, its retained outcome, or
1451    /// an explicit unknown (never held, or pruned by retention).
1452    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
1453        let read = self.snapshot_read(&SnapshotReadRequest {
1454            commit_ids: vec![id.to_owned()],
1455            ..Default::default()
1456        })?;
1457        Ok(read
1458            .deliveries
1459            .into_iter()
1460            .next()
1461            .expect("one requested delivery"))
1462    }
1463
1464    /// Hosts report an owned failure, and the first success after it, to the
1465    /// owning core through [`SyncClient::record_query_read`] (§7.6).
1466    pub fn query_snapshot(
1467        &mut self,
1468        sql: &str,
1469        params: &[Value],
1470        coverage: &[WindowCoverage],
1471    ) -> Result<QuerySnapshot, QueryReadFailure> {
1472        snapshot_connection(self.connection()?, sql, params, coverage)
1473    }
1474}
1475
1476fn validate_authority_reads(
1477    schema: &ClientSchema,
1478    declarations: &[crate::api::AuthorityReadDeclaration],
1479) -> Result<(), String> {
1480    let mut seen = BTreeSet::new();
1481    for read in declarations {
1482        let table = schema
1483            .table(&read.table)
1484            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
1485        let plain = |name: &str| {
1486            table.columns.iter().enumerate().any(|(i, c)| {
1487                c.name == name
1488                    && name != "authority_version"
1489                    && !name.starts_with("_sync")
1490                    && !table.encrypted_columns.iter().any(|e| e.index == i)
1491                    && matches!(
1492                        c.ty,
1493                        ssp2::segment::ColumnType::String
1494                            | ssp2::segment::ColumnType::Integer
1495                            | ssp2::segment::ColumnType::Float
1496                            | ssp2::segment::ColumnType::Boolean
1497                            | ssp2::segment::ColumnType::Json
1498                    )
1499            })
1500        };
1501        if !seen.insert(&read.table)
1502            || read.columns.is_empty()
1503            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
1504            || !read.columns.contains(&table.primary_key)
1505            || !read.columns.iter().all(|c| plain(c))
1506            || read.scopes.is_empty()
1507            || !read.scopes.iter().all(|(v, values)| {
1508                table
1509                    .scope_column(v)
1510                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
1511                    && !values.is_empty()
1512                    && values.iter().all(|v| !v.is_empty() && v != "*")
1513            })
1514        {
1515            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
1516        }
1517    }
1518    Ok(())
1519}
1520
1521// §7.4.2: every open and recreation checks before any persistent writes.
1522/// A marker read that hit a recognized SQLite storage failure keeps that
1523/// classification (BUSY/LOCKED stay retryable); only an unclassified read
1524/// failure is the invalid-marker `sync.local_corrupt`.
1525fn schema_marker_read_failure(error: rusqlite::Error) -> String {
1526    let failure = QueryReadFailure::from(error);
1527    if failure.code.is_some() {
1528        failure.to_string()
1529    } else {
1530        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
1531    }
1532}
1533
1534/// §7.1: reject an invalid configured client limit before any storage is
1535/// opened or written. Zero would wedge the push queue and an out-of-range
1536/// value would differ per target; the TS core enforces the same bounds.
1537fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
1538    if limits.outcome_retention_max_entries == Some(0) {
1539        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
1540    }
1541    if [
1542        limits.max_push_commits_per_request,
1543        limits.max_push_operations_per_request,
1544        limits.max_push_request_bytes,
1545    ]
1546    .into_iter()
1547    .flatten()
1548    .any(|value| value == 0 || value > u32::MAX as usize)
1549    {
1550        return Err(
1551            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
1552        );
1553    }
1554    Ok(())
1555}
1556
1557fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
1558    let corrupt =
1559        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
1560    let table_type: Option<String> = conn
1561        .query_row(
1562            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
1563            [],
1564            |row| row.get(0),
1565        )
1566        .optional()
1567        .map_err(schema_marker_read_failure)?;
1568    let Some(table_type) = table_type else {
1569        return Ok(None);
1570    };
1571    if table_type != "table" {
1572        return Err(corrupt());
1573    }
1574    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
1575    // whichever row SQLite returns first, and an older one would drive a
1576    // destructive reset over a newer replica.
1577    let mut statement = conn
1578        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
1579        .map_err(schema_marker_read_failure)?;
1580    let mut rows = statement
1581        .query([LOCAL_SCHEMA_VERSION_KEY])
1582        .map_err(schema_marker_read_failure)?;
1583    let value: Option<String> = rows
1584        .next()
1585        .map_err(schema_marker_read_failure)?
1586        .map(|row| row.get(0))
1587        .transpose()
1588        .map_err(schema_marker_read_failure)?;
1589    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
1590        return Err(corrupt());
1591    }
1592    let Some(value) = value else {
1593        let has_descriptor = conn
1594            .query_row(
1595                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
1596                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
1597                |_| Ok(()),
1598            )
1599            .optional()
1600            .map_err(schema_marker_read_failure)?
1601            .is_some();
1602        if has_descriptor {
1603            return Err(corrupt());
1604        }
1605        return Ok(None);
1606    };
1607    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
1608        || !value.bytes().all(|b| b.is_ascii_digit())
1609    {
1610        return Err(corrupt());
1611    }
1612    let version = value.parse::<i32>().map_err(|_| corrupt())?;
1613    if version > requested {
1614        return Err(
1615            "client.schema_downgrade: persisted local schema is newer than the requested schema"
1616                .to_owned(),
1617        );
1618    }
1619    Ok(Some(version))
1620}
1621
1622/// A file replica on a browser worker must recover a hot journal before any
1623/// statement reads it. `wasm_vfs` replaces the installed OPFS VFS's reserved
1624/// lock callback, so install it before the connection opens its file.
1625#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1626fn prepare_wasm_replica(path: &str) -> Result<(), String> {
1627    if path.is_empty() || path == ":memory:" {
1628        return Ok(());
1629    }
1630    crate::wasm_vfs::disable_foreign_lock_check()
1631}
1632
1633#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1634fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
1635    Ok(())
1636}
1637
1638impl SyncClient {
1639    pub fn new_with_identity(
1640        client_id: Option<String>,
1641        schema_json: &Value,
1642        limits: ClientLimits,
1643    ) -> Result<Self, String> {
1644        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1645        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1646        Self::with_connection(resolved, schema_json, limits, conn)
1647    }
1648
1649    pub fn new(
1650        client_id: String,
1651        schema_json: &Value,
1652        limits: ClientLimits,
1653    ) -> Result<Self, String> {
1654        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1655        Self::with_connection(client_id, schema_json, limits, conn)
1656    }
1657
1658    /// Build a client backed by an on-disk SQLite database at `path` — the
1659    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
1660    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
1661    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
1662    /// of the command router's dependency set (the router only holds a path).
1663    pub fn open_path(
1664        client_id: String,
1665        schema_json: &Value,
1666        limits: ClientLimits,
1667        path: &str,
1668    ) -> Result<Self, String> {
1669        validate_client_limits(&limits)?;
1670        parse_schema_json(schema_json)?;
1671        prepare_wasm_replica(path)?;
1672        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1673        Self::with_connection(client_id, schema_json, limits, conn)
1674    }
1675
1676    /// Open a file replica and resolve its client id. Native targets require
1677    /// WAL for the independent snapshot reader; `wasm32-unknown-unknown`
1678    /// accepts the sqlite-wasm worker journal instead.
1679    pub fn open_path_with_identity(
1680        client_id: Option<String>,
1681        schema_json: &Value,
1682        limits: ClientLimits,
1683        path: &str,
1684    ) -> Result<Self, String> {
1685        Self::open_path_with_identity_journal(
1686            client_id,
1687            schema_json,
1688            limits,
1689            path,
1690            !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
1691        )
1692    }
1693
1694    fn open_path_with_identity_journal(
1695        client_id: Option<String>,
1696        schema_json: &Value,
1697        limits: ClientLimits,
1698        path: &str,
1699        require_wal: bool,
1700    ) -> Result<Self, String> {
1701        validate_client_limits(&limits)?;
1702        // §7.4.2: an invalid requested version is refused before the replica is
1703        // even created, so a rejected constructor leaves no file behind.
1704        parse_schema_json(schema_json)?;
1705        prepare_wasm_replica(path)?;
1706        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1707        // A file-backed native client uses an independent read connection for
1708        // latency-critical snapshots. WAL is SQLite's intended reader/writer
1709        // concurrency mode: a view read never holds a rollback-journal lock
1710        // that delays the client's next commit. The busy timeout must precede
1711        // this replica's own startup transaction; WAL is applied only after
1712        // startup succeeded, because a refused open must leave the replica's
1713        // journal mode and contents untouched (§7.4.2).
1714        conn.busy_timeout(std::time::Duration::from_millis(250))
1715            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
1716        // Durability is the caller's contract here, not the VFS default: a
1717        // rollback-journal commit needs FULL before it writes the database
1718        // pages, or a killed worker can leave a half-written image behind.
1719        // WAL below inherits the same setting.
1720        conn.pragma_update(None, "synchronous", "FULL")
1721            .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
1722        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
1723        // A browser worker's sqlite-wasm VFS has no WAL mode, and the wasm
1724        // host has no independent snapshot reader. The WAL requirement exists
1725        // for native file replicas, where it is the reader/writer boundary.
1726        if require_wal {
1727            let journal_mode: String = client
1728                .conn
1729                .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
1730                .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1731            if journal_mode != "wal" {
1732                return Err(
1733                    "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
1734                );
1735            }
1736        }
1737        Ok(client)
1738    }
1739
1740    /// Delete a file replica at `path`, including its `-journal`, `-wal`, and
1741    /// `-shm` sidecars, through the VFS that owns it. Use this after
1742    /// `client.storage_corrupt`: an OPFS SAH pool stores those files under
1743    /// opaque names, so deleting the pool directory behind SQLite bypasses the
1744    /// pool's slot accounting.
1745    ///
1746    /// Close the client and every [`FileQuerySnapshotReader`] on `path` first;
1747    /// a VFS cannot delete a file another handle still owns. A file that does
1748    /// not exist counts as deleted, so a cleanly closed replica without
1749    /// sidecars resets. The next open
1750    /// creates a fresh replica (new client id) and re-bootstraps from the
1751    /// server. A memory replica has nothing to delete and fails with
1752    /// `sync.invalid_request`.
1753    pub fn reset_replica(path: &str) -> Result<(), String> {
1754        if path.is_empty() || path == ":memory:" {
1755            return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
1756        }
1757        // SAFETY: a null VFS name asks SQLite for its process-wide default VFS,
1758        // the same one `Connection::open(path)` above selects. `xDelete` is
1759        // that VFS's own function pointer, and `c_name` outlives the call.
1760        let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
1761        if vfs.is_null() {
1762            return Err("client.storage_io: no SQLite VFS is registered".to_owned());
1763        }
1764        let delete = unsafe { (*vfs).xDelete }
1765            .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
1766        for suffix in ["-wal", "-shm", "-journal", ""] {
1767            let file_name = format!("{path}{suffix}");
1768            let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
1769                format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
1770            })?;
1771            // SAFETY: `vfs` and its `xDelete` pointer come from the live
1772            // SQLite library; `c_name` is a valid NUL-terminated path.
1773            let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
1774            // A cleanly closed WAL replica has no sidecars; a missing file is
1775            // already deleted.
1776            if code != rusqlite::ffi::SQLITE_OK && code != rusqlite::ffi::SQLITE_IOERR_DELETE_NOENT
1777            {
1778                return Err(format!(
1779                    "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
1780                ));
1781            }
1782        }
1783        Ok(())
1784    }
1785
1786    #[must_use]
1787    pub fn client_id(&self) -> &str {
1788        &self.client_id
1789    }
1790
1791    /// Rotate the caller's client id (§1.5) and return the new one. Use it
1792    /// after a `sync.invalid_client_id` refusal (`recommendedAction:
1793    /// resetClientId`): the next round carries the new id. A live realtime
1794    /// socket is closed so the next connection registers with the new id.
1795    /// The replica and its outbox stay in place; wipe them yourself when the
1796    /// stored data belongs to the account that signed out.
1797    pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> Result<String, String> {
1798        self.disconnect_realtime(transport);
1799        let client_id = uuid::Uuid::new_v4().to_string();
1800        self.set_meta(CLIENT_ID_KEY, &client_id)?;
1801        self.client_id.clone_from(&client_id);
1802        self.round_failure = None;
1803        Ok(client_id)
1804    }
1805
1806    /// Build a client over a caller-supplied rusqlite connection — the seam a
1807    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
1808    /// with an on-disk database (`Connection::open(path)`) rather than the
1809    /// default `:memory:`. The connection MUST be fresh (no pre-existing
1810    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
1811    /// the same file across process restarts reuses the persisted rows.
1812    pub fn with_connection(
1813        client_id: String,
1814        schema_json: &Value,
1815        limits: ClientLimits,
1816        conn: Connection,
1817    ) -> Result<Self, String> {
1818        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
1819    }
1820
1821    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
1822    /// means a durable replica's persisted `clientId` wins over a fresh one.
1823    fn with_connection_identity(
1824        client_id: Option<String>,
1825        schema_json: &Value,
1826        limits: ClientLimits,
1827        conn: Connection,
1828    ) -> Result<Self, String> {
1829        validate_client_limits(&limits)?;
1830        let schema = parse_schema_json(schema_json)?;
1831        validate_authority_reads(&schema, &limits.authority_reads)?;
1832        // RFC 0005 D8: resolve the previous-version config before the opening
1833        // reset runs — the capture happens inside it. Bad bounds fail loud.
1834        let previous_version = match limits.previous_version_context {
1835            Some(config) => {
1836                config.validate()?;
1837                Some(config)
1838            }
1839            None => None,
1840        };
1841        let mut client = SyncClient {
1842            #[cfg(feature = "bench-internals")]
1843            benchmark_phases: Recorder::default(),
1844            conn,
1845            schema,
1846            client_id: client_id
1847                .clone()
1848                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
1849            limits,
1850            subs: Shared::default(),
1851            outbox: Shared::default(),
1852            failed_commits: Shared::default(),
1853            retain_failed_commits: false,
1854            conflicts: Shared::default(),
1855            rejections: Shared::default(),
1856            schema_floor: None,
1857            lease_state: None,
1858            stopped: false,
1859            upgrading: false,
1860            sync_needed: false,
1861            realtime_policy: RealtimePolicy::Optional,
1862            realtime_state: RealtimeState::Disconnected,
1863            realtime_reason_code: None,
1864            realtime_retry_delay_ms: None,
1865            presence: HashMap::new(),
1866            now_ms: None,
1867            encryption: crate::values::EncryptionConfig::default(),
1868            security_preflight: false,
1869            security_epoch: 0,
1870            transport_enabled: true,
1871            transport_epoch: 0,
1872            subscription_epoch: 0,
1873            previous_version,
1874            insert_sql: RefCell::new(HashMap::new()),
1875            overlay_dirty: OverlayDirty::clean(),
1876            storage_failure: RefCell::new(None),
1877            #[cfg(test)]
1878            overlay_rebuild_count: Cell::new(0),
1879            #[cfg(test)]
1880            acknowledged_replay_count: Cell::new(0),
1881            #[cfg(test)]
1882            outcome_prune_count: Cell::new(0),
1883            progress: ProgressObserver::default(),
1884            change_queue: VecDeque::new(),
1885            sync_intent_queue: VecDeque::new(),
1886            retry_delay_ms: 250,
1887            round_retry_delay_ms: None,
1888            round_failure: None,
1889            active_round: None,
1890            last_round: None,
1891            last_change: None,
1892            query_failures: Vec::new(),
1893            coalesced: None,
1894            coalesced_failure: None,
1895        };
1896        // The row write path leans on the prepared-statement cache (two
1897        // insert statements per synced table, plus the bookkeeping
1898        // statements); size it so a multi-table schema never thrashes.
1899        client
1900            .conn
1901            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
1902        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
1903        // writer) covers the marker read, the identity check, and every
1904        // bookkeeping/schema write, so a concurrent open that upgraded the
1905        // replica cannot interleave between the guard and the writes, and a
1906        // refusal leaves the replica unchanged.
1907        client
1908            .conn
1909            .execute_batch("BEGIN IMMEDIATE")
1910            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1911        let startup = (|| -> Result<(), String> {
1912            // The marker guard runs inside the write transaction, so the version
1913            // it validates is the version every write below commits under.
1914            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
1915            // New app indexes may reference columns that only exist after a
1916            // version-bump reset.
1917            client.create_bookkeeping_tables()?;
1918            match client.get_meta(CLIENT_ID_KEY)? {
1919                Some(existing) => match client_id {
1920                    Some(requested) if existing != requested => {
1921                        return Err(format!(
1922                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
1923                        ))
1924                    }
1925                    _ => client.client_id = existing,
1926                },
1927                None => client.set_meta(CLIENT_ID_KEY, &client.client_id)?,
1928            }
1929            client.restore_persisted_state()?;
1930            client.failed_commits = client.load_failed_commits()?.into();
1931            match marker {
1932                None => {
1933                    client.create_synced_tables()?;
1934                    client.persist_schema_version()?;
1935                    client.save_subscription_scope_schema()?;
1936                }
1937                Some(version) if version == client.schema.version => {
1938                    client.create_synced_tables()?;
1939                    // RFC 0005 D1: the same-version open backfills the descriptor
1940                    // for a database first opened by an unaware binary, so the
1941                    // NEXT bump can capture without needing a schema bump first.
1942                    set_local_schema_descriptor(&client.conn, &client.schema)
1943                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1944                    client.save_subscription_scope_schema()?;
1945                }
1946                Some(_) => client.run_schema_reset()?,
1947            }
1948            // RFC 0005 D9: the reset sweep cannot see a container whose capture
1949            // committed in its OWN file while the replica's savepoint never
1950            // released — a crash at that seam leaves a container beside a matching
1951            // OLD marker, and the next open at that version runs no reset. Discard
1952            // it before anything can read it. No-op when no container exists.
1953            client.reconcile_previous_version_at_boot()?;
1954            client.clear_satisfied_persisted_schema_floor()?;
1955            client.prune_unknown_subscriptions(false)?;
1956            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
1957            // the full startup/reset reconciliation boundary, inside this open
1958            // transaction and after the schema and identity guards, so a commit
1959            // an earlier version accepted is dropped with durable rejection
1960            // evidence before any replay. A table the current schema removed
1961            // keeps the §7.4.4 send-time classification, and an unresolved
1962            // encryption key stays with the send seam because hosts configure
1963            // keys after open.
1964            client.drop_unencodable_outbox(false, None)?;
1965            if marker == Some(client.schema.version)
1966                && (!client.outbox.is_empty()
1967                    || !client.failed_commits.is_empty()
1968                    || client.has_acknowledged_rows()?)
1969            {
1970                // Reconstruct the visible optimistic overlay from the durable
1971                // base plus outbox instead of trusting a process-interrupted
1972                // mirror.
1973                client.overlay_dirty.set(true);
1974                client.rebuild_overlay()?;
1975            }
1976            // Every persisted active subscription needs one catch-up pull on
1977            // open: realtime only covers changes after connection, while an
1978            // idempotent setWindow correctly creates no fresh command effect.
1979            // Pending outbox work has the same restart requirement. The core
1980            // owns this intent so native hosts never poll or require an
1981            // application-issued sync().
1982            client.enqueue_startup_sync_if_needed()?;
1983            Ok(())
1984        })();
1985        match startup {
1986            Ok(()) => match client.conn.execute_batch("COMMIT") {
1987                Ok(()) => Ok(client),
1988                Err(error) => {
1989                    let failure = Self::sqlite_failure(&client.storage_failure, error);
1990                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
1991                        // Keep a failed rollback's typed storage classification.
1992                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
1993                    }
1994                    Err(failure)
1995                }
1996            },
1997            Err(error) => {
1998                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
1999                    // Keep a failed rollback's typed storage classification.
2000                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
2001                }
2002                Err(error)
2003            }
2004        }
2005    }
2006
2007    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
2008    /// against this instead of system time (conformance virtual clock).
2009    pub fn set_now_ms(&mut self, now_ms: i64) {
2010        self.now_ms = Some(now_ms);
2011    }
2012
2013    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
2014    /// The command router parses these from the `create` command's
2015    /// `encryption` config (keys as `{$bytes: hex}`).
2016    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
2017        self.encryption = encryption;
2018    }
2019
2020    #[must_use]
2021    pub fn security_lifecycle(&self) -> &'static str {
2022        if self.security_preflight {
2023            "preflight"
2024        } else {
2025            "active"
2026        }
2027    }
2028
2029    #[must_use]
2030    pub fn security_preflight(&self) -> bool {
2031        self.security_preflight
2032    }
2033
2034    /// Host-owned, non-persisted network gate. Local operations remain available.
2035    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
2036        if self.transport_enabled != enabled {
2037            self.transport_enabled = enabled;
2038            self.transport_epoch = self.transport_epoch.wrapping_add(1);
2039            self.sync_intent_queue.clear();
2040            self.sync_intent_queue
2041                .push_back(if enabled && !self.security_preflight {
2042                    SyncIntent::Interactive
2043                } else {
2044                    SyncIntent::None
2045                });
2046        }
2047        // A captured reply keeps its normal apply/revocation semantics.
2048        if !enabled && self.active_round.is_none() {
2049            self.disconnect_realtime(transport);
2050        }
2051    }
2052
2053    pub fn transport_enabled(&self) -> bool {
2054        self.transport_enabled
2055    }
2056
2057    /// Quarantine this replica: enter the fail-closed gate, release all
2058    /// core-owned key material, and record the gate in the database so
2059    /// reopening re-enters preflight until `activate_security` clears it.
2060    ///
2061    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
2062    /// which must NOT leave that durable mark.
2063    pub fn begin_security_preflight(&mut self) -> Result<(), String> {
2064        self.seal_security_on_teardown();
2065        // Persist the quarantine so it survives handle teardown and restart:
2066        // reopening this replica re-enters preflight until activation clears it.
2067        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1")
2068    }
2069
2070    /// Release core-owned key material as a host tears the client down,
2071    /// WITHOUT recording a quarantine.
2072    ///
2073    /// Shutting an activated client down is not a quarantine event. Persisting
2074    /// the gate here marks every cleanly closed replica as pending, and the
2075    /// next plain `create` is then refused permanently: the reopen path
2076    /// restores the flag from the marker and the create guard rejects it. The
2077    /// in-memory flag still closes the gate for anything still holding this
2078    /// instance, which is all a teardown barrier needs.
2079    pub fn seal_security_on_teardown(&mut self) {
2080        self.cancel_sync_round();
2081        self.security_preflight = true;
2082        self.security_epoch = self.security_epoch.wrapping_add(1);
2083        self.encryption = crate::values::EncryptionConfig::default();
2084        self.sync_intent_queue.clear();
2085    }
2086
2087    /// Install the post-authentication keyring and release the host loop.
2088    pub fn activate_security(
2089        &mut self,
2090        encryption: crate::values::EncryptionConfig,
2091    ) -> Result<(), String> {
2092        if !self.security_preflight {
2093            return Err(
2094                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
2095            );
2096        }
2097        self.enqueue_startup_sync_if_needed()?;
2098        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY)?;
2099        self.encryption = encryption;
2100        self.security_preflight = false;
2101        Ok(())
2102    }
2103
2104    fn clock_now_ms(&self) -> i64 {
2105        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
2106    }
2107
2108    fn create_bookkeeping_tables(&self) -> Result<(), String> {
2109        // Durable client bookkeeping (outbox + subscription + meta).
2110        self.conn
2111            .execute_batch(
2112                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
2113                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
2114                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
2115                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
2116                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
2117                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
2118                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
2119                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
2120                   PRIMARY KEY(commit_id, idx));
2121                 CREATE INDEX IF NOT EXISTS _syncular_acked_rows_row
2122                   ON _syncular_acked_rows(tbl, id);
2123                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
2124                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
2125                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
2126                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
2127                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
2128                   client_commit_id TEXT NOT NULL UNIQUE,
2129                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
2130                   recorded_at_ms INTEGER NOT NULL,
2131                   results_json TEXT NOT NULL,
2132                   operations_json TEXT,
2133                   resolution TEXT NOT NULL DEFAULT 'active'
2134                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
2135                   resolved_at_ms INTEGER,
2136                   replacement_client_commit_id TEXT);
2137                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
2138                   ON _syncular_commit_outcomes(resolution, seq);
2139                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
2140                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
2141                 CREATE TABLE IF NOT EXISTS _syncular_meta (
2142                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
2143                 CREATE TABLE IF NOT EXISTS _syncular_windows (
2144                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
2145                   PRIMARY KEY (base, unit));
2146                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
2147                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
2148                   effective_scopes TEXT NOT NULL);
2149                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
2150                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
2151                   PRIMARY KEY(commit_id, blob_id));
2152                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
2153                   ON _syncular_blob_commit_refs(blob_id);",
2154            )
2155            .map_err(|e| e.to_string())?;
2156        // Migrate an outcome journal created before failed aggregate
2157        // envelopes were retained. Historical rows intentionally stay NULL.
2158        let _ = self
2159            .conn
2160            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
2161        if self.get_meta(LOCAL_REVISION_KEY)?.is_none() {
2162            self.set_meta(LOCAL_REVISION_KEY, "0")?;
2163        }
2164        // §5.9.7 blob cache. Mutable upload state stays off the body row so
2165        // completing a large upload does not rewrite its BLOB record.
2166        if self.schema_has_blobs() {
2167            let exists = self
2168                .conn
2169                .query_row(
2170                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
2171                    [],
2172                    |row| row.get::<_, bool>(0),
2173                )
2174                .map_err(|e| e.to_string())?;
2175            if exists {
2176                let mut statement = self
2177                    .conn
2178                    .prepare("PRAGMA table_info(_syncular_blobs)")
2179                    .map_err(|e| e.to_string())?;
2180                let mut columns = statement
2181                    .query_map([], |row| row.get::<_, String>(1))
2182                    .map_err(|e| e.to_string())?
2183                    .collect::<Result<Vec<_>, _>>()
2184                    .map_err(|e| e.to_string())?;
2185                columns.sort();
2186                let mut expected = vec![
2187                    "blob_id",
2188                    "byte_length",
2189                    "bytes",
2190                    "created_at_ms",
2191                    "media_type",
2192                ];
2193                expected.sort_unstable();
2194                if columns != expected {
2195                    return Err(
2196                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
2197                    );
2198                }
2199            } else {
2200                self.conn
2201                    .execute_batch(
2202                        "CREATE TABLE _syncular_blobs(
2203                           blob_id TEXT PRIMARY KEY,
2204                           bytes BLOB NOT NULL,
2205                           byte_length INTEGER NOT NULL,
2206                           media_type TEXT,
2207                           created_at_ms INTEGER NOT NULL);",
2208                    )
2209                    .map_err(|e| e.to_string())?;
2210            }
2211            self.conn
2212                .execute_batch(
2213                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
2214                       blob_id TEXT PRIMARY KEY,
2215                       media_type TEXT,
2216                       created_at_ms INTEGER NOT NULL);",
2217                )
2218                .map_err(|e| e.to_string())?;
2219        }
2220        Ok(())
2221    }
2222
2223    /// True iff any synced table declares a `blob_ref` column (§5.9).
2224    fn schema_has_blobs(&self) -> bool {
2225        self.schema
2226            .tables
2227            .iter()
2228            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
2229    }
2230
2231    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
2232
2233    fn get_meta(&self, key: &str) -> Result<Option<String>, String> {
2234        meta_get(&self.conn, key)
2235            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2236    }
2237
2238    // The caller holds the startup/reset transaction. Either both schema
2239    // records persist or the enclosing operation fails and rolls back.
2240    fn persist_schema_version(&self) -> Result<(), String> {
2241        self.conn
2242            .execute(
2243                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
2244                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
2245            )
2246            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
2247        set_local_schema_descriptor(&self.conn, &self.schema)
2248            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2249    }
2250
2251    pub(crate) fn set_meta(&self, key: &str, value: &str) -> Result<(), String> {
2252        meta_set(&self.conn, key, value)
2253            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2254    }
2255
2256    fn delete_meta(&self, key: &str) -> Result<(), String> {
2257        meta_delete(&self.conn, key)
2258            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2259    }
2260
2261    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
2262    fn load_outbox(&self) -> Result<Vec<Arc<OutboxCommit>>, String> {
2263        let mut stmt = self
2264            .conn
2265            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
2266            .map_err(|error| error.to_string())?;
2267        let rows = stmt
2268            .query_map([], |row| {
2269                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2270            })
2271            .map_err(|error| error.to_string())?;
2272        let mut commits = Vec::new();
2273        for row in rows {
2274            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
2275            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
2276                format!("invalid persisted outbox {client_commit_id:?}: {error}")
2277            })?;
2278            let mut ops = Vec::with_capacity(entries.len());
2279            for entry in entries {
2280                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
2281                ops.push(OutboxOp {
2282                    upsert: op == "upsert",
2283                    table: entry
2284                        .get("table")
2285                        .and_then(Value::as_str)
2286                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
2287                        .to_owned(),
2288                    row_id: entry
2289                        .get("rowId")
2290                        .and_then(Value::as_str)
2291                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
2292                        .to_owned(),
2293                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
2294                    // A legacy entry's `changedFields` key is ignored:
2295                    // the values map is the presence set (§6.1).
2296                    values: entry.get("values").and_then(Value::as_object).cloned(),
2297                    insert: entry.get("insert") == Some(&Value::Bool(true)),
2298                });
2299            }
2300            commits.push(Arc::new(OutboxCommit {
2301                client_commit_id,
2302                ops,
2303            }));
2304        }
2305        Ok(commits)
2306    }
2307
2308    fn restore_persisted_state(&mut self) -> Result<(), String> {
2309        self.subs = {
2310            let mut stmt = self
2311                .conn
2312                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
2313                .map_err(|error| error.to_string())?;
2314            let rows = stmt
2315                .query_map([], |row| {
2316                    Ok((
2317                        row.get::<_, String>(0)?,
2318                        row.get::<_, String>(1)?,
2319                        row.get::<_, String>(2)?,
2320                    ))
2321                })
2322                .map_err(|error| error.to_string())?;
2323            let mut subscriptions = Vec::new();
2324            for row in rows {
2325                let (id, table, raw) = row.map_err(|error| error.to_string())?;
2326                let state: Value = serde_json::from_str(&raw)
2327                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
2328                let requested = json_to_scope_map(
2329                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
2330                )?;
2331                let effective = state
2332                    .get("effectiveScopes")
2333                    .filter(|value| !value.is_null())
2334                    .map(json_to_scope_map)
2335                    .transpose()?;
2336                subscriptions.push(Subscription {
2337                    id,
2338                    table,
2339                    requested,
2340                    params: state
2341                        .get("params")
2342                        .and_then(Value::as_str)
2343                        .map(str::to_owned),
2344                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
2345                    bootstrap_state: state
2346                        .get("bootstrapState")
2347                        .and_then(Value::as_str)
2348                        .map(str::to_owned),
2349                    state: SubState::parse(
2350                        state
2351                            .get("status")
2352                            .and_then(Value::as_str)
2353                            .unwrap_or("active"),
2354                    ),
2355                    reason_code: state
2356                        .get("reasonCode")
2357                        .and_then(Value::as_str)
2358                        .map(str::to_owned),
2359                    effective,
2360                    synced_once: state
2361                        .get("syncedOnce")
2362                        .and_then(Value::as_bool)
2363                        .unwrap_or(false),
2364                });
2365            }
2366            subscriptions.into()
2367        };
2368
2369        self.outbox = self.load_outbox()?.into();
2370
2371        self.prune_commit_outcomes()?;
2372        let active = self.commit_outcomes(CommitOutcomeQuery {
2373            active_only: true,
2374            ..CommitOutcomeQuery::default()
2375        })?;
2376        self.conflicts = active
2377            .iter()
2378            .flat_map(|outcome| outcome.results.iter())
2379            .filter_map(|result| match result {
2380                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
2381                _ => None,
2382            })
2383            .collect::<Vec<_>>()
2384            .into();
2385        self.rejections = active
2386            .iter()
2387            .flat_map(|outcome| outcome.results.iter())
2388            .filter_map(|result| match result {
2389                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
2390                _ => None,
2391            })
2392            .collect::<Vec<_>>()
2393            .into();
2394
2395        self.lease_state = self
2396            .get_meta(LEASE_STATE_KEY)?
2397            .map(|raw| serde_json::from_str(&raw))
2398            .transpose()
2399            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
2400        self.schema_floor = self
2401            .get_meta(SCHEMA_FLOOR_KEY)?
2402            .map(|raw| serde_json::from_str(&raw))
2403            .transpose()
2404            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
2405        self.stopped = self.schema_floor.is_some();
2406        // A replica left in unactivated preflight reopens gated: the quarantine
2407        // decision persists with the data across handle teardown and restart.
2408        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY)?.as_deref() == Some("1") {
2409            self.security_preflight = true;
2410        }
2411        Ok(())
2412    }
2413
2414    fn save_subscription_scope_schema(&self) -> Result<(), String> {
2415        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
2416            .schema
2417            .tables
2418            .iter()
2419            .map(|table| {
2420                (
2421                    table.name.clone(),
2422                    table
2423                        .scope_variables
2424                        .iter()
2425                        .map(|scope| {
2426                            (
2427                                scope.variable.clone(),
2428                                [scope.prefix.clone(), scope.column.clone()],
2429                            )
2430                        })
2431                        .collect(),
2432                )
2433            })
2434            .collect();
2435        self.set_meta(
2436            "subscriptionScopeSchema",
2437            &serde_json::to_string(&declarations).expect("scope schema serialization"),
2438        )
2439    }
2440
2441    /// Remove registrations with incompatible table/key/prefix/column meaning.
2442    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
2443        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
2444            self.get_meta("subscriptionScopeSchema")?
2445                .map(|raw| serde_json::from_str(&raw))
2446                .transpose()
2447                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
2448        } else {
2449            None
2450        };
2451        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
2452            let Some(table) = self.schema.table(table_name) else {
2453                return false;
2454            };
2455            if requested
2456                .iter()
2457                .any(|(variable, _)| table.scope_column(variable).is_none())
2458            {
2459                return false;
2460            }
2461            if !bump {
2462                return true;
2463            }
2464            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
2465                return false;
2466            };
2467            let variables: BTreeSet<_> = if requested.is_empty() {
2468                old.keys()
2469                    .cloned()
2470                    .chain(
2471                        table
2472                            .scope_variables
2473                            .iter()
2474                            .map(|scope| scope.variable.clone()),
2475                    )
2476                    .collect()
2477            } else {
2478                requested
2479                    .iter()
2480                    .map(|(variable, _)| variable.clone())
2481                    .collect()
2482            };
2483            variables.iter().all(|variable| {
2484                let current = table
2485                    .scope_variables
2486                    .iter()
2487                    .find(|scope| &scope.variable == variable);
2488                match (old.get(variable), current) {
2489                    (Some(old), Some(current)) => {
2490                        old == &[current.prefix.clone(), current.column.clone()]
2491                    }
2492                    _ => false,
2493                }
2494            })
2495        };
2496        let mut stale_ids: BTreeSet<_> = self
2497            .subs
2498            .iter()
2499            .filter(|sub| !compatible(&sub.table, &sub.requested))
2500            .map(|sub| sub.id.clone())
2501            .collect();
2502        for (id, table, effective) in self.load_pending_evictions()? {
2503            if !compatible(&table, &effective) {
2504                stale_ids.insert(id);
2505            }
2506        }
2507        for id in &stale_ids {
2508            for sql in [
2509                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
2510                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
2511                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
2512            ] {
2513                self.conn
2514                    .execute(sql, rusqlite::params![id])
2515                    .map_err(|error| error.to_string())?;
2516            }
2517        }
2518        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
2519        Ok(())
2520    }
2521
2522    pub fn local_revision(&self) -> Result<u64, String> {
2523        Ok(self
2524            .get_meta(LOCAL_REVISION_KEY)?
2525            .and_then(|value| value.parse().ok())
2526            .unwrap_or(0))
2527    }
2528
2529    /// §7.5: resolve a snapshot read on the mutable owner connection through
2530    /// the same primitive the read-only sidecar uses, so the command surface
2531    /// and the file sidecar return one revision, rows, coverage, catch-up, and
2532    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
2533    /// `owner` records its failure (and its next success) in diagnostics.
2534    pub fn snapshot_read(
2535        &mut self,
2536        request: &SnapshotReadRequest<'_>,
2537        owner: Option<QueryOwner<'_>>,
2538    ) -> Result<SnapshotRead, QueryReadFailure> {
2539        let result = snapshot_read_connection(&self.conn, request);
2540        if let Some(owner) = owner {
2541            self.record_query_read(owner, result.as_ref().err());
2542        }
2543        result
2544    }
2545
2546    #[must_use]
2547    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
2548        SyncStatusSnapshot {
2549            current_schema_version: self.schema.version,
2550            outbox: self.outbox.len(),
2551            upgrading: self.upgrading,
2552            lease_state: self.lease_state.clone(),
2553            schema_floor: self.schema_floor.clone(),
2554            sync_needed: self.sync_needed,
2555            previous_version_context: self.previous_version_status(),
2556        }
2557    }
2558
2559    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
2560    /// rollback precondition), read without creating the file.
2561    fn previous_version_status(&self) -> PreviousVersionStatus {
2562        let Some(replica_path) = self.previous_version_replica_path() else {
2563            return PreviousVersionStatus {
2564                present: false,
2565                created_at_ms: None,
2566            };
2567        };
2568        match crate::previous_version::read_previous_version_record(&replica_path) {
2569            Ok(Some(record)) => PreviousVersionStatus {
2570                present: true,
2571                created_at_ms: Some(record.created_at_ms),
2572            },
2573            _ => PreviousVersionStatus {
2574                present: std::path::Path::new(
2575                    &crate::previous_version::previous_version_container_path(&replica_path),
2576                )
2577                .exists(),
2578                created_at_ms: None,
2579            },
2580        }
2581    }
2582
2583    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
2584    /// is always `previousVersion`; the container never makes
2585    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
2586    /// revocation, coverage completion and the TTL DISCARD the container and
2587    /// close the read with their own reason.
2588    pub fn previous_version_snapshot(
2589        &self,
2590        spec: &PreviousVersionReadSpec,
2591    ) -> Result<PreviousVersionSnapshot, String> {
2592        let now_ms = self.clock_now_ms();
2593        crate::previous_version::previous_version_snapshot(
2594            &self.conn,
2595            self.previous_version_replica_path().as_deref(),
2596            self.previous_version.as_ref(),
2597            PreviousVersionLifecycle {
2598                lease_inactive: self.previous_version_lease_inactive(now_ms),
2599                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
2600                coverage_complete: self.previous_version_coverage_complete(),
2601            },
2602            now_ms,
2603            self.schema.version,
2604            spec,
2605        )
2606    }
2607
2608    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
2609    pub fn previous_version_audit(
2610        &self,
2611    ) -> Result<Option<crate::previous_version::PreviousVersionAudit>, String> {
2612        crate::previous_version::stored_previous_version_audit(&self.conn)
2613            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2614    }
2615
2616    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
2617    /// and both metadata records, and report whether anything was present.
2618    /// Idempotent: absence is a successful no-op.
2619    pub fn previous_version_discard(
2620        &self,
2621    ) -> Result<crate::previous_version::PreviousVersionDiscardOutcome, String> {
2622        self.drop_previous_version()
2623    }
2624
2625    fn drop_previous_version(
2626        &self,
2627    ) -> Result<crate::previous_version::PreviousVersionDiscardOutcome, String> {
2628        let Some(replica_path) = self.previous_version_replica_path() else {
2629            return Ok(crate::previous_version::PreviousVersionDiscardOutcome {
2630                present: false,
2631                discarded: false,
2632            });
2633        };
2634        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
2635    }
2636
2637    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
2638    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
2639        let Some(lease) = &self.lease_state else {
2640            return false;
2641        };
2642        if lease.error_code.is_some() {
2643            return true;
2644        }
2645        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
2646    }
2647
2648    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
2649    /// has `cursor >= 0` and no resume token. An empty active set is not
2650    /// completion (mirrors the TS predicate exactly).
2651    fn previous_version_coverage_complete(&self) -> bool {
2652        let active = self
2653            .subs
2654            .iter()
2655            .filter(|sub| sub.state == SubState::Active)
2656            .collect::<Vec<_>>();
2657        if active.is_empty() {
2658            return false;
2659        }
2660        active
2661            .iter()
2662            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
2663    }
2664
2665    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
2666    /// apply. A container that no longer has a reason to exist is discarded so
2667    /// the rows do not outlive the window they were kept for.
2668    fn previous_version_lifetime_check(&self) {
2669        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
2670            return;
2671        };
2672        let now_ms = self.clock_now_ms();
2673        if self.previous_version_lease_inactive(now_ms) {
2674            let _ = self.drop_previous_version();
2675            return;
2676        }
2677        let Some(replica_path) = self.previous_version_replica_path() else {
2678            return;
2679        };
2680        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
2681        else {
2682            return;
2683        };
2684        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
2685            let _ = self.drop_previous_version();
2686            return;
2687        }
2688        if now_ms - record.created_at_ms > config.max_age_ms {
2689            let _ = self.drop_previous_version();
2690            return;
2691        }
2692        if self.previous_version_coverage_complete() {
2693            let _ = self.drop_previous_version();
2694        }
2695    }
2696
2697    pub fn diagnostics_snapshot(
2698        &self,
2699        request: &ClientDiagnosticsRequest,
2700    ) -> Result<ClientDiagnosticsSnapshot, String> {
2701        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
2702            return Err(format!(
2703                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
2704            ));
2705        }
2706        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
2707        for sub in self.subs.iter() {
2708            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
2709            let complete =
2710                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
2711            let state = match sub.state {
2712                SubState::Revoked => "revoked",
2713                SubState::Failed => "failed",
2714                SubState::Active if reset => "reset",
2715                SubState::Active if complete => "complete",
2716                SubState::Active => "bootstrapping",
2717            };
2718            subscriptions.insert(
2719                sub.id.clone(),
2720                DiagnosticSubscription {
2721                    id: sub.id.clone(),
2722                    table: sub.table.clone(),
2723                    state: state.to_owned(),
2724                    complete,
2725                    cursor: Some(sub.cursor),
2726                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
2727                },
2728            );
2729        }
2730        for expected in &request.expected_subscriptions {
2731            if expected.id.is_empty() || expected.table.is_empty() {
2732                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
2733            }
2734            if subscriptions
2735                .get(&expected.id)
2736                .is_some_and(|registered| registered.table != expected.table)
2737            {
2738                subscriptions.insert(
2739                    expected.id.clone(),
2740                    DiagnosticSubscription {
2741                        id: expected.id.clone(),
2742                        table: expected.table.clone(),
2743                        state: "failed".to_owned(),
2744                        complete: false,
2745                        cursor: None,
2746                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
2747                    },
2748                );
2749            } else {
2750                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
2751                    DiagnosticSubscription {
2752                        id: expected.id.clone(),
2753                        table: expected.table.clone(),
2754                        state: "unregistered".to_owned(),
2755                        complete: false,
2756                        cursor: None,
2757                        reason_code: None,
2758                    }
2759                });
2760            }
2761        }
2762        let mut ordered_subscriptions = Vec::new();
2763        let mut included = BTreeSet::new();
2764        for expected in &request.expected_subscriptions {
2765            if included.insert(expected.id.clone()) {
2766                if let Some(subscription) = subscriptions.get(&expected.id) {
2767                    ordered_subscriptions.push(subscription.clone());
2768                }
2769            }
2770        }
2771        for (id, subscription) in &subscriptions {
2772            if included.insert(id.clone()) {
2773                ordered_subscriptions.push(subscription.clone());
2774            }
2775        }
2776        let subscriptions_truncated =
2777            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
2778        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
2779        let captured_at_ms = self.clock_now_ms();
2780        let lease = if let Some(error_code) = self
2781            .lease_state
2782            .as_ref()
2783            .and_then(|state| state.error_code.clone())
2784        {
2785            ClientDiagnosticsLease {
2786                state: "stopped".to_owned(),
2787                expires_at_ms: self
2788                    .lease_state
2789                    .as_ref()
2790                    .and_then(|state| state.expires_at_ms),
2791                error_code: Some(Self::diagnostic_code(&error_code)),
2792            }
2793        } else if let Some(expires_at_ms) = self
2794            .lease_state
2795            .as_ref()
2796            .and_then(|state| state.expires_at_ms)
2797        {
2798            ClientDiagnosticsLease {
2799                state: if expires_at_ms <= captured_at_ms {
2800                    "expired".to_owned()
2801                } else {
2802                    "active".to_owned()
2803                },
2804                expires_at_ms: Some(expires_at_ms),
2805                error_code: None,
2806            }
2807        } else {
2808            ClientDiagnosticsLease {
2809                state: "none".to_owned(),
2810                expires_at_ms: None,
2811                error_code: None,
2812            }
2813        };
2814        let connectivity = match self.last_round.as_ref() {
2815            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
2816            Some(round) if round.status == "succeeded" => "online",
2817            Some(round)
2818                if round.status == "failed"
2819                    && matches!(
2820                        round.cause_kind,
2821                        Some(
2822                            crate::TransportCauseKind::Network | crate::TransportCauseKind::Connect
2823                        )
2824                    ) =>
2825            {
2826                "offline"
2827            }
2828            Some(round) if round.status == "failed" && round.http_status.is_some() => "online",
2829            _ => "unknown",
2830        };
2831        Ok(ClientDiagnosticsSnapshot {
2832            version: CLIENT_DIAGNOSTICS_VERSION,
2833            captured_at_ms,
2834            host: ClientDiagnosticsHost {
2835                kind: "direct".to_owned(),
2836                role: "single".to_owned(),
2837                connectivity: connectivity.to_owned(),
2838                realtime: self.realtime_state,
2839                realtime_policy: self.realtime_policy,
2840                realtime_reason_code: self.realtime_reason_code.clone(),
2841                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
2842            },
2843            security_lifecycle: self.security_lifecycle().to_owned(),
2844            schema: ClientDiagnosticsSchema {
2845                current_version: self.schema.version,
2846                upgrading: self.upgrading,
2847                required_version: self
2848                    .schema_floor
2849                    .as_ref()
2850                    .and_then(|floor| floor.required_schema_version),
2851                latest_version: self
2852                    .schema_floor
2853                    .as_ref()
2854                    .and_then(|floor| floor.latest_schema_version),
2855            },
2856            replica: ClientDiagnosticsReplica {
2857                local_revision: self.local_revision()?.to_string(),
2858                sync_needed: self.sync_needed,
2859                pending_outbox: self.outbox.len(),
2860            },
2861            lease,
2862            subscriptions: ordered_subscriptions,
2863            subscriptions_truncated,
2864            last_round: self.last_round.clone(),
2865            last_change: self.last_change.clone(),
2866            storage: self.diagnostics_storage(),
2867            query_failures: self.query_failures.clone(),
2868        })
2869    }
2870
2871    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
2872        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
2873            let page_count: i64 = self
2874                .conn
2875                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
2876            let page_size: i64 = self
2877                .conn
2878                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
2879            // Reuse compiled aggregates; every invocation still reads current storage.
2880            let outbox_bytes: i64 = self
2881                .conn
2882                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
2883                .query_row([], |row| row.get(0))?;
2884            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
2885                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
2886            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
2887            let blob_bytes = if self.schema_has_blobs() {
2888                self.conn
2889                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
2890                    .query_row([], |row| row.get(0))?
2891            } else {
2892                0
2893            };
2894            let pressure = self
2895                .limits
2896                .blob_cache_max_bytes
2897                .is_some_and(|limit| blob_bytes > limit);
2898            Ok(ClientDiagnosticsStorage {
2899                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
2900                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
2901                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
2902                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
2903                retained_outcome_entries: Some(outcome_entries.max(0)),
2904                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
2905                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
2906            })
2907        };
2908        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
2909            status: "unreadable".to_owned(),
2910            database_bytes_approx: None,
2911            pending_outbox_bytes_approx: None,
2912            retained_outcome_bytes_approx: None,
2913            retained_outcome_entries: None,
2914            blob_cache_bytes_approx: None,
2915            pressure_reason_code: None,
2916        })
2917    }
2918
2919    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
2920        self.change_queue.drain(..).collect()
2921    }
2922
2923    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
2924        let intents = self.sync_intent_queue.drain(..).collect();
2925        if self.transport_enabled {
2926            intents
2927        } else {
2928            vec![SyncIntent::None]
2929        }
2930    }
2931
2932    fn schedule_background_retry(&mut self) -> u64 {
2933        let delay_ms = self.retry_delay_ms;
2934        self.sync_intent_queue
2935            .push_back(SyncIntent::Background { delay_ms });
2936        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
2937        self.round_retry_delay_ms = Some(delay_ms);
2938        delay_ms
2939    }
2940
2941    fn reset_background_retry(&mut self) {
2942        self.retry_delay_ms = 250;
2943    }
2944
2945    fn retryable_transport_code(code: &str) -> bool {
2946        code == "transport.failed"
2947            || code == "transport.timeout"
2948            || code == "transport.unavailable"
2949            || code == "sync.transport_failed"
2950    }
2951
2952    /// §1.8: the transport reported a refused or unreachable endpoint. The
2953    /// native transport says `connect` (DNS failure, connection refused,
2954    /// reset, or aborted); a browser fetch transport says `network`. Repeating
2955    /// the request on a backoff repairs neither, so no retry is scheduled.
2956    fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
2957        matches!(
2958            details
2959                .as_ref()
2960                .and_then(|details| details.get("causeKind"))
2961                .and_then(serde_json::Value::as_str),
2962            Some("connect" | "network")
2963        )
2964    }
2965
2966    /// §1.5: a segment fetch that fails inside an otherwise complete round is
2967    /// invisible to the server (the round response arrived whole), so its code
2968    /// rides the following requests like a socket-round failure. A code the
2969    /// frame grammar rejects is not reportable and the previous code stays.
2970    fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
2971        if ssp2::model::valid_round_failure_code(&error.code) {
2972            self.round_failure = Some(error.code.clone());
2973        }
2974        SectionError::Transport(error)
2975    }
2976
2977    /// A failed round worth a background retry: a transport failure, or a
2978    /// §10.2 code the catalog marks retryable (clients may hardcode the
2979    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
2980    /// on the server's `retryable` flag.
2981    fn retryable_failure_code(code: &str) -> bool {
2982        Self::retryable_transport_code(code)
2983            || matches!(
2984                code,
2985                "sync.auth_required"
2986                    | "sync.auth_lease_required"
2987                    | "sync.auth_lease_revoked"
2988                    | "sync.internal_error"
2989                    | "sync.idempotency_cache_miss"
2990                    | "sync.schema_not_ready"
2991                    | "sync.segment_expired"
2992                    | "sync.rate_limited"
2993                    | "sync.websocket_connection_limit"
2994            )
2995    }
2996
2997    fn diagnostic_code(code: &str) -> String {
2998        let valid = !code.is_empty()
2999            && code.len() <= 96
3000            && code.contains('.')
3001            && code
3002                .bytes()
3003                .next()
3004                .is_some_and(|byte| byte.is_ascii_lowercase())
3005            && code.bytes().all(|byte| {
3006                byte.is_ascii_lowercase()
3007                    || byte.is_ascii_digit()
3008                    || matches!(byte, b'.' | b'_' | b'-')
3009            });
3010        if valid {
3011            code.to_owned()
3012        } else {
3013            "client.unknown_failure".to_owned()
3014        }
3015    }
3016
3017    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
3018        if self.sync_needed != value {
3019            if self.begin_observation("syncular_status").is_ok() {
3020                self.sync_needed = value;
3021                let batch = ChangeAccumulator {
3022                    status: true,
3023                    ..ChangeAccumulator::default()
3024                };
3025                if self.finish_observation("syncular_status", batch).is_err() {
3026                    self.rollback_observation("syncular_status");
3027                }
3028            } else {
3029                self.sync_needed = value;
3030            }
3031        }
3032        if value && interactive {
3033            self.sync_intent_queue.push_back(SyncIntent::Interactive);
3034        }
3035    }
3036
3037    fn sqlite_failure(
3038        storage_failure: &RefCell<Option<QueryReadFailure>>,
3039        error: rusqlite::Error,
3040    ) -> String {
3041        let message = error.to_string();
3042        let failure = QueryReadFailure::from(error);
3043        let code = failure.code;
3044        if code.is_some() && storage_failure.borrow().is_none() {
3045            *storage_failure.borrow_mut() = Some(failure);
3046        }
3047        match code {
3048            Some(code) => format!("{code}: {message}"),
3049            None => message,
3050        }
3051    }
3052
3053    fn begin_observation(&self, name: &str) -> Result<(), String> {
3054        if let Some(failure) = &self.coalesced_failure {
3055            return Err(failure.clone());
3056        }
3057        // SQLite rolls a whole transaction back on some I/O and capacity
3058        // errors; later frames must not commit on their own after that.
3059        if self.coalesced.is_some() && self.conn.is_autocommit() {
3060            return Err("the coalesced apply transaction was rolled back".to_owned());
3061        }
3062        self.conn
3063            .execute_batch(&format!("SAVEPOINT {name}"))
3064            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
3065    }
3066
3067    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
3068    /// already owns one.
3069    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
3070        if self.coalesced.is_some() || !self.conn.is_autocommit() {
3071            return Ok(());
3072        }
3073        self.conn
3074            .execute_batch("BEGIN")
3075            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3076        self.coalesced = Some(CoalescedApply {
3077            rows: 0,
3078            rollback: self.capture(),
3079        });
3080        Ok(())
3081    }
3082
3083    fn capture(&self) -> Rollback {
3084        Rollback {
3085            subs: self.subs.clone(),
3086            outbox: self.outbox.clone(),
3087            failed_commits: self.failed_commits.clone(),
3088            conflicts: self.conflicts.clone(),
3089            rejections: self.rejections.clone(),
3090            schema_floor: self.schema_floor.clone(),
3091            lease_state: self.lease_state.clone(),
3092            stopped: self.stopped,
3093            upgrading: self.upgrading,
3094            sync_needed: self.sync_needed,
3095            active_round: self.active_round,
3096            overlay_dirty: self.overlay_dirty.snapshot(),
3097            change_queue: self.change_queue.len(),
3098            sync_intent_queue: self.sync_intent_queue.len(),
3099            last_change: self.last_change.clone(),
3100        }
3101    }
3102
3103    fn restore(&mut self, rollback: Rollback) {
3104        self.subs = rollback.subs;
3105        self.outbox = rollback.outbox;
3106        self.failed_commits = rollback.failed_commits;
3107        self.conflicts = rollback.conflicts;
3108        self.rejections = rollback.rejections;
3109        self.schema_floor = rollback.schema_floor;
3110        self.lease_state = rollback.lease_state;
3111        self.stopped = rollback.stopped;
3112        self.upgrading = rollback.upgrading;
3113        self.sync_needed = rollback.sync_needed;
3114        self.active_round = rollback.active_round;
3115        self.overlay_dirty.restore(rollback.overlay_dirty);
3116        self.change_queue.truncate(rollback.change_queue);
3117        self.sync_intent_queue.truncate(rollback.sync_intent_queue);
3118        self.last_change = rollback.last_change;
3119    }
3120
3121    /// Account the rows of the next frame or block. A unit that would take the
3122    /// coalesced transaction past the bound commits the earlier units first.
3123    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
3124        let Some(open) = &self.coalesced else {
3125            return Ok(());
3126        };
3127        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
3128            self.commit_coalesced_apply()?;
3129            self.begin_coalesced_apply()?;
3130        }
3131        if let Some(open) = &mut self.coalesced {
3132            open.rows += rows;
3133        }
3134        Ok(())
3135    }
3136
3137    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
3138    /// frame back, restores the in-memory state of its `BEGIN`, drops the
3139    /// change batches and sync intents queued since, and fails the rest of the
3140    /// response.
3141    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
3142        let Some(open) = self.coalesced.take() else {
3143            return Ok(());
3144        };
3145        let committed = if self.conn.is_autocommit() {
3146            Err("the coalesced apply transaction was rolled back".to_owned())
3147        } else {
3148            self.conn
3149                .execute_batch("COMMIT")
3150                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
3151        };
3152        let Err(error) = committed else {
3153            return Ok(());
3154        };
3155        if !self.conn.is_autocommit() {
3156            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3157                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
3158            }
3159        }
3160        self.restore(open.rollback);
3161        self.coalesced_failure = Some(error.clone());
3162        Err(error)
3163    }
3164
3165    /// End the response's coalescing: COMMIT what is open and clear a failure.
3166    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
3167        let committed = self.commit_coalesced_apply();
3168        match self.coalesced_failure.take() {
3169            Some(failure) => Err(failure),
3170            None => committed,
3171        }
3172    }
3173
3174    fn rollback_observation(&self, name: &str) {
3175        if let Err(error) = self
3176            .conn
3177            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
3178        {
3179            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
3180                if failure.rollback_failure.is_none() {
3181                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
3182                }
3183            }
3184        }
3185    }
3186
3187    fn finish_observation(
3188        &mut self,
3189        name: &str,
3190        mut batch: ChangeAccumulator,
3191    ) -> Result<(), String> {
3192        #[cfg(feature = "bench-internals")]
3193        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
3194        if self.failed_commits.iter().any(|(commit, _)| {
3195            commit
3196                .ops
3197                .iter()
3198                .any(|operation| batch.tables.contains_key(&operation.table))
3199        }) {
3200            batch.outcomes = true;
3201        }
3202        if !batch.touched() {
3203            self.conn
3204                .execute_batch(&format!("RELEASE {name}"))
3205                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3206            return Ok(());
3207        }
3208        let revision = self
3209            .local_revision()?
3210            .checked_add(1)
3211            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
3212        self.conn
3213            .execute(
3214                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
3215                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
3216            )
3217            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3218        let status = batch.status.then(|| self.status_snapshot());
3219        let event = ClientChangeBatch {
3220            revision: revision.to_string(),
3221            tables: batch
3222                .tables
3223                .into_iter()
3224                .map(|(table, scope_keys)| TableChange {
3225                    table,
3226                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
3227                })
3228                .collect(),
3229            windows: batch
3230                .windows
3231                .into_iter()
3232                .map(|((base_key, table), units)| WindowChange {
3233                    base_key,
3234                    table,
3235                    units: units.into_iter().collect(),
3236                })
3237                .collect(),
3238            status,
3239            conflicts_changed: batch.conflicts,
3240            rejections_changed: batch.rejections,
3241            outcomes_changed: batch.outcomes,
3242            applied_commits: Vec::new(),
3243        };
3244        self.conn
3245            .execute_batch(&format!("RELEASE {name}"))
3246            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3247        let mut diagnostic_tables = event
3248            .tables
3249            .iter()
3250            .map(|entry| entry.table.clone())
3251            .collect::<BTreeSet<_>>()
3252            .into_iter()
3253            .collect::<Vec<_>>();
3254        let mut diagnostic_windows = event
3255            .windows
3256            .iter()
3257            .map(|entry| entry.table.clone())
3258            .collect::<BTreeSet<_>>()
3259            .into_iter()
3260            .collect::<Vec<_>>();
3261        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
3262            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
3263        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
3264        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
3265        self.last_change = Some(DiagnosticLastChange {
3266            revision: event.revision.clone(),
3267            recorded_at_ms: self.clock_now_ms(),
3268            tables: diagnostic_tables,
3269            windows: diagnostic_windows,
3270            domains_truncated,
3271            status_changed: event.status.is_some(),
3272            conflicts_changed: event.conflicts_changed,
3273            rejections_changed: event.rejections_changed,
3274            outcomes_changed: event.outcomes_changed,
3275        });
3276        self.change_queue.push_back(event);
3277        Ok(())
3278    }
3279
3280    fn record_scope_map(
3281        &self,
3282        batch: &mut ChangeAccumulator,
3283        table_name: &str,
3284        scopes: &[(String, Vec<String>)],
3285    ) {
3286        let Some(table) = self.schema.table(table_name) else {
3287            return;
3288        };
3289        // A bulk scoped delete whose map leaves a declared variable unnamed
3290        // cannot list the deleted rows' keys for it: the change is table-wide.
3291        if table.scope_variables.iter().any(|scope| {
3292            !scopes
3293                .iter()
3294                .any(|(variable, _)| *variable == scope.variable)
3295        }) {
3296            batch.table(table_name);
3297            return;
3298        }
3299        for (variable, values) in scopes {
3300            let Some(scope) = table
3301                .scope_variables
3302                .iter()
3303                .find(|scope| &scope.variable == variable)
3304            else {
3305                continue;
3306            };
3307            for value in values {
3308                batch.scope(table_name, format!("{}:{value}", scope.prefix));
3309            }
3310        }
3311    }
3312
3313    /// Record a row's current scope keys from the base or visible table.
3314    fn record_row_scopes(
3315        &self,
3316        batch: &mut ChangeAccumulator,
3317        table_name: &str,
3318        row_id: &str,
3319        base: bool,
3320    ) -> bool {
3321        let Some(table) = self.schema.table(table_name) else {
3322            return false;
3323        };
3324        if table.scope_variables.is_empty() {
3325            return false;
3326        }
3327        let columns = table
3328            .scope_variables
3329            .iter()
3330            .map(|scope| quote_ident(&scope.column))
3331            .collect::<Vec<_>>()
3332            .join(", ");
3333        let full_table = if base {
3334            base_table(table_name)
3335        } else {
3336            visible_table(table_name)
3337        };
3338        let sql = format!(
3339            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
3340            row_id_predicate(table)
3341        );
3342        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
3343            return false;
3344        };
3345        let values = stmt.query_row(rusqlite::params![row_id], |row| {
3346            let mut values = Vec::with_capacity(table.scope_variables.len());
3347            for index in 0..table.scope_variables.len() {
3348                values.push(row.get::<_, Option<String>>(index)?);
3349            }
3350            Ok(values)
3351        });
3352        let Ok(values) = values else {
3353            return false;
3354        };
3355        let mut recorded = false;
3356        for (scope, value) in table.scope_variables.iter().zip(values) {
3357            if let Some(value) = value {
3358                batch.scope(table_name, format!("{}:{value}", scope.prefix));
3359                recorded = true;
3360            }
3361        }
3362        recorded
3363    }
3364
3365    fn record_commit_changes(
3366        &self,
3367        batch: &mut ChangeAccumulator,
3368        tables: &[String],
3369        changes: &[ssp2::model::Change],
3370    ) {
3371        #[cfg(feature = "bench-internals")]
3372        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
3373        for change in changes {
3374            let Some(table_name) = tables.get(change.table_index as usize) else {
3375                continue;
3376            };
3377            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
3378            if let Some(table) = self.schema.table(table_name) {
3379                for (variable, value) in &change.scopes {
3380                    if let Some(scope) = table
3381                        .scope_variables
3382                        .iter()
3383                        .find(|scope| &scope.variable == variable)
3384                    {
3385                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
3386                        precise = true;
3387                    }
3388                }
3389            }
3390            if !precise {
3391                batch.table(table_name);
3392            }
3393        }
3394    }
3395
3396    fn scoped_rows_exist(
3397        &self,
3398        table_name: &str,
3399        effective: &[(String, Vec<String>)],
3400        owners: &[Vec<(String, Vec<String>)>],
3401    ) -> bool {
3402        let Some(table) = self.schema.table(table_name) else {
3403            return false;
3404        };
3405        let Ok(Some((predicate, params))) = local_scope_predicate(table, effective, owners) else {
3406            return false;
3407        };
3408        let sql = format!(
3409            "SELECT 1 FROM {} WHERE {predicate} LIMIT 1",
3410            base_table(table_name)
3411        );
3412        self.conn
3413            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
3414            .is_ok()
3415    }
3416
3417    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
3418    pub fn upgrading(&self) -> bool {
3419        self.upgrading
3420    }
3421
3422    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
3423    /// keeping this client's local database (identity, outbox, tables). The
3424    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
3425    /// version increases. Recreation runs the same marker guard as opening
3426    /// a durable replica, holds the guard and every write it authorizes under
3427    /// one write transaction, and preserves the current client (compiled
3428    /// schema, outbox, tables) on refusal.
3429    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
3430        let new_schema = parse_schema_json(schema_json)?;
3431        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
3432        self.conn
3433            .execute_batch("BEGIN IMMEDIATE")
3434            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3435        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
3436        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
3437        // state (dropped outbox commits, rejections, upgrade and floor flags,
3438        // overlay dirtiness) and appends change/intent batches. A COMMIT that
3439        // fails after the reset succeeded rolls the database back, so every
3440        // one of those fields is captured here and restored with it.
3441        let rollback = self.capture();
3442        let guarded = (|| -> Result<(), String> {
3443            // §7.4.2: the marker is re-read under the write lock the caller
3444            // holds, so a replica another process upgraded meanwhile refuses
3445            // this recreation instead of having its marker and tables
3446            // downgraded.
3447            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
3448            self.create_bookkeeping_tables()?;
3449            match marker {
3450                Some(version) if version < self.schema.version => self.run_schema_reset()?,
3451                None => {
3452                    self.create_synced_tables()?;
3453                    self.persist_schema_version()?;
3454                }
3455                _ => {}
3456            }
3457            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
3458            // durable outbox the same-version path is about to replay. Only the
3459            // outbox is reloaded: the recovery below reads it, and the caller's
3460            // rollback snapshot already covers this field.
3461            self.outbox = self.load_outbox()?.into();
3462            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
3463            // this guarded boundary once the schema and marker agree. A bump went
3464            // through `run_schema_reset`, which performs the same recovery after
3465            // it creates the new physical tables and before its final replay.
3466            self.drop_unencodable_outbox(false, None)?;
3467            if marker == Some(self.schema.version)
3468                && (!self.outbox.is_empty()
3469                    || !self.failed_commits.is_empty()
3470                    || self.has_acknowledged_rows()?)
3471            {
3472                self.overlay_dirty.set(true);
3473                self.rebuild_overlay()?;
3474            }
3475            self.prune_unknown_subscriptions(false)?;
3476            self.save_subscription_scope_schema()?;
3477            // The conformance recreate is the in-memory equivalent of reopening
3478            // a durable client. Apply the same startup catch-up contract even
3479            // when the schema itself did not change.
3480            self.enqueue_startup_sync_if_needed()?;
3481            Ok(())
3482        })();
3483        let outcome = match guarded {
3484            Ok(()) => self
3485                .conn
3486                .execute_batch("COMMIT")
3487                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
3488            Err(error) => Err(error),
3489        };
3490        if let Err(error) = outcome {
3491            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3492                // Keep a failed rollback's typed storage classification.
3493                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
3494            }
3495            self.schema = prior_schema;
3496            self.restore(rollback);
3497            // Prepared insert statements were derived from the reverted schema.
3498            self.insert_sql.borrow_mut().clear();
3499            return Err(error);
3500        }
3501        Ok(())
3502    }
3503
3504    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
3505        let startup_work = !self.stopped
3506            && (!self.outbox.is_empty()
3507                || self.has_acknowledged_rows()?
3508                || !self.load_pending_evictions()?.is_empty()
3509                || self.subs.iter().any(|sub| sub.state == SubState::Active));
3510        if startup_work {
3511            self.sync_needed = true;
3512            self.sync_intent_queue.push_back(SyncIntent::Interactive);
3513        }
3514        Ok(())
3515    }
3516
3517    /// A native client persists schema-floor stops across process restarts.
3518    /// Once the running app already satisfies that floor, the persisted stop
3519    /// is only stale evidence from an older server round (for example, the
3520    /// server was restarted after catching up to an app that was ahead).
3521    /// Clear it and let the normal startup pull re-negotiate. If the server is
3522    /// still incompatible it will return the floor again in that first round.
3523    fn clear_satisfied_persisted_schema_floor(&mut self) -> Result<(), String> {
3524        let satisfied = self
3525            .schema_floor
3526            .as_ref()
3527            .and_then(|floor| floor.required_schema_version)
3528            .is_some_and(|required| self.schema.version >= required);
3529        if !satisfied {
3530            return Ok(());
3531        }
3532        self.delete_meta(SCHEMA_FLOOR_KEY)?;
3533        self.schema_floor = None;
3534        self.stopped = false;
3535        Ok(())
3536    }
3537
3538    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
3539    /// path already swept it; this covers a same-version or fresh-install open,
3540    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
3541    /// an aware binary can apply.
3542    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
3543        let Some(replica_path) = self.previous_version_replica_path() else {
3544            return Ok(());
3545        };
3546        let max_age_ms = self
3547            .previous_version
3548            .filter(|config| config.enabled)
3549            .map(|config| config.max_age_ms);
3550        reconcile_previous_version_at_boot(
3551            &self.conn,
3552            &replica_path,
3553            self.schema.version,
3554            max_age_ms,
3555            self.clock_now_ms(),
3556        )
3557    }
3558
3559    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
3560    /// no file — an in-memory replica returns an empty path, and a `file:` URI
3561    /// has no plain sibling path — so the feature resolves as off there.
3562    fn previous_version_replica_path(&self) -> Option<String> {
3563        let path = self.conn.path()?;
3564        if path.is_empty() || path.starts_with("file:") {
3565            return None;
3566        }
3567        Some(path.to_owned())
3568    }
3569
3570    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
3571    /// and leaseState. Drops/recreates every synced table from the new
3572    /// schema, resets subscription sync-state (keeping registrations), clears
3573    /// the schema-floor stop state, rewrites the marker, drops outbox commits
3574    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
3575    fn run_schema_reset(&mut self) -> Result<(), String> {
3576        self.begin_observation("syncular_schema_reset")?;
3577        let rollback = self.capture();
3578        let mut batch = ChangeAccumulator::default();
3579        let result = self
3580            .run_schema_reset_observed(&mut batch, true)
3581            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
3582        if let Err(error) = result {
3583            self.rollback_observation("syncular_schema_reset");
3584            self.restore(rollback);
3585            return Err(error);
3586        }
3587        Ok(())
3588    }
3589
3590    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
3591        if self.get_meta(LOG_EPOCH_KEY)?.is_none() {
3592            self.begin_observation("syncular_epoch_acquisition")?;
3593            let rollback = self.capture();
3594            if let Err(error) = self.conn.execute(
3595                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
3596                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
3597            ) {
3598                self.rollback_observation("syncular_epoch_acquisition");
3599                return Err(Self::sqlite_failure(&self.storage_failure, error));
3600            }
3601            self.sync_needed = true;
3602            let batch = ChangeAccumulator {
3603                status: true,
3604                ..ChangeAccumulator::default()
3605            };
3606            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
3607                self.rollback_observation("syncular_epoch_acquisition");
3608                self.restore(rollback);
3609                return Err(error);
3610            }
3611            self.sync_intent_queue.push_back(SyncIntent::Interactive);
3612            return Ok(Vec::new());
3613        }
3614        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
3615        self.begin_observation("syncular_log_epoch_reset")?;
3616        let rollback = self.capture();
3617        let mut batch = ChangeAccumulator::default();
3618        let result = self
3619            .run_schema_reset_observed(&mut batch, false)
3620            .and_then(|()| {
3621                self.conn
3622                    .execute(
3623                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
3624                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
3625                    )
3626                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3627                self.sync_needed = true;
3628                batch.status = true;
3629                self.finish_observation("syncular_log_epoch_reset", batch)
3630            });
3631        if let Err(error) = result {
3632            self.rollback_observation("syncular_log_epoch_reset");
3633            self.restore(rollback);
3634            return Err(error);
3635        }
3636        self.sync_intent_queue.push_back(SyncIntent::Interactive);
3637        Ok(resets)
3638    }
3639
3640    fn run_schema_reset_observed(
3641        &mut self,
3642        batch: &mut ChangeAccumulator,
3643        capture: bool,
3644    ) -> Result<(), String> {
3645        // §7.4.2: the reset is the only path that wipes local tables for a
3646        // version bump, and it rewrites the marker last. Re-read the marker
3647        // under this reset's transaction: a replica another process upgraded
3648        // meanwhile refuses here instead of being reset to this client's
3649        // older schema.
3650        read_local_schema_version(&self.conn, self.schema.version)?;
3651        self.conn
3652            .execute_batch(
3653                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
3654            )
3655            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3656        self.cancel_sync_round();
3657        self.upgrading = true;
3658        batch.status = true;
3659        // RFC 0005 D6: classify the pending outbox against the NEW compiled
3660        // schema BEFORE the wipe, and record the advisory audit. This pass
3661        // drops nothing, so the audit sees the original pending intent; the
3662        // §7.4.4 send-time drop and the intrinsic value recovery later in this
3663        // reset are the paths that remove a commit. The classification is
3664        // shared with the send-time drop so the two cannot disagree about what
3665        // is incompatible.
3666        if self.previous_version.is_some_and(|config| config.enabled) {
3667            let previous_version = self
3668                .get_meta(LOCAL_SCHEMA_VERSION_KEY)?
3669                .and_then(|value| value.parse::<i32>().ok())
3670                .unwrap_or(self.schema.version);
3671            let pending = self
3672                .outbox
3673                .iter()
3674                .map(|commit| PendingCommitAudit {
3675                    commit_id: &commit.client_commit_id,
3676                    operations: commit_audit_operations(commit),
3677                })
3678                .collect::<Vec<_>>();
3679            crate::previous_version::write_previous_version_audit(
3680                &self.conn,
3681                &crate::previous_version::build_previous_version_audit(
3682                    &self.schema,
3683                    &pending,
3684                    previous_version,
3685                    self.schema.version,
3686                    self.clock_now_ms(),
3687                ),
3688            )?;
3689        }
3690        // RFC 0005 D5: the container lives in its own FILE, so the reset can
3691        // never reach it. Sweep first (unconditional — also on the log-epoch
3692        // reset, which captures nothing), then capture BEFORE the wipe drops
3693        // any row. A refused capture records its reason and leaves no file.
3694        if let Some(replica_path) = self.previous_version_replica_path() {
3695            sweep_previous_version_container(&self.conn, &replica_path)?;
3696            if capture {
3697                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
3698                    let previous_version = self
3699                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)?
3700                        .and_then(|value| value.parse::<i32>().ok())
3701                        .unwrap_or(self.schema.version);
3702                    capture_previous_version_from_replica(
3703                        &self.conn,
3704                        &replica_path,
3705                        previous_version,
3706                        self.schema.version,
3707                        &config,
3708                        self.clock_now_ms(),
3709                    )?;
3710                }
3711            }
3712        }
3713        for table in &self.schema.tables {
3714            batch.table(&table.name);
3715        }
3716        for (base_key, unit, table) in self.load_registered_window_units() {
3717            batch.window(&base_key, &table, &unit);
3718        }
3719        if capture {
3720            self.prune_unknown_subscriptions(true)?;
3721        }
3722        // The per-table insert SQL is derived from the OLD column lists.
3723        self.insert_sql.borrow_mut().clear();
3724        self.overlay_dirty.set(true);
3725        // Drop every synced table (base + visible) that currently exists —
3726        // discovered from sqlite_master so a bump that adds/removes tables is
3727        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
3728        // and the blob cache) are preserved; base tables are `_syncular_base_*`
3729        // so they are matched explicitly, not by the bookkeeping filter.
3730        // Drop virtual tables first so SQLite removes every FTS shadow table
3731        // atomically instead of the generic discovery tearing it apart.
3732        let virtual_tables: Vec<String> = {
3733            let mut stmt = self
3734                .conn
3735                .prepare(
3736                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
3737                )
3738                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3739            let rows = stmt
3740                .query_map([], |row| row.get::<_, String>(0))
3741                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3742            rows.filter_map(Result::ok)
3743                .filter(|name| is_synced_table_name(name))
3744                .collect()
3745        };
3746        for name in virtual_tables {
3747            self.conn
3748                .execute_batch(&format!(
3749                    "DROP TABLE IF EXISTS {}",
3750                    quote_ident(&format!("_syncular_fts_{name}"))
3751                ))
3752                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3753            self.conn
3754                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3755                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3756        }
3757        let existing: Vec<String> = {
3758            let mut stmt = self
3759                .conn
3760                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
3761                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3762            let rows = stmt
3763                .query_map([], |row| row.get::<_, String>(0))
3764                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3765            rows.filter_map(Result::ok)
3766                .filter(|name| is_synced_table_name(name))
3767                .collect()
3768        };
3769        for name in &existing {
3770            if let Some(table) = name.strip_prefix("_syncular_base_") {
3771                batch.table(table);
3772            } else if !name.starts_with("_syncular_") {
3773                batch.table(name);
3774            }
3775        }
3776        for name in existing {
3777            self.conn
3778                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3779                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3780        }
3781        // Recreate the synced tables from the NEW schema.
3782        self.create_synced_tables()?;
3783        // Reset every subscription's sync-state, keeping the registration.
3784        for sub in self.subs.iter_mut() {
3785            sub.cursor = -1;
3786            sub.bootstrap_state = None;
3787            sub.effective = None;
3788            sub.state = SubState::Active;
3789            sub.reason_code = None;
3790            sub.synced_once = false;
3791        }
3792        let subs = self.subs.clone();
3793        for sub in subs.iter() {
3794            self.persist_sub(sub)?;
3795        }
3796        // The stop state is over: this client now ships a servable schema.
3797        self.stopped = false;
3798        self.schema_floor = None;
3799        self.delete_meta(SCHEMA_FLOOR_KEY)?;
3800        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
3801        self.persist_schema_version()?;
3802        self.save_subscription_scope_schema()?;
3803        // §7.1/§7.4.4: the previous-version capture and audit above saw the
3804        // original pending intent; the synced tables now carry the new schema, so
3805        // recover intrinsically codec-invalid persisted values here, before the
3806        // final replay. A pending upsert that names a column the new schema
3807        // removed stays pending for the send-time `drop_incompatible_outbox`
3808        // prepass. The reset wrapper snapshots and restores the outbox,
3809        // rejections, and change queues, so a failed commit rolls this recovery
3810        // back with the rest of the reset.
3811        // §7.4.4: the enclosing reset observation owns the publication, so the
3812        // recovery contributes its rejection, outcome, and table domains to it.
3813        self.drop_unencodable_outbox(false, Some(batch))?;
3814        // Re-apply the surviving outbox optimistically over the empty tables.
3815        self.rebuild_overlay()?;
3816        Ok(())
3817    }
3818
3819    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
3820    /// column the current schema lacks (or a removed table) cannot be encoded
3821    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
3822    /// encodable and is validated by the server), nor can an insert-intent
3823    /// upsert that lacks a non-nullable column (§7.1, the audit's
3824    /// `missing-required-column`). Drop the commit, undo
3825    /// its optimistic projection, and raise a client-local
3826    /// `sync.outbox_incompatible` rejection.
3827    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
3828        let incompatible = self
3829            .outbox
3830            .iter()
3831            .filter(|commit| {
3832                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
3833            })
3834            .cloned()
3835            .collect::<Vec<_>>();
3836        if incompatible.is_empty() {
3837            return Ok(false);
3838        }
3839        self.drop_outbox_commits(
3840            &incompatible,
3841            OUTBOX_INCOMPATIBLE_CODE,
3842            "the persisted commit cannot encode under the current schema",
3843        )?;
3844        Ok(true)
3845    }
3846
3847    /// §7.4.4 incompatible-commit path, also taken by a refused blob upload
3848    /// (§5.9.7) with the server's code: reject every operation of `commits`,
3849    /// remove them from the outbox, and undo their optimistic rows.
3850    fn drop_outbox_commits(
3851        &mut self,
3852        incompatible: &[Arc<OutboxCommit>],
3853        code: &str,
3854        message: &str,
3855    ) -> Result<(), String> {
3856        // §7.4.4: one observation covers the durable outcome writes, the outbox
3857        // removal, the in-memory drop, and the overlay rebuild, so a storage
3858        // fault leaves the replica and the client unchanged and publishes no
3859        // change; a successful drop publishes exactly one batch carrying the
3860        // rejections, outcomes, status, and the tables whose projection changed.
3861        let rollback = self.capture();
3862        let owns_transaction = self.conn.is_autocommit();
3863        self.begin_observation("syncular_outbox_drop")?;
3864        let mut batch = ChangeAccumulator::default();
3865        let dropped = (|| -> Result<(), String> {
3866            let mut rejections = Vec::new();
3867            for commit in incompatible {
3868                let results = commit
3869                    .ops
3870                    .iter()
3871                    .enumerate()
3872                    .map(|(op_index, operation)| {
3873                        let rejection = RejectionRecord {
3874                            client_commit_id: commit.client_commit_id.clone(),
3875                            op_index: op_index as i32,
3876                            code: code.to_owned(),
3877                            message: message.to_owned(),
3878                            retryable: false,
3879                            details: None,
3880                            operation: Some(CommitOperation::from(operation)),
3881                        };
3882                        rejections.push(rejection.clone());
3883                        CommitOperationOutcome::Error { rejection }
3884                    })
3885                    .collect::<Vec<_>>();
3886                self.persist_commit_outcome(
3887                    &commit.client_commit_id,
3888                    CommitOutcomeStatus::Rejected,
3889                    &results,
3890                    Some(&commit.ops),
3891                )?;
3892                self.delete_outbox_persisted(&commit.client_commit_id)?;
3893                // A removed table has no local projection to invalidate; every
3894                // surviving table the dropped commit touched does.
3895                for operation in &commit.ops {
3896                    if self.schema.table(&operation.table).is_some() {
3897                        batch.table(&operation.table);
3898                    }
3899                }
3900            }
3901            self.prune_commit_outcomes()?;
3902            let incompatible_ids = incompatible
3903                .iter()
3904                .map(|commit| commit.client_commit_id.as_str())
3905                .collect::<BTreeSet<_>>();
3906            self.outbox
3907                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
3908            self.rejections.extend(rejections);
3909            // The dropped commits' purely-optimistic rows are undone: the
3910            // visible projection is re-derived from the surviving base + outbox.
3911            self.overlay_dirty.set(true);
3912            self.rebuild_overlay()?;
3913            batch.status = true;
3914            batch.rejections = true;
3915            batch.outcomes = true;
3916            Ok(())
3917        })();
3918        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
3919        if let Err(error) = result {
3920            // An outer RELEASE is the COMMIT. Roll back that transaction
3921            // directly: retrying RELEASE after ROLLBACK TO still needs the
3922            // reader's lock. A nested observation only rolls back its savepoint.
3923            if owns_transaction {
3924                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3925                    let mut failure = self.storage_failure.borrow_mut();
3926                    let failure =
3927                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
3928                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
3929                }
3930            } else {
3931                self.rollback_observation("syncular_outbox_drop");
3932            }
3933            self.restore(rollback);
3934            return Err(error);
3935        }
3936        Ok(())
3937    }
3938
3939    /// §7.4.3: (re)create the base + visible table pair for every synced
3940    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
3941    fn create_synced_tables(&self) -> Result<(), String> {
3942        for table in &self.schema.tables {
3943            // The base half + the visible half form the synced-table pair. An
3944            // index name is global in SQLite, so the base half's indexes are
3945            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
3946            for (full, index_prefix) in [
3947                (base_table(&table.name), "_syncular_base_"),
3948                (visible_table(&table.name), ""),
3949            ] {
3950                let mut cols: Vec<String> =
3951                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
3952                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
3953                let sql = format!(
3954                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
3955                    cols.join(", "),
3956                    quote_ident(&table.primary_key)
3957                );
3958                self.conn
3959                    .execute(&sql, [])
3960                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3961                // Local secondary indexes (CREATE INDEX subset). Created on
3962                // both halves so mirror reads hit an index on either. Runs on
3963                // both the initial create and the §7.4.3 reset recreate path.
3964                for index in &table.indexes {
3965                    let unique = if index.unique { "UNIQUE " } else { "" };
3966                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
3967                    let cols_sql = index
3968                        .columns
3969                        .iter()
3970                        .map(|c| quote_ident(c))
3971                        .collect::<Vec<_>>()
3972                        .join(", ");
3973                    let index_sql = format!(
3974                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
3975                    );
3976                    self.conn
3977                        .execute(&index_sql, [])
3978                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3979                }
3980            }
3981        }
3982        // FTS exists only on the visible half. It is a local search
3983        // projection, never a synced/base table or a wire-schema member.
3984        for table in &self.schema.tables {
3985            for index in &table.fts_indexes {
3986                self.create_fts_projection(table, index)?;
3987            }
3988        }
3989        Ok(())
3990    }
3991
3992    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
3993        let count: i64 = self
3994            .conn
3995            .query_row(
3996                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
3997                rusqlite::params![index.name],
3998                |row| row.get(0),
3999            )
4000            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4001        Ok(count > 0)
4002    }
4003
4004    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
4005        for suffix in ["ai", "ad", "au"] {
4006            self.conn
4007                .execute(
4008                    &format!(
4009                        "DROP TRIGGER IF EXISTS {}",
4010                        quote_ident(&format!("{}_{suffix}", index.name))
4011                    ),
4012                    [],
4013                )
4014                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4015        }
4016        Ok(())
4017    }
4018
4019    fn create_fts_triggers(
4020        &self,
4021        table: &TableSchema,
4022        index: &FtsIndexSchema,
4023    ) -> Result<(), String> {
4024        let fts = quote_ident(&index.name);
4025        let source = visible_table(&table.name);
4026        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
4027        let pk = quote_ident(&table.primary_key);
4028        let projection_columns = std::iter::once(source_id.clone())
4029            .chain(index.columns.iter().map(|column| quote_ident(column)))
4030            .collect::<Vec<_>>()
4031            .join(", ");
4032        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
4033            .chain(
4034                index
4035                    .columns
4036                    .iter()
4037                    .map(|column| format!("new.{}", quote_ident(column))),
4038            )
4039            .collect::<Vec<_>>()
4040            .join(", ");
4041        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4042        let delete_new = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)");
4043        let delete_old = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)");
4044        let insert_new = format!("INSERT INTO {mapping}(source_id) VALUES (CAST(new.{pk} AS TEXT)); INSERT INTO {fts} (rowid, {projection_columns}) VALUES ((SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)), {new_values})");
4045        let bi = quote_ident(&format!("{}_bi", index.name));
4046        let ai = quote_ident(&format!("{}_ai", index.name));
4047        let ad = quote_ident(&format!("{}_ad", index.name));
4048        let au = quote_ident(&format!("{}_au", index.name));
4049        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
4050        let sql = format!(
4051            "DROP TRIGGER IF EXISTS {bi};
4052             DROP TRIGGER IF EXISTS {ai};
4053             DROP TRIGGER IF EXISTS {ad};
4054             DROP TRIGGER IF EXISTS {au};
4055             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
4056             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
4057             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
4058             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
4059        );
4060        self.conn
4061            .execute_batch(&sql)
4062            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4063    }
4064
4065    fn rebuild_fts_projection(
4066        &self,
4067        table: &TableSchema,
4068        index: &FtsIndexSchema,
4069    ) -> Result<(), String> {
4070        let fts = quote_ident(&index.name);
4071        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
4072        let pk = quote_ident(&table.primary_key);
4073        let indexed_columns = index
4074            .columns
4075            .iter()
4076            .map(|column| quote_ident(column))
4077            .collect::<Vec<_>>();
4078        let projection_columns = std::iter::once(source_id.clone())
4079            .chain(indexed_columns.iter().cloned())
4080            .collect::<Vec<_>>()
4081            .join(", ");
4082        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4083        let sql = format!(
4084            "DELETE FROM {fts}; DELETE FROM {mapping}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source}; INSERT INTO {mapping}(id, source_id) SELECT rowid, {source_id} FROM {fts};",
4085            columns = indexed_columns.join(", "),
4086            source = visible_table(&table.name),
4087        );
4088        self.conn
4089            .execute_batch(&sql)
4090            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4091    }
4092
4093    fn create_fts_projection(
4094        &self,
4095        table: &TableSchema,
4096        index: &FtsIndexSchema,
4097    ) -> Result<(), String> {
4098        self.begin_observation("syncular_fts_schema")?;
4099        let result = (|| {
4100            let existed = self.fts_projection_exists(index)?;
4101            let tokenizer = index.tokenize.replace('\'', "''");
4102            let columns = index
4103                .columns
4104                .iter()
4105                .map(|column| quote_ident(column))
4106                .collect::<Vec<_>>()
4107                .join(", ");
4108            let sql = format!(
4109            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
4110            fts = quote_ident(&index.name),
4111            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
4112        );
4113            self.conn.execute(&sql, []).map_err(|error| {
4114                format!(
4115                    "cannot create local FTS5 projection {:?}: {error}",
4116                    index.name
4117                )
4118            })?;
4119            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4120            let mapped: bool = self
4121                .conn
4122                .query_row(
4123                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
4124                    [format!("_syncular_fts_{}", index.name)],
4125                    |row| row.get(0),
4126                )
4127                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4128            self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4129            if !mapped && existed {
4130                self.conn
4131                    .execute_batch(&format!(
4132                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
4133                        quote_ident(FTS_SOURCE_ID_COLUMN),
4134                        quote_ident(&index.name)
4135                    ))
4136                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4137            }
4138            self.create_fts_triggers(table, index)?;
4139            if !existed {
4140                self.rebuild_fts_projection(table, index)?;
4141            }
4142            Ok(())
4143        })();
4144        if let Err(error) = result {
4145            self.rollback_observation("syncular_fts_schema");
4146            return Err(error);
4147        }
4148        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
4149            self.rollback_observation("syncular_fts_schema");
4150            return Err(error.to_string());
4151        }
4152        Ok(())
4153    }
4154
4155    // -- persistence write-through --------------------------------------------
4156
4157    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
4158        let state = serde_json::json!({
4159            "requested": scope_map_to_json(&sub.requested),
4160            "params": sub.params,
4161            "cursor": sub.cursor,
4162            "bootstrapState": sub.bootstrap_state,
4163            "status": sub.state.name(),
4164            "reasonCode": sub.reason_code,
4165            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
4166            "syncedOnce": sub.synced_once,
4167        });
4168        self.conn.execute(
4169            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
4170            rusqlite::params![sub.id, sub.table, state.to_string()],
4171        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4172    }
4173
4174    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
4175        let ops: Vec<Value> = commit
4176            .ops
4177            .iter()
4178            .map(|op| {
4179                let mut entry = serde_json::json!({
4180                    "op": if op.upsert { "upsert" } else { "delete" },
4181                    "table": op.table,
4182                    "rowId": op.row_id,
4183                    "baseVersion": op.base_version,
4184                    "values": op.values.clone().map(Value::Object),
4185                });
4186                if op.insert {
4187                    entry["insert"] = Value::Bool(true);
4188                }
4189                entry
4190            })
4191            .collect();
4192        self.conn
4193            .execute(
4194                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
4195                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
4196            )
4197            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4198        for operation in &commit.ops {
4199            let Some(values) = &operation.values else {
4200                continue;
4201            };
4202            let Some(table) = self.schema.table(&operation.table) else {
4203                continue;
4204            };
4205            for column in table
4206                .columns
4207                .iter()
4208                .filter(|column| column.ty == ColumnType::BlobRef)
4209            {
4210                let Some(Value::String(raw)) = values.get(&column.name) else {
4211                    continue;
4212                };
4213                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
4214                    continue;
4215                };
4216                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
4217                    continue;
4218                };
4219                self.conn
4220                    .execute(
4221                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
4222                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
4223                        rusqlite::params![commit.client_commit_id, blob_id],
4224                    )
4225                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4226            }
4227        }
4228        Ok(())
4229    }
4230
4231    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
4232        self.conn
4233            .execute(
4234                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
4235                rusqlite::params![client_commit_id],
4236            )
4237            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4238        self.conn
4239            .execute(
4240                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
4241                rusqlite::params![client_commit_id],
4242            )
4243            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4244        self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
4245            .map(|_| ())
4246            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4247    }
4248
4249    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
4250        match status {
4251            CommitOutcomeStatus::Applied => "applied",
4252            CommitOutcomeStatus::Cached => "cached",
4253            CommitOutcomeStatus::Conflict => "conflict",
4254            CommitOutcomeStatus::Rejected => "rejected",
4255        }
4256    }
4257
4258    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
4259        match resolution {
4260            CommitOutcomeResolution::Active => "active",
4261            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
4262            CommitOutcomeResolution::Superseded => "superseded",
4263            CommitOutcomeResolution::Dismissed => "dismissed",
4264        }
4265    }
4266
4267    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
4268        match value {
4269            "applied" => Ok(CommitOutcomeStatus::Applied),
4270            "cached" => Ok(CommitOutcomeStatus::Cached),
4271            "conflict" => Ok(CommitOutcomeStatus::Conflict),
4272            "rejected" => Ok(CommitOutcomeStatus::Rejected),
4273            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
4274        }
4275    }
4276
4277    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
4278        match value {
4279            "active" => Ok(CommitOutcomeResolution::Active),
4280            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
4281            "superseded" => Ok(CommitOutcomeResolution::Superseded),
4282            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
4283            _ => Err(format!(
4284                "invalid persisted commit outcome resolution {value:?}"
4285            )),
4286        }
4287    }
4288
4289    fn persist_commit_outcome(
4290        &self,
4291        client_commit_id: &str,
4292        status: CommitOutcomeStatus,
4293        results: &[CommitOperationOutcome],
4294        operations: Option<&[OutboxOp]>,
4295    ) -> Result<(), String> {
4296        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
4297        let operations_json = operations
4298            .map(|items| {
4299                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
4300            })
4301            .transpose()
4302            .map_err(|error| error.to_string())?;
4303        self.conn
4304            .execute(
4305                "INSERT INTO _syncular_commit_outcomes (
4306                   client_commit_id, status, recorded_at_ms, results_json,
4307                   operations_json, resolution
4308                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
4309                rusqlite::params![
4310                    client_commit_id,
4311                    Self::outcome_status_name(status),
4312                    self.clock_now_ms(),
4313                    results_json,
4314                    operations_json
4315                ],
4316            )
4317            .map(|_| ())
4318            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4319    }
4320
4321    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
4322    /// the byte-envelope rule refuses cannot be journaled verbatim.
4323    fn stored_cell_is_representable(cell: &Value) -> bool {
4324        cell.is_null()
4325            || cell.is_string()
4326            || cell.is_boolean()
4327            || cell.is_number()
4328            || is_canonical_bytes_envelope(cell)
4329    }
4330
4331    fn stored_row_is_representable(value: &Value) -> bool {
4332        value
4333            .as_object()
4334            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
4335    }
4336
4337    fn stored_operation_is_representable(value: &Value) -> bool {
4338        value.get("table").is_some_and(Value::is_string)
4339            && value.get("rowId").is_some_and(Value::is_string)
4340            && value.get("baseVersion").is_none_or(|v| {
4341                v.as_i64()
4342                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
4343            })
4344            && match value.get("op").and_then(Value::as_str) {
4345                Some("upsert") => {
4346                    value
4347                        .get("values")
4348                        .is_some_and(Self::stored_row_is_representable)
4349                        && value
4350                            .get("insert")
4351                            .is_none_or(|insert| *insert == Value::Bool(true))
4352                }
4353                Some("delete") => value.get("values").is_none() && value.get("insert").is_none(),
4354                _ => false,
4355            }
4356    }
4357
4358    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
4359    /// only when the journal reader accepts the serialized operation, which is the
4360    /// rule `outcome_from_row` applies to this same row later.
4361    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
4362        serde_json::to_value(CommitOperation::from(operation))
4363            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
4364    }
4365
4366    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
4367        let StoredCommitOutcomeRow {
4368            sequence,
4369            client_commit_id,
4370            status,
4371            recorded_at_ms,
4372            results_json,
4373            operations_json,
4374            resolution,
4375            resolved_at_ms,
4376            replacement_client_commit_id,
4377        } = row;
4378        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
4379            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
4380            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
4381        {
4382            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
4383        }
4384        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
4385        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
4386        for result in &stored {
4387            let nested = match result.get("status").and_then(Value::as_str) {
4388                Some("conflict") => result.get("conflict"),
4389                Some("error") => result.get("rejection"),
4390                _ => None,
4391            };
4392            if nested
4393                .and_then(|record| record.get("operation"))
4394                .is_some_and(|op| !Self::stored_operation_is_representable(op))
4395                || (result.get("status").and_then(Value::as_str) == Some("conflict")
4396                    && nested
4397                        .and_then(|record| record.get("serverRow"))
4398                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
4399            {
4400                return Err(invalid());
4401            }
4402        }
4403        let results: Vec<CommitOperationOutcome> =
4404            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
4405        let has_conflict = results
4406            .iter()
4407            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
4408        let has_error = results
4409            .iter()
4410            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
4411        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
4412        let redacted = results.is_empty() && operations_json.is_none();
4413        if match status {
4414            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
4415            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
4416            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
4417        } {
4418            return Err(invalid());
4419        }
4420        let operations: Option<Vec<CommitOperation>> = operations_json
4421            .map(|raw| {
4422                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
4423                if !stored.iter().all(Self::stored_operation_is_representable) {
4424                    return Err(invalid());
4425                }
4426                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
4427            })
4428            .transpose()?;
4429        for result in &results {
4430            match result {
4431                CommitOperationOutcome::Conflict { conflict }
4432                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
4433                {
4434                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
4435                }
4436                CommitOperationOutcome::Error { rejection } => {
4437                    if let Some(details) = &rejection.details {
4438                        let encoded = serde_json::to_string(details).map_err(|_| {
4439                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
4440                        })?;
4441                        RejectionDetails::parse(&encoded).map_err(|_| {
4442                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
4443                        })?;
4444                    }
4445                }
4446                _ => {}
4447            }
4448        }
4449        Ok(CommitOutcome {
4450            sequence,
4451            client_commit_id,
4452            status,
4453            recorded_at_ms,
4454            results,
4455            operations,
4456            retained_rows: None,
4457            resolution: Self::parse_outcome_resolution(&resolution)?,
4458            resolved_at_ms,
4459            replacement_client_commit_id,
4460        })
4461    }
4462
4463    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
4464    fn unique_conflicts(
4465        &self,
4466        table: &TableSchema,
4467        values: &Map<String, Value>,
4468        base: bool,
4469    ) -> Result<Vec<RetainedUniqueConflict>, String> {
4470        let source = if base {
4471            base_table(&table.name)
4472        } else {
4473            visible_table(&table.name)
4474        };
4475        let mut conflicts = Vec::new();
4476        for index in table.indexes.iter().filter(|index| index.unique) {
4477            let mut params = vec![json_param_to_sql(
4478                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
4479            )?];
4480            for name in &index.columns {
4481                let column = table
4482                    .columns
4483                    .iter()
4484                    .find(|column| column.name == *name)
4485                    .ok_or("sync.local_corrupt")?;
4486                let cell = json_to_column_value(column, values.get(name))?;
4487                params.push(owned_sql_value(RowParam::Cell(&cell))?);
4488            }
4489            let sql = format!(
4490                "SELECT * FROM {source} WHERE {} != ? AND {}",
4491                quote_ident(&table.primary_key),
4492                index
4493                    .columns
4494                    .iter()
4495                    .map(|column| format!("{} = ?", quote_ident(column)))
4496                    .collect::<Vec<_>>()
4497                    .join(" AND ")
4498            );
4499            let mut statement = self
4500                .conn
4501                .prepare_cached(&sql)
4502                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4503            let records = statement
4504                .query_map(rusqlite::params_from_iter(params), |record| {
4505                    let values = table
4506                        .columns
4507                        .iter()
4508                        .enumerate()
4509                        .map(|(index, column)| {
4510                            let value = sql_ref_to_json(column, record.get_ref(index)?);
4511                            json_to_column_value(column, Some(&value)).map_err(|message| {
4512                                rusqlite::Error::ToSqlConversionFailure(message.into())
4513                            })
4514                        })
4515                        .collect::<Result<Row, _>>()?;
4516                    Ok((values, record.get::<_, i64>(table.columns.len())?))
4517                })
4518                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4519            for record in records {
4520                let (row, version) =
4521                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4522                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
4523                conflicts.push(RetainedUniqueConflict {
4524                    index: index.name.clone(),
4525                    columns: index.columns.clone(),
4526                    row_id,
4527                    server_row: table
4528                        .columns
4529                        .iter()
4530                        .zip(&row)
4531                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
4532                        .collect(),
4533                    server_version: version,
4534                });
4535            }
4536        }
4537        Ok(conflicts)
4538    }
4539
4540    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
4541        if let Some((commit, initial)) = self
4542            .failed_commits
4543            .iter()
4544            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
4545        {
4546            let mut rows = Vec::new();
4547            for operation in &commit.ops {
4548                // A table the current schema removed has no rows to retain.
4549                let Some(table) = self.schema.table(&operation.table) else {
4550                    continue;
4551                };
4552                let key =
4553                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
4554                        .map_err(|_| "sync.local_corrupt".to_owned())?;
4555                let base = self.stored_row(table, &operation.row_id, true)?;
4556                rows.push(RetainedCommitRow {
4557                    table: operation.table.clone(),
4558                    row_id: operation.row_id.clone(),
4559                    unique_conflicts: if operation.upsert {
4560                        self.unique_conflicts(
4561                            table,
4562                            initial
4563                                .get(&key)
4564                                .and_then(Value::as_object)
4565                                .or(operation.values.as_ref())
4566                                .ok_or("sync.local_corrupt")?,
4567                            true,
4568                        )?
4569                    } else {
4570                        Vec::new()
4571                    },
4572                    local_row: if operation.upsert {
4573                        initial
4574                            .get(&key)
4575                            .and_then(Value::as_object)
4576                            .cloned()
4577                            .or_else(|| operation.values.clone())
4578                    } else {
4579                        None
4580                    },
4581                    server_row: base.as_ref().map(|(values, _)| {
4582                        table
4583                            .columns
4584                            .iter()
4585                            .zip(values)
4586                            .map(|(column, value)| {
4587                                (column.name.clone(), column_value_to_json(value))
4588                            })
4589                            .collect()
4590                    }),
4591                    server_version: base.map(|(_, version)| version),
4592                });
4593            }
4594            outcome.retained_rows = Some(rows);
4595        }
4596        Ok(outcome)
4597    }
4598
4599    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
4600        let row = self
4601            .conn
4602            .query_row(
4603                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
4604                        resolution, resolved_at_ms, replacement_client_commit_id
4605                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
4606                rusqlite::params![client_commit_id],
4607                |row| {
4608                    Ok(StoredCommitOutcomeRow {
4609                        sequence: row.get(0)?,
4610                        client_commit_id: row.get(1)?,
4611                        status: row.get(2)?,
4612                        recorded_at_ms: row.get(3)?,
4613                        results_json: row.get(4)?,
4614                        operations_json: row.get(5)?,
4615                        resolution: row.get(6)?,
4616                        resolved_at_ms: row.get(7)?,
4617                        replacement_client_commit_id: row.get(8)?,
4618                    })
4619                },
4620            )
4621            .optional()
4622            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4623        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
4624            .transpose()
4625    }
4626
4627    /// Internal host step. The host must run its idle drive before finishing.
4628    #[doc(hidden)]
4629    pub fn prepare_current_base(
4630        &self,
4631        client_commit_id: &str,
4632        sequence: i64,
4633    ) -> Result<CurrentBasePreparation, CurrentBaseProof> {
4634        let outcome = self
4635            .commit_outcome(client_commit_id)
4636            .map_err(|_| CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed))?;
4637        let valid = outcome.as_ref().is_some_and(|outcome| {
4638            outcome.sequence == sequence
4639                && outcome.resolution == CommitOutcomeResolution::Active
4640                && matches!(
4641                    outcome.status,
4642                    CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
4643                )
4644                && outcome
4645                    .operations
4646                    .as_ref()
4647                    .zip(outcome.retained_rows.as_ref())
4648                    .is_some_and(|(operations, rows)| {
4649                        !rows.is_empty() && operations.len() == rows.len()
4650                    })
4651        });
4652        if !valid {
4653            return Err(CurrentBaseProof::failure(
4654                CurrentBaseProofReason::OutcomeUnavailable,
4655            ));
4656        }
4657        if !self.transport_enabled {
4658            return Err(CurrentBaseProof::failure(CurrentBaseProofReason::Offline));
4659        }
4660        Ok(CurrentBasePreparation {
4661            client_commit_id: client_commit_id.to_owned(),
4662            sequence,
4663            subscriptions: self.subs.to_vec(),
4664            transport_epoch: self.transport_epoch,
4665            subscription_epoch: self.subscription_epoch,
4666            security_epoch: self.security_epoch,
4667        })
4668    }
4669
4670    /// Internal host step after the completed post-rejection idle drive.
4671    #[doc(hidden)]
4672    pub fn finish_current_base(
4673        &mut self,
4674        prepared: CurrentBasePreparation,
4675        drive: SyncOutcome,
4676    ) -> CurrentBaseProof {
4677        if !self.transport_enabled || self.transport_epoch != prepared.transport_epoch {
4678            return CurrentBaseProof::failure(CurrentBaseProofReason::Offline);
4679        }
4680        if self.subscription_epoch != prepared.subscription_epoch {
4681            return CurrentBaseProof::failure(CurrentBaseProofReason::MissingCoverage);
4682        }
4683        if self.security_preflight || self.security_epoch != prepared.security_epoch {
4684            return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed);
4685        }
4686        let report = match drive {
4687            SyncOutcome::Ok(report) => report,
4688            SyncOutcome::BudgetExhausted(_) => {
4689                return CurrentBaseProof::failure(CurrentBaseProofReason::BudgetExhausted)
4690            }
4691            _ => return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed),
4692        };
4693        if report.schema_floor.is_some() {
4694            return CurrentBaseProof::failure(CurrentBaseProofReason::SchemaFloor);
4695        }
4696        if self
4697            .conn
4698            .execute_batch("SAVEPOINT syncular_base_proof")
4699            .is_err()
4700        {
4701            return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
4702        }
4703        let proof = (|| {
4704            let snapshot = snapshot_read_connection(
4705                &self.conn,
4706                &SnapshotReadRequest {
4707                    subscriptions: prepared
4708                        .subscriptions
4709                        .iter()
4710                        .map(|sub| sub.id.clone())
4711                        .collect(),
4712                    commit_ids: vec![prepared.client_commit_id.clone()],
4713                    ..Default::default()
4714                },
4715            )
4716            .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
4717            let current = self
4718                .commit_outcome(&prepared.client_commit_id)
4719                .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
4720            let unavailable = if report.revoked.is_empty() {
4721                CurrentBaseProofReason::OutcomeUnavailable
4722            } else {
4723                CurrentBaseProofReason::Revoked
4724            };
4725            let Some(outcome) = current else {
4726                return Err(unavailable);
4727            };
4728            if !matches!(
4729                snapshot.deliveries.first(),
4730                Some(CommitDelivery::Known { .. })
4731            ) || outcome.sequence != prepared.sequence
4732                || outcome.resolution != CommitOutcomeResolution::Active
4733                || !matches!(
4734                    outcome.status,
4735                    CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
4736                )
4737            {
4738                return Err(unavailable);
4739            }
4740            let (Some(operations), Some(rows)) = (outcome.operations, outcome.retained_rows) else {
4741                return Err(unavailable);
4742            };
4743            if rows.is_empty() || operations.len() != rows.len() {
4744                return Err(unavailable);
4745            }
4746            let after: HashMap<&str, &Subscription> =
4747                self.subs.iter().map(|sub| (sub.id.as_str(), sub)).collect();
4748            let complete: HashSet<&str> = snapshot
4749                .subscriptions
4750                .iter()
4751                .filter_map(|sub| match sub {
4752                    SubscriptionCatchup::Known {
4753                        id,
4754                        bootstrap_complete: true,
4755                        ..
4756                    } => Some(id.as_str()),
4757                    _ => None,
4758                })
4759                .collect();
4760            let candidates: Vec<_> = prepared
4761                .subscriptions
4762                .iter()
4763                .filter_map(|before| {
4764                    let after = after.get(before.id.as_str())?;
4765                    if before.table != after.table
4766                        || before.params != after.params
4767                        || canonical_scope_json(&before.requested)
4768                            != canonical_scope_json(&after.requested)
4769                        || !complete.contains(before.id.as_str())
4770                    {
4771                        return None;
4772                    }
4773                    after
4774                        .effective
4775                        .as_ref()
4776                        .map(|scopes| (after.table.as_str(), scopes))
4777                })
4778                .collect();
4779            let covered = |table_name: &str, row: &Map<String, Value>| {
4780                let Some(table) = self.schema.table(table_name) else {
4781                    return false;
4782                };
4783                candidates.iter().any(|(candidate_table, scopes)| {
4784                    *candidate_table == table_name
4785                        && !scopes.is_empty()
4786                        && scopes.iter().all(|(variable, allowed)| {
4787                            table
4788                                .scope_column(variable)
4789                                .and_then(|column| match row.get(column) {
4790                                    Some(Value::String(value)) => Some(value.clone()),
4791                                    Some(Value::Bool(value)) => Some(value.to_string()),
4792                                    Some(Value::Number(value)) => value.as_f64().map(|number| {
4793                                        ryu_js::Buffer::new().format_finite(number).to_owned()
4794                                    }),
4795                                    _ => None,
4796                                })
4797                                .is_some_and(|value| !value.is_empty() && allowed.contains(&value))
4798                        })
4799                })
4800            };
4801            for row in &rows {
4802                let Some(base) = row.server_row.as_ref().or(row.local_row.as_ref()) else {
4803                    return Err(if report.revoked.is_empty() {
4804                        CurrentBaseProofReason::MissingCoverage
4805                    } else {
4806                        CurrentBaseProofReason::Revoked
4807                    });
4808                };
4809                if !covered(&row.table, base)
4810                    || row
4811                        .unique_conflicts
4812                        .iter()
4813                        .any(|conflict| !covered(&row.table, &conflict.server_row))
4814                {
4815                    return Err(if report.revoked.is_empty() {
4816                        CurrentBaseProofReason::MissingCoverage
4817                    } else {
4818                        CurrentBaseProofReason::Revoked
4819                    });
4820                }
4821            }
4822            Ok(rows)
4823        })();
4824        if self
4825            .conn
4826            .execute_batch("RELEASE syncular_base_proof")
4827            .is_err()
4828        {
4829            return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
4830        }
4831        match proof {
4832            Ok(rows) => CurrentBaseProof {
4833                proven: true,
4834                rows: Some(rows),
4835                reason: None,
4836            },
4837            Err(reason) => CurrentBaseProof::failure(reason),
4838        }
4839    }
4840
4841    pub fn prove_current_base(
4842        &mut self,
4843        transport: &mut dyn Transport,
4844        client_commit_id: &str,
4845        sequence: i64,
4846        max_rounds: Option<u32>,
4847    ) -> CurrentBaseProof {
4848        let prepared = match self.prepare_current_base(client_commit_id, sequence) {
4849            Ok(prepared) => prepared,
4850            Err(proof) => return proof,
4851        };
4852        let drive = self.sync_until_idle(transport, max_rounds);
4853        self.finish_current_base(prepared, drive)
4854    }
4855
4856    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
4857        if query.limit == Some(0) {
4858            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
4859        }
4860        let mut sql = String::from(
4861            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
4862                    resolution, resolved_at_ms, replacement_client_commit_id
4863               FROM _syncular_commit_outcomes",
4864        );
4865        if query.active_only {
4866            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
4867        }
4868        sql.push_str(" ORDER BY seq DESC");
4869        if let Some(limit) = query.limit {
4870            sql.push_str(&format!(" LIMIT {limit}"));
4871        }
4872        let mut stmt = self
4873            .conn
4874            .prepare(&sql)
4875            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4876        let rows = stmt
4877            .query_map([], |row| {
4878                Ok(StoredCommitOutcomeRow {
4879                    sequence: row.get(0)?,
4880                    client_commit_id: row.get(1)?,
4881                    status: row.get(2)?,
4882                    recorded_at_ms: row.get(3)?,
4883                    results_json: row.get(4)?,
4884                    operations_json: row.get(5)?,
4885                    resolution: row.get(6)?,
4886                    resolved_at_ms: row.get(7)?,
4887                    replacement_client_commit_id: row.get(8)?,
4888                })
4889            })
4890            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4891        let mut outcomes = Vec::new();
4892        for row in rows {
4893            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
4894                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
4895            )?)?);
4896        }
4897        Ok(outcomes)
4898    }
4899
4900    fn prune_commit_outcomes(&self) -> Result<(), String> {
4901        #[cfg(test)]
4902        self.outcome_prune_count
4903            .set(self.outcome_prune_count.get() + 1);
4904        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
4905        let count = self
4906            .conn
4907            .query_row(
4908                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
4909                [],
4910                |row| row.get::<_, i64>(0),
4911            )
4912            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
4913            as usize;
4914        let excess = count.saturating_sub(max_entries);
4915        if excess == 0 {
4916            return Ok(());
4917        }
4918        let mut stmt = self
4919            .conn
4920            .prepare(
4921                "SELECT seq FROM _syncular_commit_outcomes
4922                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
4923                  ORDER BY seq ASC LIMIT ?1",
4924            )
4925            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4926        let rows = stmt
4927            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
4928            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4929        let sequences = rows
4930            .collect::<Result<Vec<_>, _>>()
4931            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4932        drop(stmt);
4933        for sequence in sequences {
4934            self.conn
4935                .execute(
4936                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
4937                    rusqlite::params![sequence],
4938                )
4939                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4940        }
4941        Ok(())
4942    }
4943
4944    // -- driver surface ---------------------------------------------------------
4945
4946    /// Register one subscription; see [`SyncClient::subscribe_many`].
4947    pub fn subscribe(
4948        &mut self,
4949        id: String,
4950        table: String,
4951        scopes: Vec<(String, Vec<String>)>,
4952        params: Option<String>,
4953    ) -> Result<(), String> {
4954        self.subscribe_many(vec![SubscriptionRequest {
4955            id,
4956            table,
4957            scopes,
4958            params,
4959        }])
4960    }
4961
4962    /// Persist one registration or re-arm without raising sync-needed.
4963    /// Returns whether the registry changed.
4964    fn register_subscription(
4965        &mut self,
4966        id: String,
4967        table: String,
4968        scopes: Vec<(String, Vec<String>)>,
4969        params: Option<String>,
4970    ) -> Result<bool, String> {
4971        if self.schema.table(&table).is_none() {
4972            return Err(format!("unknown table {table:?}"));
4973        }
4974        let registered_at = self
4975            .subs
4976            .iter()
4977            .position(|subscription| subscription.id == id);
4978        if let Some(index) = registered_at {
4979            let existing = &self.subs[index];
4980            let same_intent = existing.table == table
4981                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
4982                && existing.params == params;
4983            if !same_intent {
4984                return Err(
4985                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
4986                        .to_owned(),
4987                );
4988            }
4989            // §3.3: a revoked registration keeps its id and intent but no
4990            // progress. Subscribing again with the same intent re-arms it and
4991            // asks for a pull, which resolves its scopes from scratch. A
4992            // `failed` registration is a configuration error and stays put.
4993            if existing.state != SubState::Revoked {
4994                return Ok(false);
4995            }
4996        }
4997        let sub = Subscription {
4998            id: id.clone(),
4999            table,
5000            requested: scopes,
5001            params,
5002            cursor: -1,
5003            bootstrap_state: None,
5004            state: SubState::Active,
5005            reason_code: None,
5006            effective: None,
5007            synced_once: false,
5008        };
5009        self.persist_sub(&sub)?;
5010        match registered_at {
5011            Some(index) => self.subs[index] = sub,
5012            None => self.subs.push(sub),
5013        }
5014        self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
5015        Ok(true)
5016    }
5017
5018    /// Register several subscriptions in one local transaction: the same
5019    /// rules as `subscribe` for each request, and one commit for all of them
5020    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
5021    /// registrations pays it once). An error rolls back every registration
5022    /// of the call. A call that registers or re-arms anything raises
5023    /// sync-needed with an interactive intent (§4.1), as the TS client does;
5024    /// `set_window` (§4.8) registers through the same path without it and
5025    /// batches its own intent.
5026    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
5027        let rollback = self.capture();
5028        self.begin_observation("syncular_subscribe")?;
5029        let mut registered_any = false;
5030        let registered = (|| {
5031            for request in requests {
5032                registered_any |= self.register_subscription(
5033                    request.id,
5034                    request.table,
5035                    request.scopes,
5036                    request.params,
5037                )?;
5038            }
5039            self.conn
5040                .execute_batch("RELEASE syncular_subscribe")
5041                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5042        })();
5043        if let Err(error) = registered {
5044            self.rollback_observation("syncular_subscribe");
5045            self.restore(rollback);
5046            return Err(error);
5047        }
5048        if registered_any {
5049            self.set_sync_needed(true, true);
5050        }
5051        Ok(())
5052    }
5053
5054    /// Remove a registration. Removing a registered id raises sync-needed with
5055    /// an interactive intent (§4.1), as the TS client does; an unknown id is a
5056    /// no-op.
5057    pub fn unsubscribe(&mut self, id: &str) -> Result<(), String> {
5058        if !self.subs.iter().any(|s| s.id == id) {
5059            return Ok(());
5060        }
5061        self.conn
5062            .execute(
5063                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5064                rusqlite::params![id],
5065            )
5066            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5067        self.cancel_sync_round();
5068        self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
5069        self.subs.retain(|s| s.id != id);
5070        self.set_sync_needed(true, true);
5071        Ok(())
5072    }
5073
5074    // -- windowed subscriptions (§4.8) ------------------------------------------
5075
5076    /// §4.8: set the live window units for a base — a value-sharded family
5077    /// of subscriptions, one per unit. Added units get fresh subscriptions
5078    /// (image-lane bootstrap on the next sync); removed units are
5079    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
5080    /// Idempotent; re-entry cancels any deferred eviction.
5081    pub fn set_window(
5082        &mut self,
5083        base: &WindowBase,
5084        units: &[String],
5085    ) -> Result<CommandEffects, String> {
5086        let table = self
5087            .schema
5088            .table(&base.table)
5089            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
5090        if table.scope_column(&base.variable).is_none() {
5091            return Err(format!(
5092                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
5093                base.table, base.variable
5094            ));
5095        }
5096        let base_key = window_base_key(base);
5097        let wanted: std::collections::HashSet<&String> = units.iter().collect();
5098        let live = self.load_window_units(&base_key);
5099        self.begin_observation("syncular_window")?;
5100        let mut batch = ChangeAccumulator::default();
5101        let mut changed = false;
5102
5103        let rollback = self.capture();
5104        let widened = (|| {
5105            // Widen: units wanted but not live → fresh subscription + registry row.
5106            for unit in units {
5107                if live.iter().any(|(u, _)| u == unit) {
5108                    continue;
5109                }
5110                let sub_id = derive_sub_id(base, unit);
5111                self.delete_pending_evict(&sub_id)?;
5112                self.insert_window_unit(&base_key, unit, &sub_id)?;
5113                self.register_subscription(
5114                    sub_id,
5115                    base.table.clone(),
5116                    unit_scopes(base, unit),
5117                    base.params.clone(),
5118                )?;
5119                batch.window(&base_key, &base.table, unit);
5120                changed = true;
5121            }
5122
5123            self.finish_observation("syncular_window", batch)
5124        })();
5125        if let Err(error) = widened {
5126            self.rollback_observation("syncular_window");
5127            self.restore(rollback);
5128            return Err(error);
5129        }
5130        for (unit, sub_id) in live {
5131            if wanted.contains(&unit) {
5132                continue;
5133            }
5134            self.rebuild_overlay_if_dirty()?;
5135            let rollback = self.capture();
5136            self.begin_observation("syncular_window")?;
5137            let effective = self
5138                .subs
5139                .iter()
5140                .find(|sub| sub.id == sub_id)
5141                .and_then(|sub| sub.effective.clone())
5142                .unwrap_or_else(|| unit_scopes(base, &unit));
5143            let mut batch = ChangeAccumulator::default();
5144            self.record_scope_map(&mut batch, &base.table, &effective);
5145            batch.window(&base_key, &base.table, &unit);
5146            let remaining =
5147                self.evict_unit(&base_key, base, &unit, &sub_id)
5148                    .and_then(|remaining| {
5149                        self.finish_observation("syncular_window", batch)
5150                            .map(|()| remaining)
5151                    });
5152            let remaining = match remaining {
5153                Ok(remaining) => remaining,
5154                Err(error) => {
5155                    self.rollback_observation("syncular_window");
5156                    self.restore(rollback);
5157                    return Err(error);
5158                }
5159            };
5160            if remaining {
5161                self.drain_pending_evictions()?;
5162            }
5163            changed = true;
5164        }
5165        Ok(if changed {
5166            CommandEffects::interactive()
5167        } else {
5168            CommandEffects::none()
5169        })
5170    }
5171
5172    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
5173    /// the subset still bootstrap-pending. Registration alone is not
5174    /// completeness — a unit is pending until its subscription completes a
5175    /// bootstrap round (cursor advances past -1 with no resume token held).
5176    pub fn window_state(&self, base: &WindowBase) -> WindowState {
5177        let mut units = Vec::new();
5178        let mut pending = Vec::new();
5179        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
5180            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
5181                Some(sub) => {
5182                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
5183                }
5184                None => true,
5185            };
5186            if is_pending {
5187                pending.push(unit.clone());
5188            }
5189            units.push(unit);
5190        }
5191        WindowState { units, pending }
5192    }
5193
5194    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
5195        let mut stmt = match self
5196            .conn
5197            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
5198        {
5199            Ok(stmt) => stmt,
5200            Err(_) => return Vec::new(),
5201        };
5202        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
5203            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5204        });
5205        match rows {
5206            Ok(rows) => rows.filter_map(Result::ok).collect(),
5207            Err(_) => Vec::new(),
5208        }
5209    }
5210
5211    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
5212        let mut stmt = match self.conn.prepare(
5213            "SELECT windows.base, windows.unit, subscriptions.tbl
5214               FROM _syncular_windows AS windows
5215               JOIN _syncular_subscriptions AS subscriptions
5216                 ON subscriptions.id = windows.sub_id
5217               ORDER BY windows.base, windows.unit",
5218        ) {
5219            Ok(stmt) => stmt,
5220            Err(_) => return Vec::new(),
5221        };
5222        let rows = stmt.query_map([], |row| {
5223            Ok((
5224                row.get::<_, String>(0)?,
5225                row.get::<_, String>(1)?,
5226                row.get::<_, String>(2)?,
5227            ))
5228        });
5229        match rows {
5230            Ok(rows) => rows.filter_map(Result::ok).collect(),
5231            Err(_) => Vec::new(),
5232        }
5233    }
5234
5235    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
5236        self.conn
5237            .query_row(
5238                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
5239                rusqlite::params![sub_id],
5240                |row| Ok((row.get(0)?, row.get(1)?)),
5241            )
5242            .ok()
5243    }
5244
5245    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
5246        self.conn
5247            .execute(
5248                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
5249                rusqlite::params![base_key, unit, sub_id],
5250            )
5251            .map(|_| ())
5252            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5253    }
5254
5255    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
5256        self.conn
5257            .execute(
5258                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
5259                rusqlite::params![base_key, unit],
5260            )
5261            .map(|_| ())
5262            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5263    }
5264
5265    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
5266    /// Deletes the unit's rows except those pinned by a pending outbox
5267    /// commit (E1); records a deferred eviction if any pin remains; discards
5268    /// the subscription's cursor/resume/effective-echo (E3) and version
5269    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
5270    /// nothing.
5271    fn evict_unit(
5272        &mut self,
5273        base_key: &str,
5274        base: &WindowBase,
5275        unit: &str,
5276        sub_id: &str,
5277    ) -> Result<bool, String> {
5278        let effective = self
5279            .subs
5280            .iter()
5281            .find(|s| s.id == sub_id)
5282            .and_then(|s| s.effective.clone())
5283            .unwrap_or_else(|| unit_scopes(base, unit));
5284        let pinned = self.pinned_row_ids(&base.table);
5285        let owners = self.scope_owners(&base.table, sub_id);
5286        let (remaining, deferred) =
5287            self.evict_scope_rows(&base.table, &effective, &owners, &pinned)?;
5288        self.delete_window_unit(base_key, unit)?;
5289        self.conn
5290            .execute(
5291                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5292                [sub_id],
5293            )
5294            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5295        self.subs.retain(|sub| sub.id != sub_id);
5296        // A prepared round still carries this unit; its SUB_END must not
5297        // complete a re-added unit that lost these rows (as `unsubscribe`).
5298        self.cancel_sync_round();
5299        self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
5300        if deferred {
5301            self.save_pending_evict(sub_id, &base.table, &effective)?;
5302        } else {
5303            self.delete_pending_evict(sub_id)?;
5304        }
5305        Ok(remaining)
5306    }
5307
5308    /// §4.8 E1: delete base rows owned by the effective scopes
5309    /// ([`local_scope_predicate`]) EXCEPT pinned primary keys. Returns (more
5310    /// unpinned work, any scoped rows remain). A missing scope-column mapping
5311    /// fails without deleting rows.
5312    fn evict_scope_rows(
5313        &self,
5314        table_name: &str,
5315        effective: &[(String, Vec<String>)],
5316        owners: &[Vec<(String, Vec<String>)>],
5317        pinned: &std::collections::HashSet<String>,
5318    ) -> Result<(bool, bool), String> {
5319        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
5320        let Some((scope, mut params)) = local_scope_predicate(table, effective, owners)
5321            .map_err(|()| "sync.scope_revoked".to_owned())?
5322        else {
5323            return Ok((false, false));
5324        };
5325        let scope_params = params.len();
5326        let pk = quote_ident(&table.primary_key);
5327        // Pinned ids are rowId text; the text form matches every key type.
5328        let predicate = if pinned.is_empty() {
5329            scope.clone()
5330        } else {
5331            params.extend(pinned.iter().cloned().map(SqlValue::Text));
5332            let holes = vec!["?"; pinned.len()].join(", ");
5333            format!("{scope} AND CAST({pk} AS TEXT) NOT IN ({holes})")
5334        };
5335        let base = base_table(table_name);
5336        let visible = visible_table(table_name);
5337        let mut ids: Vec<SqlValue> = Vec::new();
5338        for source in [&base, &visible] {
5339            let mut stmt = self
5340                .conn
5341                .prepare_cached(&format!(
5342                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
5343                ))
5344                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5345            ids = stmt
5346                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
5347                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
5348                .collect::<Result<_, _>>()
5349                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5350            if !ids.is_empty() {
5351                break;
5352            }
5353        }
5354        if !ids.is_empty() {
5355            for id in &ids {
5356                self.conn
5357                    .execute(
5358                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
5359                        rusqlite::params![table_name, id],
5360                    )
5361                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5362            }
5363            let holes = vec!["?"; ids.len()].join(", ");
5364            for source in [&base, &visible] {
5365                self.conn
5366                    .execute(
5367                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
5368                        rusqlite::params_from_iter(&ids),
5369                    )
5370                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5371            }
5372        }
5373        let mut remaining = false;
5374        let mut deferred = false;
5375        for source in [&base, &visible] {
5376            remaining |= self
5377                .conn
5378                .query_row(
5379                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
5380                    rusqlite::params_from_iter(&params),
5381                    |row| row.get::<_, bool>(0),
5382                )
5383                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5384            deferred |= self
5385                .conn
5386                .query_row(
5387                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
5388                    rusqlite::params_from_iter(&params[..scope_params]),
5389                    |row| row.get::<_, bool>(0),
5390                )
5391                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5392        }
5393        Ok((remaining, deferred))
5394    }
5395
5396    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
5397    fn drain_pending_evictions(&mut self) -> Result<(), String> {
5398        let pending = self.load_pending_evictions()?;
5399        if pending.is_empty() {
5400            return Ok(());
5401        }
5402        self.rebuild_overlay_if_dirty()?;
5403        for (sub_id, table_name, effective) in pending {
5404            if self.schema.table(&table_name).is_none() {
5405                self.delete_pending_evict(&sub_id)?;
5406                continue;
5407            }
5408            loop {
5409                std::thread::yield_now();
5410                self.begin_observation("syncular_evict")?;
5411                let pinned = self.pinned_row_ids(&table_name);
5412                let owners = self.scope_owners(&table_name, &sub_id);
5413                let result = self.evict_scope_rows(&table_name, &effective, &owners, &pinned);
5414                let (remaining, deferred) = match result {
5415                    Ok(result) => result,
5416                    Err(error) => {
5417                        self.rollback_observation("syncular_evict");
5418                        return Err(error);
5419                    }
5420                };
5421                if !deferred {
5422                    if let Err(error) = self.delete_pending_evict(&sub_id) {
5423                        self.rollback_observation("syncular_evict");
5424                        return Err(error);
5425                    }
5426                }
5427                let mut batch = ChangeAccumulator::default();
5428                self.record_scope_map(&mut batch, &table_name, &effective);
5429                if let Err(error) = self.finish_observation("syncular_evict", batch) {
5430                    self.rollback_observation("syncular_evict");
5431                    return Err(error);
5432                }
5433                if !remaining {
5434                    break;
5435                }
5436            }
5437        }
5438        Ok(())
5439    }
5440
5441    /// §3.3/§4.8/§5.6 row ownership: the effective scopes of every other
5442    /// active subscription on `table` that holds rows (`cursor >= 0`). A
5443    /// scoped delete keeps every row one of them still covers.
5444    fn scope_owners(&self, table: &str, except_id: &str) -> Vec<Vec<(String, Vec<String>)>> {
5445        self.subs
5446            .iter()
5447            .filter(|sub| {
5448                sub.id != except_id
5449                    && sub.table == table
5450                    && sub.state == SubState::Active
5451                    && sub.cursor >= 0
5452            })
5453            .filter_map(|sub| sub.effective.clone())
5454            .collect()
5455    }
5456
5457    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
5458    /// commit — rows that MUST NOT be evicted until the commit drains.
5459    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
5460        let mut pinned = std::collections::HashSet::new();
5461        for commit in self
5462            .outbox
5463            .iter()
5464            .map(|commit| &**commit)
5465            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
5466        {
5467            for op in &commit.ops {
5468                if op.table == table {
5469                    pinned.insert(op.row_id.clone());
5470                }
5471            }
5472        }
5473        pinned
5474    }
5475
5476    fn save_pending_evict(
5477        &self,
5478        sub_id: &str,
5479        table: &str,
5480        effective: &[(String, Vec<String>)],
5481    ) -> Result<(), String> {
5482        self.conn.execute(
5483            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
5484            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
5485        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5486    }
5487
5488    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
5489        self.conn
5490            .execute(
5491                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5492                rusqlite::params![sub_id],
5493            )
5494            .map(|_| ())
5495            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5496    }
5497
5498    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
5499        let mut stmt = self
5500            .conn
5501            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
5502            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5503        let rows = stmt
5504            .query_map([], |row| {
5505                Ok((
5506                    row.get::<_, String>(0)?,
5507                    row.get::<_, String>(1)?,
5508                    row.get::<_, String>(2)?,
5509                ))
5510            })
5511            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5512        rows.map(|row| {
5513            let (sub_id, table, json) =
5514                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5515            let value: Value = serde_json::from_str(&json)
5516                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
5517            let effective = json_to_scope_map(&value)
5518                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
5519            Ok((sub_id, table, effective))
5520        })
5521        .collect()
5522    }
5523
5524    /// Select failed-intent retention; reload durable retained aggregates.
5525    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
5526        self.retain_failed_commits = enabled;
5527        for (commit, _) in self.failed_commits.iter() {
5528            for operation in &commit.ops {
5529                self.overlay_dirty.table(&operation.table);
5530            }
5531        }
5532        self.failed_commits = self.load_failed_commits()?.into();
5533        for (commit, _) in self.failed_commits.iter() {
5534            for operation in &commit.ops {
5535                self.overlay_dirty.table(&operation.table);
5536            }
5537        }
5538        Ok(())
5539    }
5540
5541    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
5542        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
5543            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5544        let rows = statement
5545            .query_map([], |row| {
5546                Ok((
5547                    row.get::<_, String>(0)?,
5548                    row.get::<_, String>(1)?,
5549                    row.get::<_, String>(2)?,
5550                ))
5551            })
5552            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5553        rows.map(|row| {
5554            let (id, operations, initial) =
5555                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5556            let operations: Vec<CommitOperation> =
5557                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
5558            let initial =
5559                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
5560            Ok((
5561                OutboxCommit {
5562                    client_commit_id: id,
5563                    ops: operations
5564                        .into_iter()
5565                        .map(|op| OutboxOp {
5566                            upsert: op.op == "upsert",
5567                            table: op.table,
5568                            row_id: op.row_id,
5569                            base_version: op.base_version,
5570                            values: op.values,
5571                            insert: op.insert,
5572                        })
5573                        .collect(),
5574                },
5575                initial,
5576            ))
5577        })
5578        .collect()
5579    }
5580
5581    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
5582        let mut initial = Map::new();
5583        for operation in operations {
5584            // A table the current schema removed has no rows to retain.
5585            let Some(table) = self.schema.table(&operation.table) else {
5586                continue;
5587            };
5588            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
5589                Some(row) => Some(row),
5590                None => self.visible_row(table, &operation.row_id)?,
5591            };
5592            if let Some((values, _)) = initial_row {
5593                let mut intended: Map<String, Value> = table
5594                    .columns
5595                    .iter()
5596                    .zip(values.iter())
5597                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
5598                    .collect();
5599                if let Some(patch) = &operation.values {
5600                    intended.extend(patch.clone());
5601                }
5602                initial.insert(
5603                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
5604                        .map_err(|_| "sync.local_corrupt".to_owned())?,
5605                    Value::Object(intended),
5606                );
5607            }
5608        }
5609        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
5610            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
5611            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
5612            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5613        self.failed_commits.push((
5614            OutboxCommit {
5615                client_commit_id: id.to_owned(),
5616                ops: operations.to_vec(),
5617            },
5618            initial,
5619        ));
5620        Ok(())
5621    }
5622
5623    fn drop_failed_commits(
5624        &mut self,
5625        matches: impl Fn(&str, &Map<String, Value>) -> bool,
5626    ) -> Result<Vec<OutboxCommit>, String> {
5627        let mut doomed = self.drop_acknowledged_rows(&matches)?;
5628        for (commit, initial) in self.failed_commits.iter() {
5629            for operation in &commit.ops {
5630                let key =
5631                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
5632                        .map_err(|_| "sync.local_corrupt".to_owned())?;
5633                if initial
5634                    .get(&key)
5635                    .and_then(Value::as_object)
5636                    .is_some_and(|values| matches(&operation.table, values))
5637                {
5638                    doomed.push(commit.clone());
5639                    break;
5640                }
5641            }
5642        }
5643        for commit in &doomed {
5644            self.conn
5645                .execute(
5646                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
5647                    rusqlite::params![commit.client_commit_id],
5648                )
5649                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5650        }
5651        for commit in &doomed {
5652            self.conn.execute("UPDATE _syncular_commit_outcomes SET operations_json = NULL, results_json = '[]' WHERE client_commit_id = ?1", rusqlite::params![commit.client_commit_id]).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5653        }
5654        self.conflicts.retain(|record| {
5655            !doomed
5656                .iter()
5657                .any(|commit| commit.client_commit_id == record.client_commit_id)
5658        });
5659        self.rejections.retain(|record| {
5660            !doomed
5661                .iter()
5662                .any(|commit| commit.client_commit_id == record.client_commit_id)
5663        });
5664        self.failed_commits.retain(|(commit, _)| {
5665            !doomed
5666                .iter()
5667                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
5668        });
5669        for commit in &doomed {
5670            for operation in &commit.ops {
5671                self.overlay_dirty.table(&operation.table);
5672            }
5673        }
5674        Ok(doomed)
5675    }
5676
5677    /// Record one commit of local mutations. The returned commit ID is the
5678    /// §2.3 idempotency key. Failures are structured so callers read
5679    /// `code`/`message`/`details` instead of parsing a string prefix.
5680    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
5681        let prior = self.storage_failure.borrow_mut().take();
5682        let outcome = (|| -> Result<String, String> {
5683            if mutations.is_empty() {
5684                return Err(
5685                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
5686                        .to_owned(),
5687                );
5688            }
5689            let mut ops = Vec::with_capacity(mutations.len());
5690            for mutation in mutations {
5691                match mutation {
5692                    Mutation::Patch {
5693                        table,
5694                        values,
5695                        base_version,
5696                    } => {
5697                        let schema_table = self
5698                            .schema
5699                            .table(&table)
5700                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
5701                        let values = normalize_values_casing(schema_table, values)?;
5702                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
5703                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
5704                    }
5705                    Mutation::Upsert {
5706                        table,
5707                        values,
5708                        base_version,
5709                    } => {
5710                        let schema_table = self.schema.table(&table).ok_or_else(|| {
5711                            format!("sync.unknown_table: unknown table {table:?}")
5712                        })?;
5713                        // §5: value keys are accepted in snake_case AND the
5714                        // generated row types' camelCase; normalize to SQL truth
5715                        // before the pk lookup / codec see them.
5716                        let values = normalize_values_casing(schema_table, values)?;
5717                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
5718                        // §6.7 `mutate` marks every column present: fill missing
5719                        // nullable columns with NULL before the payload is built.
5720                        let values = full_row_values(schema_table, values)?;
5721                        // §5.11: validate the payload encodes with the current
5722                        // codec. A key-selection or unknown-key failure is a
5723                        // durable rejection at the push seam (§10.3), never an
5724                        // author-time error. Full-row `mutate` presents every
5725                        // column, so no stored fallback is needed.
5726                        self.validate_author_encode(schema_table, &row_id, &values)?;
5727                        // §7.1 insert intent: the row is absent from the
5728                        // visible replica at call time, before this batch
5729                        // applies anything.
5730                        let insert = !self.visible_row_exists(schema_table, &row_id)?;
5731                        ops.push(OutboxOp {
5732                            upsert: true,
5733                            table,
5734                            row_id,
5735                            base_version,
5736                            values: Some(values),
5737                            insert,
5738                        });
5739                    }
5740                    Mutation::Delete {
5741                        table,
5742                        row_id,
5743                        base_version,
5744                    } => {
5745                        if self.schema.table(&table).is_none() {
5746                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
5747                        }
5748                        // §2.4: the server rejects a rowId holding U+0000.
5749                        if row_id.contains('\0') {
5750                            return Err(
5751                                "sync.invalid_request: rowId holds U+0000 (§2.4)".to_owned()
5752                            );
5753                        }
5754                        ops.push(OutboxOp {
5755                            upsert: false,
5756                            table,
5757                            row_id,
5758                            base_version,
5759                            values: None,
5760                            insert: false,
5761                        });
5762                    }
5763                }
5764            }
5765            self.record_outbox_commit(ops)
5766        })();
5767        self.finish_authoring(prior, outcome)
5768    }
5769
5770    /// Scope the retained `storage_failure` diagnostic to this call so an
5771    /// earlier operation's failure cannot classify a new one, and surface the
5772    /// call's own classified failure with its details. A fresh classified
5773    /// failure replaces the retained diagnostic; otherwise the earlier one is
5774    /// kept for the next sync.
5775    fn finish_authoring(
5776        &self,
5777        prior: Option<QueryReadFailure>,
5778        outcome: Result<String, String>,
5779    ) -> Result<String, ClientError> {
5780        let fresh = self.storage_failure.borrow_mut().take();
5781        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
5782        match outcome {
5783            Ok(id) => Ok(id),
5784            Err(message) => Err(fresh
5785                .map(ClientError::from)
5786                .unwrap_or_else(|| ClientError::from(message))),
5787        }
5788    }
5789
5790    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
5791        let commit = OutboxCommit {
5792            client_commit_id: uuid::Uuid::new_v4().to_string(),
5793            ops,
5794        };
5795        self.begin_observation("syncular_mutation")?;
5796        let mut batch = ChangeAccumulator::default();
5797        for op in &commit.ops {
5798            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
5799            if let Some(values) = &op.values {
5800                if let Some(table) = self.schema.table(&op.table) {
5801                    for scope in &table.scope_variables {
5802                        if let Some(Value::String(value)) = values.get(&scope.column) {
5803                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
5804                            precise = true;
5805                        }
5806                    }
5807                }
5808            }
5809            if !precise {
5810                batch.table(&op.table);
5811            }
5812        }
5813        if let Err(error) = self.persist_outbox_insert(&commit) {
5814            self.rollback_observation("syncular_mutation");
5815            return Err(error);
5816        }
5817        // A clean overlay contains the FIFO fold of every previous commit, so
5818        // the new commit applies over it. §7.1: an operation that does not
5819        // apply (a secondary unique collision) fails the whole commit; the
5820        // savepoint rollback removes the outbox entry and every visible write.
5821        // The append undoes itself: a rollback capture would copy the outbox.
5822        let was_dirty = self.overlay_dirty.snapshot();
5823        let id = commit.client_commit_id.clone();
5824        let appended = self.rebuild_overlay_if_dirty().and_then(|()| {
5825            commit
5826                .ops
5827                .iter()
5828                .try_for_each(|op| self.apply_outbox_op(op))?;
5829            self.outbox.push(Arc::new(commit));
5830            batch.status = true;
5831            self.finish_observation("syncular_mutation", batch)
5832                .inspect_err(|_| drop(self.outbox.pop()))
5833        });
5834        if let Err(error) = appended {
5835            self.rollback_observation("syncular_mutation");
5836            self.overlay_dirty.restore(was_dirty);
5837            return Err(error);
5838        }
5839        Ok(id)
5840    }
5841
5842    /// Record one §6.1 sparse upsert: the primary key plus the supplied
5843    /// non-scope columns are present; every other column is absent and stays
5844    /// untouched on the server and in the local overlay. An absent local base
5845    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
5846    /// Failures are structured so callers read `code`/`message`/`details`.
5847    pub fn patch(
5848        &mut self,
5849        table: &str,
5850        row_id: &str,
5851        partial: Map<String, Value>,
5852        base_version: Option<i64>,
5853    ) -> Result<String, ClientError> {
5854        let prior = self.storage_failure.borrow_mut().take();
5855        let outcome = self.perform_patch(table, row_id, partial, base_version);
5856        self.finish_authoring(prior, outcome)
5857    }
5858
5859    /// The unscoped patch body. Public authoring entry points scope
5860    /// `storage_failure` around the whole operation, including any pre-read.
5861    fn perform_patch(
5862        &mut self,
5863        table: &str,
5864        row_id: &str,
5865        partial: Map<String, Value>,
5866        base_version: Option<i64>,
5867    ) -> Result<String, String> {
5868        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
5869        self.record_outbox_commit(vec![operation])
5870    }
5871
5872    fn prepare_patch(
5873        &self,
5874        table: &str,
5875        row_id: &str,
5876        partial: Map<String, Value>,
5877        base_version: Option<i64>,
5878    ) -> Result<OutboxOp, String> {
5879        let schema_table = self
5880            .schema
5881            .table(table)
5882            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
5883        let partial = normalize_values_casing(schema_table, partial)?;
5884        if let Some(pk) = partial.get(&schema_table.primary_key) {
5885            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
5886                return Err(format!(
5887                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
5888                ));
5889            }
5890        }
5891        if !self.visible_row_exists(schema_table, row_id)? {
5892            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
5893        }
5894        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
5895        // server accepts a present scope column whose value equals the
5896        // stored row and applies it as a no-op, so a decoded envelope
5897        // round-trips. The local row is the only value the client can prove
5898        // equality against; when the row is absent locally the client cannot
5899        // prove equality against the server's stored row and fails closed.
5900        // A primary key that is also a scope column needs no stored-row
5901        // read: the primary key in a sparse payload is by construction the
5902        // row id being patched (§6.1), so its value is proven equal already.
5903        for scope in &schema_table.scope_variables {
5904            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
5905                continue;
5906            }
5907            let Some(column) = schema_table
5908                .columns
5909                .iter()
5910                .find(|column| column.name == scope.column)
5911            else {
5912                continue;
5913            };
5914            let stored = self
5915                .conn
5916                .query_row(
5917                    &format!(
5918                        "SELECT {} FROM {} WHERE {}",
5919                        quote_ident(&scope.column),
5920                        visible_table(&schema_table.name),
5921                        row_id_predicate(schema_table)
5922                    ),
5923                    rusqlite::params![row_id],
5924                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
5925                )
5926                .optional()
5927                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5928            // Decode the supplied value through the same column-type path the
5929            // rest of the patch encoding uses, so a value that only equals
5930            // the stored one in another number representation still compares
5931            // equal.
5932            let incoming = json_to_column_value(column, partial.get(&scope.column))
5933                .ok()
5934                .map(|value| column_value_to_json(&value));
5935            // A stored blob never equals a patch value, matching the server's
5936            // scope-column comparison (§3.4): bytes are unstorable as scope
5937            // values.
5938            let equal = stored.as_ref().is_some_and(|stored| {
5939                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
5940            });
5941            if !equal {
5942                return Err(format!(
5943                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
5944                    scope.column
5945                ));
5946            }
5947        }
5948        let mut values = partial;
5949        // Presence-set semantics: a proven-equal scope column is dropped, so
5950        // the patch leaves the stored value untouched (§6.2).
5951        for scope in &schema_table.scope_variables {
5952            values.remove(&scope.column);
5953        }
5954        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
5955            // §2.4: the rowId of an integer key is its canonical decimal form.
5956            ColumnType::Integer => match row_id.parse::<i64>() {
5957                Ok(v)
5958                    if v.to_string() == row_id
5959                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
5960                            .contains(&v) =>
5961                {
5962                    Value::from(v)
5963                }
5964                _ => {
5965                    return Err(format!(
5966                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
5967                ))
5968                }
5969            },
5970            ColumnType::Boolean => match row_id {
5971                "true" => Value::Bool(true),
5972                "false" => Value::Bool(false),
5973                _ => {
5974                    return Err(format!(
5975                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
5976                ))
5977                }
5978            },
5979            _ => Value::from(row_id.to_owned()),
5980        };
5981        values.insert(schema_table.primary_key.clone(), pk_value);
5982        // §5.11: structural validation only; an absent key-id selector is
5983        // resolved at the push seam from the stored local row, and a
5984        // key-selection or unknown-key failure becomes a durable
5985        // `client.encrypt_failed` rejection there (§10.3), never an
5986        // author-time error.
5987        self.validate_author_encode(schema_table, row_id, &values)?;
5988        Ok(OutboxOp {
5989            upsert: true,
5990            table: table.to_owned(),
5991            row_id: row_id.to_owned(),
5992            base_version,
5993            values: Some(values),
5994            insert: false,
5995        })
5996    }
5997
5998    /// §5.11 key-id fallback: the stored local row's selector value as a
5999    /// positional row (only the selector slot filled). Returns None when no
6000    /// selector is configured, the row is locally absent, or the stored
6001    /// value is not a usable key id. A failed storage read is an error.
6002    fn stored_key_fallback(
6003        &self,
6004        table: &TableSchema,
6005        row_id: &str,
6006    ) -> Result<Option<Row>, String> {
6007        let Some(selector) = self.encryption.key_id_columns.get(&table.name) else {
6008            return Ok(None);
6009        };
6010        let Some(index) = table
6011            .columns
6012            .iter()
6013            .position(|column| &column.name == selector)
6014        else {
6015            return Ok(None);
6016        };
6017        let sql = format!(
6018            "SELECT {} FROM {} WHERE {}",
6019            quote_ident(selector),
6020            visible_table(&table.name),
6021            row_id_predicate(table)
6022        );
6023        let key = self
6024            .conn
6025            .query_row(&sql, rusqlite::params![row_id], |row| {
6026                row.get::<_, Option<String>>(0)
6027            })
6028            .optional()
6029            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6030            .flatten()
6031            .filter(|key| !key.is_empty());
6032        let Some(key) = key else {
6033            return Ok(None);
6034        };
6035        let mut fallback: Row = vec![None; table.columns.len()];
6036        fallback[index] = Some(ColumnValue::String(key));
6037        Ok(Some(fallback))
6038    }
6039
6040    /// §5.11/§10.3 author-time encode check: a structural failure (a
6041    /// non-nullable NULL, a value the codec rejects) is an author-time
6042    /// error; a key-selection or unknown-key failure is deferred to the push
6043    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
6044    fn validate_author_encode(
6045        &self,
6046        table: &TableSchema,
6047        row_id: &str,
6048        values: &Map<String, Value>,
6049    ) -> Result<(), String> {
6050        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
6051            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
6052            result => result.map(|_| ()),
6053        }
6054    }
6055
6056    /// §5.11/§10.3: drop every pending commit whose push encode fails with
6057    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
6058    /// raise one durable rejection per commit. `sync()` never aborts for an
6059    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
6060    /// undoing the dropped commit's purely-optimistic rows.
6061    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
6062    /// (an envelope or a type an earlier version accepted). `with_encryption`
6063    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
6064    /// the startup boundary leaves those to the send seam, where hosts configure
6065    /// keys after open.
6066    /// Recovery runs as one observation. An enclosing observation (the guarded
6067    /// schema reset) passes its `batch`, so the rejection, outcome, and table
6068    /// domains join that change event instead of publishing a second one.
6069    fn drop_unencodable_outbox(
6070        &mut self,
6071        with_encryption: bool,
6072        batch: Option<&mut ChangeAccumulator>,
6073    ) -> Result<bool, String> {
6074        let mut failures = Vec::new();
6075        for commit in self.outbox.iter() {
6076            for op in &commit.ops {
6077                let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
6078                    continue;
6079                };
6080                // An insert lacking a non-nullable column is left to
6081                // `drop_incompatible_outbox` (§7.4.4).
6082                let Some(values) = op.presented_values(table) else {
6083                    continue;
6084                };
6085                let fallback = self.stored_key_fallback(table, &op.row_id)?;
6086                let failure = match encode_sparse_row_json(
6087                    table,
6088                    &op.row_id,
6089                    &values,
6090                    &self.encryption,
6091                    fallback.as_ref(),
6092                ) {
6093                    Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
6094                        with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
6095                    }
6096                    // §7.1/§7.4.4: a value the current codec refuses was
6097                    // persisted by a version that accepted it; it leaves
6098                    // through the incompatible-outbox path, so the FIFO
6099                    // queue never wedges.
6100                    Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
6101                        LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
6102                        OUTBOX_INCOMPATIBLE_CODE,
6103                    )),
6104                    _ => None,
6105                };
6106                if let Some((error, code)) = failure {
6107                    failures.push((commit.clone(), error, code));
6108                    break;
6109                }
6110            }
6111        }
6112        if failures.is_empty() {
6113            return Ok(false);
6114        }
6115        // §7.4.4: one observation covers the durable outcome writes, the outbox
6116        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
6117        // leaves the replica and the client unchanged, a successful recovery
6118        // publishes one batch with the rejection, outcome, and table domains, and
6119        // an ordinary open with nothing to recover publishes nothing.
6120        let rollback = self.capture();
6121        let owns_observation = batch.is_none();
6122        let owns_transaction = self.conn.is_autocommit();
6123        let mut domains = ChangeAccumulator::default();
6124        if owns_observation {
6125            self.begin_observation("syncular_value_recovery")?;
6126        }
6127        let dropped = (|| -> Result<(), String> {
6128            for (commit, message, code) in failures {
6129                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
6130                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
6131                }
6132                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
6133                let rejection = RejectionRecord {
6134                    client_commit_id: commit.client_commit_id.clone(),
6135                    op_index: 0,
6136                    code: code.to_owned(),
6137                    message,
6138                    retryable: false,
6139                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
6140                        field_paths: None,
6141                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
6142                        required_action: None,
6143                        references: None,
6144                    }),
6145                    operation: representable
6146                        .then(|| commit.ops.first())
6147                        .flatten()
6148                        .map(CommitOperation::from),
6149                };
6150                self.persist_commit_outcome(
6151                    &commit.client_commit_id,
6152                    CommitOutcomeStatus::Rejected,
6153                    &[CommitOperationOutcome::Error {
6154                        rejection: rejection.clone(),
6155                    }],
6156                    representable.then_some(commit.ops.as_slice()),
6157                )?;
6158                self.delete_outbox_persisted(&commit.client_commit_id)?;
6159                self.outbox
6160                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
6161                self.rejections.push(rejection);
6162                for operation in &commit.ops {
6163                    domains.table(operation.table.as_str());
6164                }
6165            }
6166            domains.status = true;
6167            domains.rejections = true;
6168            domains.outcomes = true;
6169            self.prune_commit_outcomes()?;
6170            // The visible projection is re-derived from the surviving base + outbox.
6171            self.overlay_dirty.set(true);
6172            self.rebuild_overlay()?;
6173            Ok(())
6174        })();
6175        let result = match batch {
6176            Some(batch) => {
6177                if dropped.is_ok() {
6178                    for table in domains.tables.keys() {
6179                        batch.table(table.as_str());
6180                    }
6181                    batch.status |= domains.status;
6182                    batch.rejections |= domains.rejections;
6183                    batch.outcomes |= domains.outcomes;
6184                }
6185                dropped
6186            }
6187            None => {
6188                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
6189            }
6190        };
6191        if let Err(error) = result {
6192            if owns_observation {
6193                if owns_transaction {
6194                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
6195                        let mut failure = self.storage_failure.borrow_mut();
6196                        let failure =
6197                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
6198                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
6199                    }
6200                } else {
6201                    self.rollback_observation("syncular_value_recovery");
6202                }
6203            }
6204            self.restore(rollback);
6205            return Err(error);
6206        }
6207        Ok(true)
6208    }
6209
6210    pub fn pending_commit_ids(&self) -> Vec<String> {
6211        self.outbox
6212            .iter()
6213            .map(|c| c.client_commit_id.clone())
6214            .collect()
6215    }
6216
6217    /// §6.1: the sparse push payload bytes of every pending upsert operation,
6218    /// FIFO — exactly what the next push round would put on the wire. Used by
6219    /// conformance B.21(h) to pin cross-core byte identity.
6220    pub fn pending_payloads(&self) -> Result<Vec<Vec<u8>>, String> {
6221        let mut payloads = Vec::new();
6222        for commit in self.outbox.iter() {
6223            for op in &commit.ops {
6224                let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
6225                    continue;
6226                };
6227                let Some(values) = op.presented_values(table) else {
6228                    continue;
6229                };
6230                let fallback = self.stored_key_fallback(table, &op.row_id)?;
6231                if let Ok(payload) = encode_sparse_row_json(
6232                    table,
6233                    &op.row_id,
6234                    &values,
6235                    &self.encryption,
6236                    fallback.as_ref(),
6237                ) {
6238                    payloads.push(payload);
6239                }
6240            }
6241        }
6242        Ok(payloads)
6243    }
6244
6245    pub fn conflicts(&self) -> &[ConflictRecord] {
6246        &self.conflicts
6247    }
6248
6249    pub fn rejections(&self) -> &[RejectionRecord] {
6250        &self.rejections
6251    }
6252
6253    pub fn resolve_commit_outcome(
6254        &mut self,
6255        input: ResolveCommitOutcomeInput,
6256    ) -> Result<CommitOutcome, String> {
6257        let current = self
6258            .commit_outcome(&input.client_commit_id)?
6259            .ok_or_else(|| {
6260                format!(
6261                    "sync.outcome_not_found: no durable outcome exists for {:?}",
6262                    input.client_commit_id
6263                )
6264            })?;
6265        if current.resolution != CommitOutcomeResolution::Active {
6266            return Ok(current);
6267        }
6268        if input.resolution == CommitOutcomeResolution::Active {
6269            return Err("sync.invalid_request: resolution must leave active state".to_owned());
6270        }
6271        match input.resolution {
6272            CommitOutcomeResolution::Superseded => {
6273                let replacement = input
6274                    .replacement_client_commit_id
6275                    .as_deref()
6276                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
6277                    .ok_or_else(|| {
6278                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
6279                            .to_owned()
6280                    })?;
6281                let _ = replacement;
6282            }
6283            _ if input.replacement_client_commit_id.is_some() => {
6284                return Err(
6285                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
6286                        .to_owned(),
6287                );
6288            }
6289            _ => {}
6290        }
6291        let allowed = match current.status {
6292            CommitOutcomeStatus::Conflict => matches!(
6293                input.resolution,
6294                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
6295            ),
6296            CommitOutcomeStatus::Rejected => {
6297                matches!(
6298                    input.resolution,
6299                    CommitOutcomeResolution::Superseded
6300                        | CommitOutcomeResolution::ResolvedKeepServer
6301                )
6302            }
6303            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
6304                input.resolution == CommitOutcomeResolution::Dismissed
6305            }
6306        };
6307        if !allowed {
6308            return Err(format!(
6309                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
6310                input.resolution, current.status
6311            ));
6312        }
6313
6314        let rollback = self.capture();
6315        self.begin_observation("syncular_outcome_resolution")?;
6316        let result = (|| {
6317            self.conn
6318                .execute(
6319                    "UPDATE _syncular_commit_outcomes
6320                        SET resolution = ?1, resolved_at_ms = ?2,
6321                            replacement_client_commit_id = ?3
6322                      WHERE client_commit_id = ?4 AND resolution = 'active'",
6323                    rusqlite::params![
6324                        Self::outcome_resolution_name(input.resolution),
6325                        self.clock_now_ms(),
6326                        input.replacement_client_commit_id,
6327                        input.client_commit_id
6328                    ],
6329                )
6330                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6331            let resolved = self
6332                .commit_outcome(&current.client_commit_id)?
6333                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
6334            self.conn
6335                .execute(
6336                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
6337                    rusqlite::params![input.client_commit_id],
6338                )
6339                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6340            for (commit, _) in self.failed_commits.iter() {
6341                if commit.client_commit_id == input.client_commit_id {
6342                    for operation in &commit.ops {
6343                        self.overlay_dirty.table(&operation.table);
6344                    }
6345                }
6346            }
6347            self.failed_commits
6348                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
6349            self.prune_commit_outcomes()?;
6350            Ok(resolved)
6351        })();
6352        let resolved = match result {
6353            Ok(outcome) => outcome,
6354            Err(error) => {
6355                self.rollback_observation("syncular_outcome_resolution");
6356                self.restore(rollback);
6357                return Err(error);
6358            }
6359        };
6360        self.conflicts
6361            .retain(|record| record.client_commit_id != current.client_commit_id);
6362        self.rejections
6363            .retain(|record| record.client_commit_id != current.client_commit_id);
6364        let mut batch = ChangeAccumulator {
6365            conflicts: current.status == CommitOutcomeStatus::Conflict,
6366            rejections: current.status == CommitOutcomeStatus::Rejected,
6367            outcomes: true,
6368            ..ChangeAccumulator::default()
6369        };
6370        if let Some(operations) = &current.operations {
6371            for operation in operations {
6372                batch.table(&operation.table);
6373            }
6374        }
6375        if let Err(error) = self
6376            .rebuild_overlay_if_dirty()
6377            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
6378        {
6379            self.rollback_observation("syncular_outcome_resolution");
6380            self.restore(rollback);
6381            return Err(error);
6382        }
6383        Ok(resolved)
6384    }
6385
6386    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
6387        self.schema_floor.as_ref()
6388    }
6389
6390    /// §7.3.5: the client's opaque auth-lease state, if any.
6391    pub fn lease_state(&self) -> Option<&LeaseState> {
6392        self.lease_state.as_ref()
6393    }
6394
6395    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
6396    /// the two lease codes set it; other errors leave leaseState untouched.
6397    fn record_lease_error(&mut self, code: &str) -> Result<(), String> {
6398        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
6399            return Ok(());
6400        }
6401        let mut next = self.lease_state.clone().unwrap_or_default();
6402        next.error_code = Some(code.to_owned());
6403        self.set_lease_state(Some(next))
6404    }
6405
6406    fn set_lease_state(&mut self, next: Option<LeaseState>) -> Result<(), String> {
6407        if self.lease_state == next {
6408            return Ok(());
6409        }
6410        self.begin_observation("syncular_lease")?;
6411        let rollback = self.capture();
6412        self.lease_state = next;
6413        let persisted = match &self.lease_state {
6414            Some(lease) => serde_json::to_string(lease)
6415                .map_err(|error| error.to_string())
6416                .and_then(|json| self.set_meta(LEASE_STATE_KEY, &json)),
6417            None => self.delete_meta(LEASE_STATE_KEY),
6418        };
6419        let batch = ChangeAccumulator {
6420            status: true,
6421            ..ChangeAccumulator::default()
6422        };
6423        if let Err(error) =
6424            persisted.and_then(|()| self.finish_observation("syncular_lease", batch))
6425        {
6426            self.rollback_observation("syncular_lease");
6427            self.restore(rollback);
6428            return Err(error);
6429        }
6430        Ok(())
6431    }
6432
6433    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) -> Result<(), String> {
6434        if self.schema_floor == next {
6435            return Ok(());
6436        }
6437        self.begin_observation("syncular_schema_floor")?;
6438        let rollback = self.capture();
6439        self.stopped = next.is_some();
6440        self.schema_floor = next;
6441        let persisted = match &self.schema_floor {
6442            Some(floor) => serde_json::to_string(floor)
6443                .map_err(|error| error.to_string())
6444                .and_then(|json| self.set_meta(SCHEMA_FLOOR_KEY, &json)),
6445            None => self.delete_meta(SCHEMA_FLOOR_KEY),
6446        };
6447        let batch = ChangeAccumulator {
6448            status: true,
6449            ..ChangeAccumulator::default()
6450        };
6451        if let Err(error) =
6452            persisted.and_then(|()| self.finish_observation("syncular_schema_floor", batch))
6453        {
6454            self.rollback_observation("syncular_schema_floor");
6455            self.restore(rollback);
6456            return Err(error);
6457        }
6458        Ok(())
6459    }
6460
6461    fn set_upgrading(&mut self, value: bool) {
6462        if self.upgrading == value {
6463            return;
6464        }
6465        if self.begin_observation("syncular_upgrading").is_err() {
6466            return;
6467        }
6468        self.upgrading = value;
6469        let batch = ChangeAccumulator {
6470            status: true,
6471            ..ChangeAccumulator::default()
6472        };
6473        if self
6474            .finish_observation("syncular_upgrading", batch)
6475            .is_err()
6476        {
6477            self.rollback_observation("syncular_upgrading");
6478        }
6479    }
6480
6481    pub fn sync_needed(&self) -> bool {
6482        self.sync_needed
6483    }
6484
6485    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
6486        let sub = self.subs.iter().find(|s| s.id == id)?;
6487        Some(SubscriptionStateView {
6488            id: sub.id.clone(),
6489            table: sub.table.clone(),
6490            status: sub.state.name().to_owned(),
6491            cursor: sub.cursor,
6492            has_resume_token: sub.bootstrap_state.is_some(),
6493            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6494            reason_code: sub.reason_code.clone(),
6495        })
6496    }
6497
6498    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
6499        let schema_table = self
6500            .schema
6501            .table(table)
6502            .ok_or_else(|| format!("unknown table {table:?}"))?;
6503        let sql = format!(
6504            "SELECT * FROM {} ORDER BY {} ASC",
6505            visible_table(table),
6506            quote_ident(&schema_table.primary_key)
6507        );
6508        let mut stmt = self
6509            .conn
6510            .prepare(&sql)
6511            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6512        let mut rows = stmt
6513            .query([])
6514            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6515        let mut out = Vec::new();
6516        while let Some(row) = rows
6517            .next()
6518            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6519        {
6520            let mut values = Map::new();
6521            for (i, column) in schema_table.columns.iter().enumerate() {
6522                let value = row
6523                    .get_ref(i)
6524                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6525                values.insert(column.name.clone(), sql_ref_to_json(column, value));
6526            }
6527            let version: i64 = row
6528                .get(schema_table.columns.len())
6529                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6530            let row_id = match values.get(&schema_table.primary_key) {
6531                Some(Value::String(s)) => s.clone(),
6532                Some(Value::Number(n)) => n.to_string(),
6533                Some(Value::Bool(b)) => b.to_string(),
6534                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
6535            };
6536            out.push(RowState {
6537                row_id,
6538                version,
6539                values,
6540            });
6541        }
6542        Ok(out)
6543    }
6544
6545    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
6546    //
6547    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
6548    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
6549    // the op with `yrs`, re-encodes the whole doc state, and records a
6550    // baseVersion-less sparse upsert carrying only the primary key and the
6551    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
6552    // the overlay re-materializes the edit immediately (optimistic apply,
6553    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
6554    // Byte-compatible with `@syncular/crdt-yjs`.
6555
6556    /// The current stored value of a `crdt` column for one row — the visible
6557    /// (optimistic) bytes, or `None` when the row is absent or the column is
6558    /// NULL (the empty document, §5.10.1). Errors if the column is not a
6559    /// `crdt` column (guards the app against a typo'd column name).
6560    #[cfg(feature = "crdt-yjs")]
6561    fn crdt_column_bytes(
6562        &self,
6563        table: &str,
6564        row_id: &str,
6565        column: &str,
6566    ) -> Result<Option<Vec<u8>>, String> {
6567        let schema_table = self
6568            .schema
6569            .table(table)
6570            .ok_or_else(|| format!("unknown table {table:?}"))?;
6571        let col = schema_table
6572            .columns
6573            .iter()
6574            .find(|c| c.name == column)
6575            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
6576        if col.ty != ColumnType::Crdt {
6577            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
6578        }
6579        let sql = format!(
6580            "SELECT {} FROM {} WHERE {}",
6581            quote_ident(column),
6582            visible_table(table),
6583            row_id_predicate(schema_table)
6584        );
6585        let bytes: Option<Vec<u8>> = self
6586            .conn
6587            .prepare_cached(&sql)
6588            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6589            .query_row(rusqlite::params![row_id], |row| {
6590                row.get::<_, Option<Vec<u8>>>(0)
6591            })
6592            .map_err(|e| match e {
6593                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
6594                other => Self::sqlite_failure(&self.storage_failure, other),
6595            })?;
6596        Ok(bytes)
6597    }
6598
6599    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
6600    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
6601    /// An absent row / NULL column is the empty document (empty string).
6602    #[cfg(feature = "crdt-yjs")]
6603    pub fn crdt_text(
6604        &self,
6605        table: &str,
6606        row_id: &str,
6607        column: &str,
6608        name: &str,
6609    ) -> Result<String, String> {
6610        let bytes = self
6611            .crdt_column_bytes(table, row_id, column)?
6612            .unwrap_or_default();
6613        crate::crdt::text(&bytes, name)
6614    }
6615
6616    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
6617    /// record the resulting crdt-only sparse operation. Returns the enqueued
6618    /// `clientCommitId`.
6619    #[cfg(feature = "crdt-yjs")]
6620    pub fn crdt_insert_text(
6621        &mut self,
6622        table: &str,
6623        row_id: &str,
6624        column: &str,
6625        name: &str,
6626        index: u32,
6627        value: &str,
6628    ) -> Result<String, ClientError> {
6629        let prior = self.storage_failure.borrow_mut().take();
6630        let outcome = (|| -> Result<String, String> {
6631            let current = self
6632                .crdt_column_bytes(table, row_id, column)?
6633                .unwrap_or_default();
6634            let update = crate::crdt::insert_text(&current, name, index, value)?;
6635            self.perform_crdt_push_update(table, row_id, column, &update)
6636        })();
6637        self.finish_authoring(prior, outcome)
6638    }
6639
6640    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
6641    /// record the resulting crdt-only sparse operation. Returns the enqueued
6642    /// `clientCommitId`.
6643    #[cfg(feature = "crdt-yjs")]
6644    pub fn crdt_delete_text(
6645        &mut self,
6646        table: &str,
6647        row_id: &str,
6648        column: &str,
6649        name: &str,
6650        index: u32,
6651        len: u32,
6652    ) -> Result<String, ClientError> {
6653        let prior = self.storage_failure.borrow_mut().take();
6654        let outcome = (|| -> Result<String, String> {
6655            let current = self
6656                .crdt_column_bytes(table, row_id, column)?
6657                .unwrap_or_default();
6658            let update = crate::crdt::delete_text(&current, name, index, len)?;
6659            self.perform_crdt_push_update(table, row_id, column, &update)
6660        })();
6661        self.finish_authoring(prior, outcome)
6662    }
6663
6664    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
6665    /// `crdt` column's current state and record the resulting crdt-only sparse
6666    /// operation. The app authored the update with its own `yrs` model.
6667    /// Returns the enqueued `clientCommitId`.
6668    #[cfg(feature = "crdt-yjs")]
6669    pub fn crdt_apply_update(
6670        &mut self,
6671        table: &str,
6672        row_id: &str,
6673        column: &str,
6674        update: &[u8],
6675    ) -> Result<String, ClientError> {
6676        let prior = self.storage_failure.borrow_mut().take();
6677        let outcome = (|| -> Result<String, String> {
6678            let current = self
6679                .crdt_column_bytes(table, row_id, column)?
6680                .unwrap_or_default();
6681            let next = crate::crdt::apply_update(&current, update)?;
6682            self.perform_crdt_push_update(table, row_id, column, &next)
6683        })();
6684        self.finish_authoring(prior, outcome)
6685    }
6686
6687    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
6688    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
6689    /// enqueue it. The server merges the crdt column and leaves every other
6690    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
6691    /// clobber a concurrent edit to another column. A locally absent row
6692    /// records the same partial operation; the server answers per §6.2.
6693    #[cfg(feature = "crdt-yjs")]
6694    fn perform_crdt_push_update(
6695        &mut self,
6696        table: &str,
6697        row_id: &str,
6698        column: &str,
6699        crdt_bytes: &[u8],
6700    ) -> Result<String, String> {
6701        let mut bytes_obj = Map::new();
6702        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
6703        let mut partial = Map::new();
6704        partial.insert(column.to_owned(), Value::Object(bytes_obj));
6705        self.perform_patch(table, row_id, partial, None)
6706    }
6707
6708    /// Run an arbitrary read-only SQL query against the local database and
6709    /// return each row as a `column-name → JSON value` map. This is the seam
6710    /// the React `useSyncQuery` live-query API needs (it takes app-authored
6711    /// SQL over the visible tables/views, not a fixed table read like
6712    /// [`read_rows`]).
6713    ///
6714    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
6715    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
6716    /// same envelope the command surface uses everywhere else. Output BLOB
6717    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
6718    ///
6719    /// The result column typing is dynamic (SQLite's stored affinity), because
6720    /// arbitrary SQL can alias, join, and compute — there is no schema column
6721    /// to consult per output cell, unlike [`read_rows`].
6722    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
6723        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
6724    }
6725
6726    /// Repository benchmark and conformance access, present only with `bench-internals`.
6727    #[cfg(feature = "bench-internals")]
6728    #[doc(hidden)]
6729    pub fn benchmark_connection(&mut self) -> &mut Connection {
6730        &mut self.conn
6731    }
6732
6733    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
6734    #[cfg(feature = "bench-internals")]
6735    #[doc(hidden)]
6736    pub fn benchmark_phases(
6737        &self,
6738        enabled: Option<bool>,
6739        reset: bool,
6740    ) -> Result<Option<Value>, String> {
6741        self.benchmark_phases.configure(enabled, reset)?;
6742        Ok(self.benchmark_phases.snapshot())
6743    }
6744
6745    /// One serialized, read-only authority observation, including durable coverage.
6746    pub fn authority_snapshot(&self) -> Result<Value, String> {
6747        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
6748        if self.limits.authority_reads.is_empty() {
6749            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
6750        }
6751        self.conn
6752            .execute_batch("SAVEPOINT authority_snapshot")
6753            .map_err(|e| e.to_string())?;
6754        let result = (|| {
6755            let revision = self
6756                .get_meta(LOCAL_REVISION_KEY)?
6757                .ok_or("sync.local_corrupt: authority revision is missing")?;
6758            let parsed_revision = revision
6759                .parse::<u64>()
6760                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
6761            if parsed_revision.to_string() != revision {
6762                return Err("sync.local_corrupt: authority revision is invalid".into());
6763            }
6764            let mut tables = Vec::new();
6765            for read in &self.limits.authority_reads {
6766                let table = self
6767                    .schema
6768                    .table(&read.table)
6769                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
6770                let scalar = |column: &str, value: &Value| -> Result<String, String> {
6771                    let ty = table
6772                        .columns
6773                        .iter()
6774                        .find(|c| c.name == column)
6775                        .expect("validated authority column")
6776                        .ty;
6777                    if ty == ColumnType::Boolean {
6778                        if let Some(value) = value.as_bool() {
6779                            return Ok(value.to_string());
6780                        }
6781                        return match value.as_i64() {
6782                            Some(0) => Ok("false".into()),
6783                            Some(1) => Ok("true".into()),
6784                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
6785                        };
6786                    }
6787                    match value {
6788                        Value::String(s) => Ok(s.clone()),
6789                        Value::Number(n) => Ok(n.to_string()),
6790                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
6791                            .get("$bigint")
6792                            .and_then(Value::as_str)
6793                            .map(str::to_owned)
6794                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
6795                        _ => {
6796                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
6797                        }
6798                    }
6799                };
6800                let matches = |row: &QueryRow| -> Result<bool, String> {
6801                    for (variable, values) in &read.scopes {
6802                        let column = table
6803                            .scope_column(variable)
6804                            .expect("validated authority scope");
6805                        let value = row
6806                            .get(column)
6807                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
6808                        if value.is_null() || !values.contains(&scalar(column, value)?) {
6809                            return Ok(false);
6810                        }
6811                    }
6812                    Ok(true)
6813                };
6814                let selected = read
6815                    .columns
6816                    .iter()
6817                    .map(|c| quote_ident(c))
6818                    .collect::<Vec<_>>()
6819                    .join(",");
6820                let mut intent = BTreeSet::new();
6821                let mut relevant = BTreeSet::new();
6822                for row in query_connection(&self.conn,
6823                    "SELECT id,values_json FROM (SELECT json_extract(value,'$.table') AS tbl,json_extract(value,'$.rowId') AS id,json_extract(value,'$.values') AS values_json FROM _syncular_outbox,json_each(ops_json) UNION ALL SELECT json_extract(value,'$.table'),json_extract(value,'$.rowId'),json_extract(value,'$.values') FROM _syncular_failed_commits,json_each(operations_json) UNION ALL SELECT tbl,id,intent_json FROM _syncular_acked_rows) WHERE tbl=?",
6824                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
6825                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
6826                    intent.insert(id.clone());
6827                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
6828                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
6829                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
6830                    }
6831                }
6832                let mut rows = BTreeMap::new();
6833                for full in [base_table(&read.table), visible_table(&read.table)] {
6834                    for mut values in query_connection(
6835                        &self.conn,
6836                        &format!(
6837                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
6838                        ),
6839                        &[],
6840                    )
6841                    .map_err(|e| e.to_string())?
6842                    {
6843                        if !matches(&values)? {
6844                            continue;
6845                        }
6846                        let id = scalar(
6847                            &table.primary_key,
6848                            values
6849                                .get(&table.primary_key)
6850                                .expect("selected primary key"),
6851                        )?;
6852                        relevant.insert(id.clone());
6853                        if full == base_table(&read.table) {
6854                            let version = values
6855                                .remove("authority_version")
6856                                .expect("selected version");
6857                            let version = version
6858                                .as_i64()
6859                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
6860                            if version >= 0 {
6861                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
6862                            }
6863                        }
6864                    }
6865                }
6866                intent.retain(|id| relevant.contains(id));
6867                let rows = rows.into_values().collect::<Vec<_>>();
6868                let mut persisted = Vec::new();
6869                for row in query_connection(
6870                    &self.conn,
6871                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
6872                    &[Value::String(read.table.clone())],
6873                )
6874                .map_err(|e| e.message)?
6875                {
6876                    let state: Value = serde_json::from_str(
6877                        row.get("state_json")
6878                            .and_then(Value::as_str)
6879                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
6880                    )
6881                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
6882                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
6883                    let status = state
6884                        .get("status")
6885                        .and_then(Value::as_str)
6886                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
6887                    let cursor = state
6888                        .get("cursor")
6889                        .and_then(Value::as_i64)
6890                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
6891                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
6892                        && status == "active"
6893                        && cursor >= 0
6894                        && state.get("bootstrapState").is_none_or(Value::is_null)
6895                        && state.get("params").is_none_or(Value::is_null);
6896                    let exposed = effective.map(|s| {
6897                        read.scopes
6898                            .keys()
6899                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
6900                            .collect::<Map<_, _>>()
6901                    });
6902                    let requested = read
6903                        .scopes
6904                        .keys()
6905                        .map(|v| {
6906                            (
6907                                v.clone(),
6908                                state
6909                                    .get("requested")
6910                                    .and_then(|s| s.get(v))
6911                                    .cloned()
6912                                    .unwrap_or(json!([])),
6913                            )
6914                        })
6915                        .collect::<Map<_, _>>();
6916                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
6917                }
6918                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
6919                    match selectors.split_first() {
6920                        None => !candidates.is_empty(),
6921                        Some(((variable, values), rest)) => values.iter().all(|value| {
6922                            covered(
6923                                rest,
6924                                &candidates
6925                                    .iter()
6926                                    .copied()
6927                                    .filter(|p| {
6928                                        p["effectiveScopes"][variable.as_str()]
6929                                            .as_array()
6930                                            .is_some_and(|held| {
6931                                                held.contains(&Value::String(value.clone()))
6932                                                    || held.contains(&Value::String("*".into()))
6933                                            })
6934                                    })
6935                                    .collect::<Vec<_>>(),
6936                            )
6937                        }),
6938                    }
6939                }
6940                let coverage = if covered(
6941                    &read.scopes.iter().collect::<Vec<_>>(),
6942                    &persisted
6943                        .iter()
6944                        .filter(|p| p["complete"] == true)
6945                        .collect::<Vec<_>>(),
6946                ) {
6947                    "complete"
6948                } else if persisted.iter().any(|p| {
6949                    p["status"] == "active"
6950                        && read.scopes.iter().all(|(v, values)| {
6951                            values.iter().any(|value| {
6952                                p["requestedScopes"][v].as_array().is_some_and(|held| {
6953                                    held.contains(&Value::String(value.clone()))
6954                                        || held.contains(&Value::String("*".into()))
6955                                })
6956                            })
6957                        })
6958                }) {
6959                    "pending"
6960                } else {
6961                    "missing"
6962                };
6963                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
6964            }
6965            Ok(
6966                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
6967            )
6968        })();
6969        match result {
6970            Ok(snapshot) => {
6971                self.conn
6972                    .execute_batch("RELEASE authority_snapshot")
6973                    .map_err(|e| e.to_string())?;
6974                Ok(snapshot)
6975            }
6976            Err(error) => {
6977                self.conn
6978                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
6979                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
6980                Err(error)
6981            }
6982        }
6983    }
6984
6985    /// Rows, coverage, and local revision from one SQLite read snapshot.
6986    /// §7.5 atomic snapshot read. An `owner` records a failure in
6987    /// `diagnostics_snapshot().query_failures` until its next successful read.
6988    pub fn query_snapshot(
6989        &mut self,
6990        sql: &str,
6991        params: &[QueryValue],
6992        coverage: &[WindowCoverage],
6993        owner: Option<QueryOwner<'_>>,
6994    ) -> Result<QuerySnapshot, String> {
6995        let result = snapshot_connection(&self.conn, sql, params, coverage);
6996        if let Some(owner) = owner {
6997            self.record_query_read(owner, result.as_ref().err());
6998        }
6999        result.map_err(|failure| failure.to_string())
7000    }
7001
7002    /// §7.6: record the outcome of one owned snapshot read, including reads a
7003    /// host ran on a separate connection. A success removes the owner's entry;
7004    /// an identical failure leaves it unchanged; a changed failure moves last.
7005    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
7006        let position = self
7007            .query_failures
7008            .iter()
7009            .position(|entry| entry.id == owner.id);
7010        let Some(failure) = failure else {
7011            if let Some(index) = position {
7012                self.query_failures.remove(index);
7013            }
7014            return;
7015        };
7016        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
7017            .into_iter()
7018            .map(str::to_owned)
7019            .collect();
7020        let code = failure.code.unwrap_or("client.query_failed").to_owned();
7021        if position.is_some_and(|index| {
7022            let entry = &self.query_failures[index];
7023            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
7024        }) {
7025            return;
7026        }
7027        if let Some(index) = position {
7028            self.query_failures.remove(index);
7029        }
7030        self.query_failures.push(DiagnosticQueryFailure {
7031            id: owner.id.to_owned(),
7032            tables,
7033            code,
7034            sqlite_code: failure.sqlite_code,
7035            at_ms: self.clock_now_ms(),
7036        });
7037        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
7038            self.query_failures.remove(0);
7039        }
7040    }
7041
7042    // -- request building ---------------------------------------------------------
7043
7044    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
7045        #[cfg(feature = "bench-internals")]
7046        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
7047        let log_epoch = self.get_meta(LOG_EPOCH_KEY).map_err(|message| {
7048            Box::new(SyncOutcome::Failed {
7049                error_code: "storage.failed".into(),
7050                message,
7051                details: None,
7052                recommended_action: None,
7053            })
7054        })?;
7055        let max_commits = self.limits.max_push_commits_per_request;
7056        let max_ops = self
7057            .limits
7058            .max_push_operations_per_request
7059            .unwrap_or(PUSH_OPS_PER_REQUEST);
7060        let byte_cap = self.limits.max_push_request_bytes;
7061        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
7062        // bare URL — capability negotiation, decided per transport.
7063        let accept = self.limits.accept.unwrap_or(if url_capable {
7064            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
7065        } else {
7066            DEFAULT_ACCEPT
7067        });
7068        let header = Frame::ReqHeader {
7069            client_id: self.client_id.clone(),
7070            schema_version: self.schema.version,
7071            log_epoch: log_epoch.clone(),
7072        };
7073        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
7074        // which stays ahead of every push, pull and subscription frame; the
7075        // byte-capacity probes below measure this exact sequence.
7076        let mut lead = vec![header];
7077        lead.extend(
7078            self.round_failure
7079                .iter()
7080                .map(|code| Frame::RoundFailure { code: code.clone() }),
7081        );
7082        let pull = Frame::PullHeader {
7083            limit_commits: self.limits.limit_commits.unwrap_or(0),
7084            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
7085            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
7086            accept,
7087        };
7088        let mut fresh = Vec::new();
7089        let mut sub_frames = Vec::new();
7090        for sub in self.subs.iter() {
7091            if sub.state != SubState::Active {
7092                continue;
7093            }
7094            let mut scopes = sub.requested.clone();
7095            sort_scope_map(&mut scopes);
7096            sub_frames.push(Frame::Subscription {
7097                id: sub.id.clone(),
7098                table: sub.table.clone(),
7099                scopes,
7100                params: sub.params.clone().map(RawJson),
7101                cursor: sub.cursor,
7102                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
7103            });
7104            fresh.push((
7105                sub.id.clone(),
7106                sub.cursor < 0 && sub.bootstrap_state.is_none(),
7107            ));
7108        }
7109        // The fixed frames (order-independent size) and the header+pull probe
7110        // both encode canonically, so the byte budget is the exact wire size.
7111        let fixed_bytes = match byte_cap {
7112            Some(_) => {
7113                let mut frames = lead.clone();
7114                frames.push(pull.clone());
7115                frames.extend(sub_frames.iter().cloned());
7116                encode_message(&Message {
7117                    wire_version: WIRE_VERSION,
7118                    msg_kind: MsgKind::Request,
7119                    frames,
7120                })
7121                .len()
7122            }
7123            None => 0,
7124        };
7125        if let Some(cap) = byte_cap {
7126            if fixed_bytes > cap {
7127                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
7128            }
7129        }
7130        let base_bytes = match byte_cap {
7131            Some(_) => {
7132                let mut frames = lead.clone();
7133                frames.push(pull.clone());
7134                encode_message(&Message {
7135                    wire_version: WIRE_VERSION,
7136                    msg_kind: MsgKind::Request,
7137                    frames,
7138                })
7139                .len()
7140            }
7141            None => 0,
7142        };
7143        let total_outbox = if log_epoch.is_some() {
7144            self.outbox.len()
7145        } else {
7146            0
7147        };
7148        let mut push_frames: Vec<Frame> = Vec::new();
7149        let mut pushed_ids = Vec::new();
7150        let mut ops_in_request = 0usize;
7151        let mut bytes_in_request = 0usize;
7152        let mut deferred_commits = 0usize;
7153        #[cfg(feature = "bench-internals")]
7154        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
7155        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
7156            // §6.1/§7.1: the commit-count and operation caps are hard. A
7157            // first commit over the operation cap is a typed capacity error
7158            // and stays queued; a later one defers with the complete suffix.
7159            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
7160                deferred_commits = total_outbox - index;
7161                break;
7162            }
7163            if ops_in_request + commit.ops.len() > max_ops {
7164                if push_frames.is_empty() {
7165                    return Err(Self::push_capacity_outcome(
7166                        "operations",
7167                        max_ops,
7168                        commit.ops.len(),
7169                        Some(commit.client_commit_id.as_str()),
7170                    ));
7171                }
7172                deferred_commits = total_outbox - index;
7173                break;
7174            }
7175            let operations = commit
7176                .ops
7177                .iter()
7178                .map(|op| {
7179                    let payload = match self
7180                        .schema
7181                        .table(&op.table)
7182                        .and_then(|table| Some((table, op.presented_values(table)?)))
7183                    {
7184                        Some((table, values)) => {
7185                            // §0: outbox entries encode at send time with the
7186                            // current codec (validated at mutate()). §6.1: the
7187                            // values map's keys are the sparse presence set; an
7188                            // insert-intent operation presents every column (§7.1).
7189                            // §5.11: encrypted columns are encrypted here, with
7190                            // an absent key-id selector read from the stored row.
7191                            // `sync_inner` runs `drop_unencodable_outbox` first,
7192                            // so an encrypt failure cannot reach this encode; the
7193                            // author seam validated every other failure.
7194                            let fallback =
7195                                self.stored_key_fallback(table, &op.row_id)
7196                                    .map_err(|message| {
7197                                        Box::new(SyncOutcome::Failed {
7198                                            error_code: "storage.failed".into(),
7199                                            message,
7200                                            details: None,
7201                                            recommended_action: None,
7202                                        })
7203                                    })?;
7204                            encode_sparse_row_json(
7205                                table,
7206                                &op.row_id,
7207                                &values,
7208                                &self.encryption,
7209                                fallback.as_ref(),
7210                            )
7211                            .ok()
7212                        }
7213                        None => None,
7214                    };
7215                    Ok(ssp2::model::Operation {
7216                        table: op.table.clone(),
7217                        row_id: op.row_id.clone(),
7218                        op: if op.upsert { Op::Upsert } else { Op::Delete },
7219                        base_version: op.base_version,
7220                        payload,
7221                    })
7222                })
7223                .collect::<Result<Vec<_>, Box<SyncOutcome>>>()?;
7224            let frame = Frame::PushCommit {
7225                client_commit_id: commit.client_commit_id.clone(),
7226                operations,
7227            };
7228            let frame_bytes = match byte_cap {
7229                Some(_) => {
7230                    let mut frames = lead.clone();
7231                    frames.push(frame.clone());
7232                    frames.push(pull.clone());
7233                    encode_message(&Message {
7234                        wire_version: WIRE_VERSION,
7235                        msg_kind: MsgKind::Request,
7236                        frames,
7237                    })
7238                    .len()
7239                        - base_bytes
7240                }
7241                None => 0,
7242            };
7243            // The head is always encoded so the caller can report the full
7244            // projected request size; a later commit defers once it no longer
7245            // fits.
7246            if let Some(cap) = byte_cap {
7247                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
7248                    deferred_commits = total_outbox - index;
7249                    break;
7250                }
7251            }
7252            push_frames.push(frame);
7253            pushed_ids.push(commit.client_commit_id.clone());
7254            ops_in_request += commit.ops.len();
7255            bytes_in_request += frame_bytes;
7256        }
7257        #[cfg(feature = "bench-internals")]
7258        drop(outbox_phase);
7259        if let Some(cap) = byte_cap {
7260            if fixed_bytes + bytes_in_request > cap {
7261                // The head alone does not fit this budget; the commit stays
7262                // queued and no transport round is attempted.
7263                let blocked = if push_frames.is_empty() {
7264                    None
7265                } else {
7266                    self.outbox.first().map(|c| c.client_commit_id.as_str())
7267                };
7268                return Err(Self::push_capacity_outcome(
7269                    "bytes",
7270                    cap,
7271                    fixed_bytes + bytes_in_request,
7272                    blocked,
7273                ));
7274            }
7275        }
7276        let mut frames = lead;
7277        frames.extend(push_frames);
7278        frames.push(pull);
7279        frames.extend(sub_frames);
7280        let message = Message {
7281            wire_version: WIRE_VERSION,
7282            msg_kind: MsgKind::Request,
7283            frames,
7284        };
7285        Ok((
7286            message,
7287            RequestMeta {
7288                pushed_ids,
7289                fresh,
7290                accept,
7291                deferred_commits,
7292            },
7293        ))
7294    }
7295
7296    /// §7.1: a configured push capacity limit cannot carry this request.
7297    fn push_capacity_outcome(
7298        kind: &str,
7299        limit: usize,
7300        size: usize,
7301        client_commit_id: Option<&str>,
7302    ) -> Box<SyncOutcome> {
7303        let mut details = Map::new();
7304        details.insert("kind".to_owned(), Value::from(kind));
7305        details.insert("limit".to_owned(), Value::from(limit as u64));
7306        details.insert("size".to_owned(), Value::from(size as u64));
7307        if let Some(id) = client_commit_id {
7308            details.insert("clientCommitId".to_owned(), Value::from(id));
7309        }
7310        Box::new(SyncOutcome::Failed {
7311            error_code: "client.push_request_too_large".into(),
7312            message: "the push request exceeds a configured capacity limit".into(),
7313            details: Some(Value::Object(details)),
7314            recommended_action: None,
7315        })
7316    }
7317
7318    // -- sync -------------------------------------------------------------------
7319
7320    /// Cloneable observation handle; no client lock is needed to read it.
7321    pub fn progress(&self) -> ProgressObserver {
7322        self.progress.clone()
7323    }
7324
7325    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7326        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
7327            Ok(prepared) => prepared,
7328            Err(outcome) => return *outcome,
7329        };
7330        // The round's network deadline rides `PreparedSyncRound` and is scoped
7331        // to each exchange, so uploads, continuations, the main request, and
7332        // every segment fetch share one monotonic anchor without leaving
7333        // deadline state on a reused transport.
7334        let mut next = prepared;
7335        loop {
7336            match self.apply_sync_round(next.exchange(transport)) {
7337                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
7338                crate::AppliedSyncRound::Complete {
7339                    outcome, controls, ..
7340                } => {
7341                    if self.transport_enabled {
7342                        for text in controls {
7343                            let _ = transport.realtime_send(&text);
7344                        }
7345                    } else {
7346                        self.disconnect_realtime(transport);
7347                    }
7348                    return outcome;
7349                }
7350            }
7351        }
7352    }
7353
7354    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
7355        if let SyncOutcome::Failed {
7356            error_code,
7357            message,
7358            details,
7359            ..
7360        } = &mut outcome
7361        {
7362            if let Some((failure, code)) = self
7363                .storage_failure
7364                .borrow()
7365                .as_ref()
7366                .and_then(|failure| failure.code.map(|code| (failure, code)))
7367            {
7368                *error_code = code.to_owned();
7369                *message = match code {
7370                    "client.storage_busy" => "local SQLite storage is busy",
7371                    "client.storage_full" => "local SQLite storage is full",
7372                    "client.storage_io" => "local SQLite storage I/O failed",
7373                    "client.storage_corrupt" => "local SQLite storage is corrupt",
7374                    _ => unreachable!("unrecognized classified storage failure"),
7375                }
7376                .to_owned();
7377                *details = failure.details();
7378            }
7379        }
7380        let details = match &outcome {
7381            SyncOutcome::Failed { details, .. } => details.as_ref(),
7382            _ => None,
7383        };
7384        let cause_kind = details
7385            .and_then(|details| details.get("causeKind"))
7386            .and_then(Value::as_str)
7387            .and_then(|cause| match cause {
7388                "network" => Some(crate::TransportCauseKind::Network),
7389                "connect" => Some(crate::TransportCauseKind::Connect),
7390                "tls" => Some(crate::TransportCauseKind::Tls),
7391                "io" => Some(crate::TransportCauseKind::Io),
7392                "request" => Some(crate::TransportCauseKind::Request),
7393                "protocol" => Some(crate::TransportCauseKind::Protocol),
7394                "timeout" => Some(crate::TransportCauseKind::Timeout),
7395                "round_deadline" => Some(crate::TransportCauseKind::RoundDeadline),
7396                "aborted" => Some(crate::TransportCauseKind::Aborted),
7397                "body" => Some(crate::TransportCauseKind::Body),
7398                "status" => Some(crate::TransportCauseKind::Status),
7399                "redirect" => Some(crate::TransportCauseKind::Redirect),
7400                "unknown" => Some(crate::TransportCauseKind::Unknown),
7401                _ => None,
7402            });
7403        let http_status = if matches!(
7404            cause_kind,
7405            Some(
7406                crate::TransportCauseKind::Status
7407                    | crate::TransportCauseKind::Body
7408                    | crate::TransportCauseKind::Redirect
7409            )
7410        ) {
7411            details
7412                .and_then(|details| details.get("httpStatus"))
7413                .and_then(Value::as_f64)
7414                .filter(|status| status.fract() == 0.0 && (100.0..=599.0).contains(status))
7415                .map(|status| status as u16)
7416        } else {
7417            None
7418        };
7419        let retry_delay_ms = self.round_retry_delay_ms;
7420        self.progress.update(|p| match &outcome {
7421            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
7422                if report.failed.is_empty() =>
7423            {
7424                p.state = ProgressState::Complete
7425            }
7426            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
7427                p.state = ProgressState::Failed;
7428                p.error_code = Some("sync.scope_revoked".into());
7429            }
7430            SyncOutcome::Failed { error_code, .. } => {
7431                p.state = ProgressState::Failed;
7432                p.error_code = Some(Self::diagnostic_code(error_code));
7433                p.cause_kind = cause_kind;
7434                p.http_status = http_status;
7435                p.retry_delay_ms = retry_delay_ms;
7436            }
7437            SyncOutcome::RealtimeUnavailable { .. } => {
7438                p.state = ProgressState::Failed;
7439                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
7440                p.retry_delay_ms = retry_delay_ms;
7441            }
7442        });
7443        let completed_at_ms = self.clock_now_ms();
7444        self.last_round = Some(match &outcome {
7445            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
7446                status: "succeeded".to_owned(),
7447                started_at_ms,
7448                completed_at_ms,
7449                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7450                counters: Some(DiagnosticRoundCounters {
7451                    pushed: report.pushed,
7452                    applied: report.applied.len(),
7453                    rejected: report.rejected.len(),
7454                    retryable: report.retryable.len(),
7455                    conflicts: report.conflicts,
7456                    commits_applied: report.commits_applied,
7457                    segment_rows_applied: report.segment_rows_applied,
7458                    bootstrapping: report.bootstrapping.len(),
7459                    resets: report.resets.len(),
7460                    revoked: report.revoked.len(),
7461                    failed: report.failed.len(),
7462                    deferred_commits: report.deferred_commits,
7463                }),
7464                error_code: None,
7465                cause_kind: None,
7466                http_status: None,
7467            },
7468            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
7469                status: "failed".to_owned(),
7470                started_at_ms,
7471                completed_at_ms,
7472                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7473                counters: None,
7474                error_code: Some(Self::diagnostic_code(error_code)),
7475                cause_kind,
7476                http_status,
7477            },
7478            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
7479                status: "failed".to_owned(),
7480                started_at_ms,
7481                completed_at_ms,
7482                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7483                counters: None,
7484                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
7485                cause_kind: None,
7486                http_status: None,
7487            },
7488        });
7489        outcome
7490    }
7491
7492    /// Capture one request on the client owner. Mutations made after this
7493    /// call remain in the outbox for a later round; no network I/O occurs here.
7494    pub fn prepare_sync_round(
7495        &mut self,
7496        url_capable: bool,
7497    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
7498        if !self.transport_enabled {
7499            return Err(Box::new(SyncOutcome::Failed {
7500                error_code: "sync.offline".into(),
7501                message: "transport gate is closed".into(),
7502                details: None,
7503                recommended_action: None,
7504            }));
7505        }
7506        if self.active_round.is_some() {
7507            return Err(Box::new(SyncOutcome::Failed {
7508                error_code: "client.round_in_progress".into(),
7509                message: "a sync round is already in flight".into(),
7510                details: None,
7511                recommended_action: None,
7512            }));
7513        }
7514        self.storage_failure.borrow_mut().take();
7515        self.progress.start();
7516        self.round_retry_delay_ms = None;
7517        let started_at_ms = self.clock_now_ms();
7518        let prepared = (|| {
7519            if self.stopped {
7520                return Err(Box::new(SyncOutcome::Ok(SyncReport {
7521                    schema_floor: self.schema_floor.clone(),
7522                    ..SyncReport::default()
7523                })));
7524            }
7525            if self.security_preflight {
7526                return Err(Box::new(SyncOutcome::Failed {
7527                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
7528                    message: "security preflight must complete before syncing".into(),
7529                    details: None,
7530                    recommended_action: None,
7531                }));
7532            }
7533            self.set_sync_needed(false, false);
7534            self.drain_pending_evictions()
7535                .and_then(|_| self.drop_incompatible_outbox())
7536                .and_then(|_| self.drop_unencodable_outbox(true, None))
7537                .map_err(|message| {
7538                    Box::new(SyncOutcome::Failed {
7539                        error_code: "storage.failed".into(),
7540                        message,
7541                        details: None,
7542                        recommended_action: None,
7543                    })
7544                })?;
7545            let has_log_epoch = self.get_meta(LOG_EPOCH_KEY).map_err(|message| {
7546                Box::new(SyncOutcome::Failed {
7547                    error_code: "storage.failed".into(),
7548                    message,
7549                    details: None,
7550                    recommended_action: None,
7551                })
7552            })?;
7553            let uploads = if has_log_epoch.is_some() && self.schema_has_blobs() {
7554                self.pending_blob_uploads().map_err(|e| {
7555                    Box::new(SyncOutcome::Failed {
7556                        error_code: e.code,
7557                        message: e.message,
7558                        details: e.details,
7559                        recommended_action: None,
7560                    })
7561                })?
7562            } else {
7563                Vec::new()
7564            };
7565            if self.realtime_state != RealtimeState::Connected
7566                && self.realtime_policy == RealtimePolicy::Required
7567            {
7568                let retry_delay_ms = self.schedule_background_retry();
7569                self.realtime_retry_delay_ms = Some(retry_delay_ms);
7570                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
7571                    state: self.realtime_state,
7572                    reason_code: self.realtime_reason_code.clone(),
7573                    retry_delay_ms,
7574                }));
7575            }
7576            let (message, meta) = self.build_request(url_capable)?;
7577            let id = uuid::Uuid::new_v4();
7578            self.active_round = Some(id);
7579            let phase = if uploads.is_empty() {
7580                crate::round::RoundPhase::Sync
7581            } else {
7582                crate::round::RoundPhase::UploadGrant
7583            };
7584            Ok(crate::PreparedSyncRound {
7585                id,
7586                started_at_ms,
7587                message,
7588                meta,
7589                uploads: uploads.into(),
7590                phase,
7591                encoded: None,
7592                continuation: None,
7593                completed: None,
7594                realtime: self.realtime_state == RealtimeState::Connected,
7595                scopes: self
7596                    .subs
7597                    .iter()
7598                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
7599                    .collect(),
7600                #[cfg(feature = "bench-internals")]
7601                benchmark_phases: self.benchmark_phases.clone(),
7602                progress: self.progress.clone(),
7603                fixed_now: self.now_ms,
7604                round_deadline_at: None,
7605            })
7606        })();
7607        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
7608    }
7609
7610    /// Discard the reply of an old authorization/subscription context. Its
7611    /// submitted commits stay durable and can be retried with the same ids.
7612    pub fn cancel_sync_round(&mut self) {
7613        self.active_round = None;
7614    }
7615
7616    /// Apply captured network results on the owner, preserving all normal
7617    /// version, schema, content-address and cursor checks. No network I/O.
7618    pub fn apply_sync_round(
7619        &mut self,
7620        completed: crate::CompletedSyncRound,
7621    ) -> crate::AppliedSyncRound {
7622        let mut prepared = completed.prepared;
7623        if self.active_round != Some(prepared.id) || self.security_preflight {
7624            return crate::AppliedSyncRound::Complete {
7625                outcome: SyncOutcome::Failed {
7626                    error_code: "client.round_cancelled".into(),
7627                    message: "sync round belongs to a released client context".into(),
7628                    details: None,
7629                    recommended_action: None,
7630                },
7631                controls: Vec::new(),
7632                more: false,
7633                bootstrap_advanced: false,
7634            };
7635        }
7636        self.storage_failure.borrow_mut().take();
7637        let (response, mut downloads, transport_failed) = match completed.exchange {
7638            crate::round::ExchangeResult::Upload { id, result } => {
7639                let result = match result {
7640                    // §5.9.7: a non-retryable server refusal (`blob.too_large`)
7641                    // is the body's final outcome. The commits naming it take
7642                    // the incompatible-commit path with the server's code and
7643                    // leave this round's request; the round continues.
7644                    Err(error)
7645                        if error
7646                            .details
7647                            .as_ref()
7648                            .and_then(|details| details.get("retryable"))
7649                            == Some(&Value::Bool(false)) =>
7650                    {
7651                        self.conn
7652                            .prepare(
7653                                "SELECT commit_id FROM _syncular_blob_commit_refs WHERE blob_id = ?",
7654                            )
7655                            .and_then(|mut statement| {
7656                                statement
7657                                    .query_map(rusqlite::params![id], |row| row.get::<_, String>(0))?
7658                                    .collect::<Result<HashSet<_>, _>>()
7659                            })
7660                            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7661                            .and_then(|refused| {
7662                                let commits = self
7663                                    .outbox
7664                                    .iter()
7665                                    .filter(|commit| refused.contains(&commit.client_commit_id))
7666                                    .cloned()
7667                                    .collect::<Vec<_>>();
7668                                if !commits.is_empty() {
7669                                    self.drop_outbox_commits(&commits, &error.code, &error.message)?;
7670                                }
7671                                prepared.message.frames.retain(|frame| {
7672                                    !matches!(frame, Frame::PushCommit { client_commit_id, .. }
7673                                        if refused.contains(client_commit_id))
7674                                });
7675                                prepared.meta.pushed_ids.retain(|id| !refused.contains(id));
7676                                prepared.continuation = Some(crate::round::RoundPhase::UploadGrant);
7677                                Ok(())
7678                            })
7679                            .map_err(|message| TransportError::new("client.failed", message))
7680                    }
7681                    result => result,
7682                };
7683                let result = result.and_then(|()| {
7684                    self.conn
7685                        .execute(
7686                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
7687                            rusqlite::params![id],
7688                        )
7689                        .map(|_| ())
7690                        .map_err(|error| {
7691                            TransportError::new(
7692                                "client.failed",
7693                                Self::sqlite_failure(&self.storage_failure, error),
7694                            )
7695                        })
7696                });
7697                match result {
7698                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
7699                    Err(TransportError {
7700                        code,
7701                        message,
7702                        details,
7703                    }) => {
7704                        self.active_round = None;
7705                        if Self::unreachable_transport(&details) {
7706                            // The round's work is still owed; the host's
7707                            // connectivity resume runs it.
7708                            self.set_sync_needed(true, false);
7709                        } else if Self::retryable_failure_code(&code) {
7710                            self.schedule_background_retry();
7711                        }
7712                        let recommended_action = recommended_action_of(&details);
7713                        let outcome = self.finish_sync_round(
7714                            prepared.started_at_ms,
7715                            SyncOutcome::Failed {
7716                                error_code: code,
7717                                message,
7718                                details,
7719                                recommended_action,
7720                            },
7721                        );
7722                        return crate::AppliedSyncRound::Complete {
7723                            outcome,
7724                            controls: Vec::new(),
7725                            more: false,
7726                            bootstrap_advanced: false,
7727                        };
7728                    }
7729                }
7730            }
7731            crate::round::ExchangeResult::Reply {
7732                response,
7733                downloads,
7734                transport_failed,
7735            } => (response, downloads, transport_failed),
7736        };
7737        self.active_round = None;
7738        let before = self
7739            .subs
7740            .iter()
7741            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
7742            .collect::<Vec<_>>();
7743        let outcome = (|| {
7744            let response = match response {
7745                Ok(response) => {
7746                    // §1.5: a complete, decodable response ends the report, an
7747                    // in-band ERROR included (the server already recorded it).
7748                    self.round_failure = None;
7749                    response
7750                }
7751                Err(TransportError {
7752                    code,
7753                    message,
7754                    details,
7755                }) => {
7756                    // §1.5: the server cannot see a round whose transport
7757                    // failed or whose response never decoded, so its code rides
7758                    // the following requests. A code the frame grammar rejects
7759                    // is not reportable, and the previous code stays.
7760                    if ssp2::model::valid_round_failure_code(&code) {
7761                        self.round_failure = Some(code.clone());
7762                    }
7763                    if transport_failed {
7764                        if prepared.realtime {
7765                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
7766                        }
7767                        if let Err(message) = self.record_lease_error(&code) {
7768                            return SyncOutcome::Failed {
7769                                error_code: "storage.failed".to_owned(),
7770                                message,
7771                                details: None,
7772                                recommended_action: None,
7773                            };
7774                        }
7775                    }
7776                    if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
7777                    {
7778                        let delay = self.schedule_background_retry();
7779                        if prepared.realtime && transport_failed {
7780                            self.realtime_retry_delay_ms = Some(delay);
7781                        }
7782                    }
7783                    let recommended_action = recommended_action_of(&details);
7784                    return SyncOutcome::Failed {
7785                        error_code: code,
7786                        message,
7787                        details,
7788                        recommended_action,
7789                    };
7790                }
7791            };
7792            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
7793            if let SyncOutcome::Ok(report) = &mut outcome {
7794                report.deferred_commits = prepared.meta.deferred_commits;
7795            }
7796            match &outcome {
7797                SyncOutcome::Ok(_) => self.reset_background_retry(),
7798                SyncOutcome::Failed {
7799                    error_code,
7800                    details,
7801                    ..
7802                } if Self::retryable_failure_code(error_code)
7803                    && !Self::unreachable_transport(details) =>
7804                {
7805                    self.schedule_background_retry();
7806                }
7807                _ => {}
7808            }
7809            if prepared.meta.deferred_commits > 0 {
7810                self.set_sync_needed(true, true);
7811            }
7812            self.previous_version_lifetime_check();
7813            outcome
7814        })();
7815        match &outcome {
7816            // §4.7: a subscription that still holds a resume token needs
7817            // another round, also when the drive that started it ends early.
7818            SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() => {
7819                self.set_sync_needed(true, false)
7820            }
7821            // The round's work is still owed; the host's connectivity resume
7822            // runs it.
7823            SyncOutcome::Failed { details, .. } if Self::unreachable_transport(details) => {
7824                self.set_sync_needed(true, false)
7825            }
7826            _ => {}
7827        }
7828        let more = self.transport_enabled
7829            && matches!(&outcome, SyncOutcome::Ok(report)
7830                if report.schema_floor.is_none()
7831                    && (!report.bootstrapping.is_empty()
7832                        || report.commits_applied > 0
7833                        || report.segment_rows_applied > 0
7834                        || !report.resets.is_empty()
7835                        || report.deferred_commits > 0
7836                        || self.sync_needed));
7837        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
7838            && before
7839                != self
7840                    .subs
7841                    .iter()
7842                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
7843                    .collect::<Vec<_>>();
7844        crate::AppliedSyncRound::Complete {
7845            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
7846            controls: if self.transport_enabled {
7847                downloads.controls
7848            } else {
7849                Vec::new()
7850            },
7851            more,
7852            bootstrap_advanced,
7853        }
7854    }
7855
7856    /// §7.7: one idle drive's default total deadline, the TS client's value.
7857    pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
7858
7859    pub fn sync_until_idle(
7860        &mut self,
7861        transport: &mut dyn Transport,
7862        max_rounds: Option<u32>,
7863    ) -> SyncOutcome {
7864        self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
7865    }
7866
7867    /// As [`SyncClient::sync_until_idle`], with an explicit total deadline in
7868    /// milliseconds (`None` disables it). The deadline is checked between
7869    /// rounds; one blocking transport call is bounded by the transport's own
7870    /// whole-round deadline (§1.8).
7871    pub fn sync_until_idle_with_deadline(
7872        &mut self,
7873        transport: &mut dyn Transport,
7874        max_rounds: Option<u32>,
7875        timeout_ms: Option<u64>,
7876    ) -> SyncOutcome {
7877        // §7.7: an explicit budget is a strict positive round count. Zero is a
7878        // caller error, not an exhaustion.
7879        if max_rounds == Some(0) {
7880            return SyncOutcome::Failed {
7881                details: None,
7882                error_code: "sync.invalid_request".into(),
7883                message: "maxRounds must be a positive integer".into(),
7884                recommended_action: None,
7885            };
7886        }
7887        if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
7888            return SyncOutcome::Failed {
7889                details: None,
7890                error_code: "sync.invalid_request".into(),
7891                message: "timeoutMs must be a positive integer".into(),
7892                recommended_action: None,
7893            };
7894        }
7895        let started_at_ms = self.clock_now_ms();
7896        let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
7897        self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
7898    }
7899
7900    fn sync_until_idle_bounded(
7901        &mut self,
7902        transport: &mut dyn Transport,
7903        max_rounds: Option<u32>,
7904        started_at_ms: i64,
7905        deadline_ms: Option<i64>,
7906    ) -> SyncOutcome {
7907        let rounds = max_rounds.unwrap_or(20);
7908        let mut aggregate = SyncReport::default();
7909        let mut spent = 0;
7910        while spent < rounds {
7911            if let Some(deadline_ms) = deadline_ms {
7912                if self.clock_now_ms() >= deadline_ms {
7913                    return SyncOutcome::Failed {
7914                        error_code: "sync.idle_timeout".into(),
7915                        message: "sync_until_idle exceeded its deadline".into(),
7916                        details: Some(serde_json::json!({
7917                            "timeoutMs": deadline_ms
7918                                .saturating_sub(started_at_ms) as u64,
7919                            "rounds": spent,
7920                        })),
7921                        recommended_action: Some("retryLater".into()),
7922                    };
7923                }
7924            }
7925            spent += 1;
7926            let before = self
7927                .subs
7928                .iter()
7929                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
7930                .collect::<Vec<_>>();
7931            match self.sync(transport) {
7932                SyncOutcome::Failed {
7933                    error_code,
7934                    message,
7935                    details,
7936                    recommended_action,
7937                } => {
7938                    return SyncOutcome::Failed {
7939                        details,
7940                        error_code,
7941                        message,
7942                        recommended_action,
7943                    };
7944                }
7945                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
7946                SyncOutcome::BudgetExhausted(_) => {
7947                    unreachable!("sync() never exhausts a round budget")
7948                }
7949                SyncOutcome::Ok(report) => {
7950                    if max_rounds.is_none()
7951                        && report.segment_rows_applied > 0
7952                        && !report.bootstrapping.is_empty()
7953                        && before
7954                            != self
7955                                .subs
7956                                .iter()
7957                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
7958                                .collect::<Vec<_>>()
7959                    {
7960                        spent = 0;
7961                    }
7962                    // TS parity: a disabled transport or a schema floor ends
7963                    // the loop. The latest round still merges first.
7964                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
7965                    aggregate.merge(&report);
7966                    if stop {
7967                        return SyncOutcome::Ok(aggregate);
7968                    }
7969                    // §4.5: pull again whenever the response contained
7970                    // commits or segments; resets re-bootstrap; a pending
7971                    // resume token continues paging (§4.7); deferred outbox
7972                    // commits and a raised sync-needed signal push on the next
7973                    // round.
7974                    let more = !report.bootstrapping.is_empty()
7975                        || report.commits_applied > 0
7976                        || report.segment_rows_applied > 0
7977                        || !report.resets.is_empty()
7978                        || report.deferred_commits > 0
7979                        || self.sync_needed;
7980                    if !more {
7981                        return SyncOutcome::Ok(aggregate);
7982                    }
7983                }
7984            }
7985        }
7986        // §7.7: partial success. The aggregate report is retained and the run
7987        // is explicitly not idle.
7988        SyncOutcome::BudgetExhausted(aggregate)
7989    }
7990
7991    fn process_response(
7992        &mut self,
7993        transport: &mut dyn Transport,
7994        response: Message,
7995        meta: &RequestMeta,
7996    ) -> SyncOutcome {
7997        #[cfg(feature = "bench-internals")]
7998        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
7999        let mut report = SyncReport {
8000            pushed: meta.pushed_ids.len() as u32,
8001            ..SyncReport::default()
8002        };
8003        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8004            HashMap::new();
8005        let pushed_ids = meta
8006            .pushed_ids
8007            .iter()
8008            .map(String::as_str)
8009            .collect::<HashSet<_>>();
8010        let mut incomplete_subscriptions: HashSet<&str> =
8011            meta.fresh.iter().map(|(id, _)| id.as_str()).collect();
8012        let mut last_final_push_result_id: Option<String> = None;
8013        for frame in &response.frames {
8014            match frame {
8015                Frame::PushResultDetails {
8016                    client_commit_id,
8017                    entries,
8018                } => {
8019                    let details = rejection_details_by_commit
8020                        .entry(client_commit_id.clone())
8021                        .or_default();
8022                    for entry in entries {
8023                        let parsed = match RejectionDetails::parse(&entry.details.0) {
8024                            Ok(value) => value,
8025                            Err(message) => {
8026                                return SyncOutcome::Failed {
8027                                    details: None,
8028                                    error_code: "sync.invalid_request".to_owned(),
8029                                    message,
8030                                    recommended_action: None,
8031                                };
8032                            }
8033                        };
8034                        details.insert(entry.op_index, parsed);
8035                    }
8036                }
8037                Frame::PushResult {
8038                    client_commit_id,
8039                    status,
8040                    results,
8041                    ..
8042                } if pushed_ids.contains(client_commit_id.as_str())
8043                    && Self::push_result_is_final(*status, results) =>
8044                {
8045                    last_final_push_result_id = Some(client_commit_id.clone());
8046                }
8047                _ => {}
8048            }
8049        }
8050        if response.wire_version < 2 {
8051            return SyncOutcome::Failed {
8052                details: None,
8053                error_code: "client.invalid_host_response".to_owned(),
8054                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8055                recommended_action: None,
8056            };
8057        }
8058        let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
8059            status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
8060        });
8061        let mut frames = response.frames.into_iter().peekable();
8062        match frames.next() {
8063            Some(Frame::RespHeader {
8064                required_schema_version,
8065                latest_schema_version,
8066                log_epoch,
8067                reset_required,
8068            }) => {
8069                if let Some(required) = required_schema_version {
8070                    // §1.6 schema-floor response: nothing else is processed;
8071                    // stop syncing and surface the upgrade requirement.
8072                    let floor = SchemaFloor {
8073                        required_schema_version: Some(required),
8074                        latest_schema_version,
8075                    };
8076                    if let Err(message) = self.set_schema_floor(Some(floor.clone())) {
8077                        return SyncOutcome::Failed {
8078                            details: None,
8079                            error_code: "storage.failed".to_owned(),
8080                            message,
8081                            recommended_action: None,
8082                        };
8083                    }
8084                    report.schema_floor = Some(floor);
8085                    return SyncOutcome::Ok(report);
8086                }
8087                if reset_required {
8088                    if frames.next().is_some() {
8089                        return SyncOutcome::Failed {
8090                            details: None,
8091                            error_code: "client.invalid_host_response".to_owned(),
8092                            message: "log-epoch reset response contains body frames".to_owned(),
8093                            recommended_action: None,
8094                        };
8095                    }
8096                    match self.run_log_epoch_reset(&log_epoch) {
8097                        Ok(resets) => {
8098                            report.resets = resets;
8099                            return SyncOutcome::Ok(report);
8100                        }
8101                        Err(message) => {
8102                            return SyncOutcome::Failed {
8103                                details: None,
8104                                error_code: "sync.local_corrupt".to_owned(),
8105                                message,
8106                                recommended_action: None,
8107                            };
8108                        }
8109                    }
8110                }
8111                let stored_epoch = match self.get_meta(LOG_EPOCH_KEY) {
8112                    Ok(stored) => stored,
8113                    Err(message) => {
8114                        return SyncOutcome::Failed {
8115                            details: None,
8116                            error_code: "storage.failed".to_owned(),
8117                            message,
8118                            recommended_action: None,
8119                        };
8120                    }
8121                };
8122                if stored_epoch.as_deref() != Some(log_epoch.as_str()) {
8123                    return SyncOutcome::Failed {
8124                        details: None,
8125                        error_code: "client.invalid_host_response".to_owned(),
8126                        message: "server changed logEpoch without requiring a reset".to_owned(),
8127                        recommended_action: None,
8128                    };
8129                }
8130            }
8131            _ => {
8132                return SyncOutcome::Failed {
8133                    details: None,
8134                    error_code: "sync.invalid_request".to_owned(),
8135                    message: "response does not start with RESP_HEADER".to_owned(),
8136                    recommended_action: None,
8137                };
8138            }
8139        }
8140
8141        if let Err(message) = self.begin_coalesced_apply() {
8142            return SyncOutcome::Failed {
8143                details: None,
8144                error_code: "storage.failed".into(),
8145                message,
8146                recommended_action: None,
8147            };
8148        }
8149        let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
8150        while let Some(frame) = frames.next() {
8151            match frame {
8152                frame @ Frame::PushResult { .. } => {
8153                    let successful = matches!(
8154                        &frame,
8155                        Frame::PushResult {
8156                            status: PushStatus::Applied | PushStatus::Cached,
8157                            ..
8158                        }
8159                    );
8160                    let mut results = vec![frame];
8161                    if successful {
8162                        while matches!(
8163                            frames.peek(),
8164                            Some(Frame::PushResult {
8165                                status: PushStatus::Applied | PushStatus::Cached,
8166                                ..
8167                            })
8168                        ) {
8169                            if let Some(next) = frames.next() {
8170                                results.push(next);
8171                            }
8172                        }
8173                    }
8174                    if self
8175                        .handle_push_results(
8176                            &results,
8177                            &pushed_ids,
8178                            &rejection_details_by_commit,
8179                            &mut report,
8180                            last_final_push_result_id.as_deref(),
8181                        )
8182                        .is_err()
8183                    {
8184                        failure = Some((
8185                            "client.outcome_persistence_failed".to_owned(),
8186                            "local commit outcome could not be persisted".to_owned(),
8187                            None,
8188                            None,
8189                        ));
8190                        break;
8191                    }
8192                }
8193                Frame::PushResultDetails { .. } => {}
8194                Frame::SubStart {
8195                    id,
8196                    status,
8197                    reason_code,
8198                    effective_scopes,
8199                    bootstrap: _,
8200                } => {
8201                    let mut body = Vec::new();
8202                    let mut sub_end: Option<(i64, Option<String>)> = None;
8203                    for inner in frames.by_ref() {
8204                        match inner {
8205                            Frame::SubEnd {
8206                                next_cursor,
8207                                bootstrap_state,
8208                            } => {
8209                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
8210                                break;
8211                            }
8212                            Frame::Unknown { .. } => {}
8213                            other => body.push(other),
8214                        }
8215                    }
8216                    if sub_end.is_some() {
8217                        incomplete_subscriptions.remove(id.as_str());
8218                    }
8219                    let section_failure = match self.process_section(
8220                        transport,
8221                        &id,
8222                        status,
8223                        &reason_code,
8224                        effective_scopes,
8225                        body,
8226                        sub_end,
8227                        meta,
8228                        &mut report,
8229                        &coalesced_deletes,
8230                    ) {
8231                        Err(SectionError::Abort(code, message)) => {
8232                            Some((code, message, None, None))
8233                        }
8234                        Err(SectionError::Transport(error)) => {
8235                            Some((error.code, error.message, None, error.details))
8236                        }
8237                        _ => None,
8238                    };
8239                    if section_failure.is_some() {
8240                        failure = section_failure;
8241                        break;
8242                    }
8243                }
8244                Frame::Lease {
8245                    lease_id,
8246                    expires_at_ms,
8247                } => {
8248                    // §7.3.5: persist the opaque lease; a fresh lease clears
8249                    // any prior lease error (the outage/revocation is over).
8250                    if let Err(message) = self.set_lease_state(Some(LeaseState {
8251                        lease_id: Some(lease_id),
8252                        expires_at_ms: Some(expires_at_ms),
8253                        error_code: None,
8254                    })) {
8255                        failure = Some(("storage.failed".to_owned(), message, None, None));
8256                        break;
8257                    }
8258                }
8259                Frame::Error {
8260                    code,
8261                    message,
8262                    recommended_action,
8263                    details,
8264                    ..
8265                } => {
8266                    // §1.6: the whole request failed; already-completed
8267                    // subscriptions keep their applied data and cursors.
8268                    let recommended_action =
8269                        (!recommended_action.is_empty()).then_some(recommended_action);
8270                    let details = details
8271                        .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
8272                        .filter(Value::is_object);
8273                    failure = Some((code, message, recommended_action, details));
8274                    break;
8275                }
8276                Frame::Unknown { .. } => {}
8277                _ => {
8278                    failure = Some((
8279                        "sync.invalid_request".to_owned(),
8280                        "unexpected frame in response".to_owned(),
8281                        None,
8282                        None,
8283                    ));
8284                    break;
8285                }
8286            }
8287        }
8288
8289        let applied = match failure {
8290            Some(failure) => Err(failure),
8291            // Local apply boundaries already reconciled their affected overlay.
8292            // Refuse round success if any remaining dirty work fails to
8293            // reconcile. §4.8 E1: the push half may have drained commits that
8294            // pinned rows of a shrunk window unit, so retry deferred evictions.
8295            None => self
8296                .rebuild_overlay_if_dirty()
8297                .and_then(|()| self.drain_pending_evictions())
8298                .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
8299        };
8300        // §1.4 rule 4: frames completed before a failure stay committed; a
8301        // failed COMMIT of the coalesced transaction fails the round.
8302        if let Err(message) = self.finish_coalesced_apply() {
8303            return SyncOutcome::Failed {
8304                details: None,
8305                error_code: "storage.failed".into(),
8306                message,
8307                recommended_action: None,
8308            };
8309        }
8310        if let Err((error_code, message, recommended_action, details)) = applied {
8311            return SyncOutcome::Failed {
8312                details,
8313                error_code,
8314                message,
8315                recommended_action,
8316            };
8317        }
8318        if !incomplete_subscriptions.is_empty() {
8319            self.set_sync_needed(true, true);
8320        }
8321        self.ack_after_pull(transport);
8322        // §7.4.5: the reset is over once the first post-reset pull round
8323        // leaves no subscription mid-bootstrap — the tables are rebuilt.
8324        if self.upgrading && report.bootstrapping.is_empty() {
8325            self.set_upgrading(false);
8326        }
8327        SyncOutcome::Ok(report)
8328    }
8329
8330    // -- push results (§6.3, §7.2) ------------------------------------------------
8331
8332    fn handle_push_results(
8333        &mut self,
8334        frames: &[Frame],
8335        pushed_ids: &HashSet<&str>,
8336        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
8337        report: &mut SyncReport,
8338        last_final_push_result_id: Option<&str>,
8339    ) -> Result<(), String> {
8340        self.begin_observation("syncular_push_results")?;
8341        let rollback = self.capture();
8342        let conflict_count = self.conflicts.len();
8343        let applied_count = report.applied.len();
8344        let rejected_count = report.rejected.len();
8345        let retryable_count = report.retryable.len();
8346        let mut drained = Vec::new();
8347        let mut batch = ChangeAccumulator::default();
8348        let persisted = (|| {
8349            for frame in frames {
8350                let Frame::PushResult {
8351                    client_commit_id,
8352                    status,
8353                    results,
8354                    commit_seq,
8355                } = frame
8356                else {
8357                    return Err("unexpected acknowledgement frame".to_owned());
8358                };
8359                if !pushed_ids.contains(client_commit_id.as_str()) {
8360                    continue;
8361                }
8362                let Some(index) = self
8363                    .outbox
8364                    .iter()
8365                    .position(|commit| commit.client_commit_id == *client_commit_id)
8366                else {
8367                    continue;
8368                };
8369                let status = *status;
8370                let rejection_details = rejection_details_by_commit.get(client_commit_id);
8371                let operations = self.outbox[index].ops.clone();
8372                match status {
8373                    PushStatus::Applied | PushStatus::Cached => {
8374                        // §7.2: a lost ack replays as `cached` — proceed as if the
8375                        // ack had arrived.
8376                        let sequence =
8377                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
8378                        let affected: HashSet<_> = operations
8379                            .iter()
8380                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
8381                            .collect();
8382                        let prior = self.acknowledged_operations(Some(&affected), None)?;
8383                        for (op_index, operation) in operations.iter().enumerate() {
8384                            let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
8385                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8386                            if delivered {
8387                                continue;
8388                            }
8389                            let table = self
8390                                .schema
8391                                .table(&operation.table)
8392                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
8393                            let mut intended: Map<String, Value> = self
8394                                .stored_row(table, &operation.row_id, true)?
8395                                .map(|(row, _)| {
8396                                    table
8397                                        .columns
8398                                        .iter()
8399                                        .zip(&row)
8400                                        .map(|(column, value)| {
8401                                            (column.name.clone(), column_value_to_json(value))
8402                                        })
8403                                        .collect()
8404                                })
8405                                .unwrap_or_default();
8406                            let mut scope_image = intended.clone();
8407                            for op in prior
8408                                .iter()
8409                                .chain(
8410                                    self.failed_commits
8411                                        .iter()
8412                                        .flat_map(|(commit, _)| &commit.ops),
8413                                )
8414                                .chain(
8415                                    self.outbox
8416                                        .iter()
8417                                        .take(index)
8418                                        .flat_map(|commit| &commit.ops),
8419                                )
8420                                .chain(operations.iter().take(op_index + 1))
8421                            {
8422                                if op.table != operation.table || op.row_id != operation.row_id {
8423                                    continue;
8424                                }
8425                                if let Some(values) = &op.values {
8426                                    intended.extend(values.clone());
8427                                } else {
8428                                    scope_image = intended.clone();
8429                                    intended.clear();
8430                                }
8431                            }
8432                            self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
8433                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
8434                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
8435                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
8436                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8437                        }
8438                        let journal_results = results
8439                            .iter()
8440                            .map(|result| {
8441                                let op_index = match result {
8442                                    OpResult::Applied { op_index }
8443                                    | OpResult::Conflict { op_index, .. }
8444                                    | OpResult::Error { op_index, .. } => *op_index,
8445                                };
8446                                CommitOperationOutcome::Applied { op_index }
8447                            })
8448                            .collect::<Vec<_>>();
8449                        let outcome_status = if status == PushStatus::Applied {
8450                            CommitOutcomeStatus::Applied
8451                        } else {
8452                            CommitOutcomeStatus::Cached
8453                        };
8454                        let persisted = self
8455                            .persist_commit_outcome(
8456                                client_commit_id,
8457                                outcome_status,
8458                                &journal_results,
8459                                None,
8460                            )
8461                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8462                        persisted?;
8463                        self.sync_needed = true;
8464                        batch.status = true;
8465                        batch.outcomes = true;
8466                    }
8467                    PushStatus::Rejected => {
8468                        if results.iter().any(|result| {
8469                            matches!(
8470                                result,
8471                                OpResult::Error {
8472                                    code,
8473                                    retryable: true,
8474                                    ..
8475                                } if code == "sync.idempotency_cache_miss"
8476                            )
8477                        }) {
8478                            // §6.3/§7.2: a serving failure, not an outcome — keep the
8479                            // exact commit queued for an identical retry.
8480                            report.retryable.push(client_commit_id.to_owned());
8481                            continue;
8482                        }
8483
8484                        let mut journal_results = Vec::with_capacity(results.len());
8485                        let mut conflicts = Vec::new();
8486                        let mut rejections = Vec::new();
8487                        for result in results {
8488                            match result {
8489                                OpResult::Applied { op_index } => {
8490                                    journal_results.push(CommitOperationOutcome::Applied {
8491                                        op_index: *op_index,
8492                                    });
8493                                }
8494                                OpResult::Conflict {
8495                                    op_index,
8496                                    code,
8497                                    message,
8498                                    server_version,
8499                                    server_row,
8500                                    conflict_columns: conflict_columns_bitmap,
8501                                } => {
8502                                    let operation = operations
8503                                        .get(*op_index as usize)
8504                                        .map(CommitOperation::from);
8505                                    let (table, row_id) = operation
8506                                        .as_ref()
8507                                        .map(|op| (op.table.clone(), op.row_id.clone()))
8508                                        .unwrap_or_default();
8509                                    let server_row_json = self
8510                                        .schema
8511                                        .table(&table)
8512                                        .and_then(|t| {
8513                                            decode_row_bytes(t, server_row, &self.encryption)
8514                                                .ok()
8515                                                .map(|row| (t, row))
8516                                        })
8517                                        .map(|(t, row)| {
8518                                            let mut map = Map::new();
8519                                            for (i, column) in t.columns.iter().enumerate() {
8520                                                map.insert(
8521                                                    column.name.clone(),
8522                                                    column_value_to_json(
8523                                                        row.get(i).unwrap_or(&None),
8524                                                    ),
8525                                                );
8526                                            }
8527                                            map
8528                                        })
8529                                        .unwrap_or_default();
8530                                    // §6.3: decode the presence-layout bitmap
8531                                    // into the marked column names.
8532                                    let conflict_columns = self
8533                                        .schema
8534                                        .table(&table)
8535                                        .map(|t| {
8536                                            t.columns
8537                                                .iter()
8538                                                .enumerate()
8539                                                .filter(|(index, _)| {
8540                                                    conflict_columns_bitmap
8541                                                        .get(index / 8)
8542                                                        .is_some_and(|byte| {
8543                                                            byte & (1 << (index % 8)) != 0
8544                                                        })
8545                                                })
8546                                                .map(|(_, column)| column.name.clone())
8547                                                .collect::<Vec<_>>()
8548                                        })
8549                                        .unwrap_or_default();
8550                                    let conflict = ConflictRecord {
8551                                        client_commit_id: client_commit_id.to_owned(),
8552                                        op_index: *op_index,
8553                                        table,
8554                                        row_id,
8555                                        code: code.clone(),
8556                                        message: message.clone(),
8557                                        server_version: *server_version,
8558                                        server_row: server_row_json,
8559                                        conflict_columns,
8560                                        operation,
8561                                    };
8562                                    journal_results.push(CommitOperationOutcome::Conflict {
8563                                        conflict: conflict.clone(),
8564                                    });
8565                                    conflicts.push(conflict);
8566                                }
8567                                OpResult::Error {
8568                                    op_index,
8569                                    code,
8570                                    message,
8571                                    retryable,
8572                                } => {
8573                                    let rejection = RejectionRecord {
8574                                        client_commit_id: client_commit_id.to_owned(),
8575                                        op_index: *op_index,
8576                                        code: code.clone(),
8577                                        message: message.clone(),
8578                                        retryable: *retryable,
8579                                        details: rejection_details
8580                                            .and_then(|details| details.get(op_index))
8581                                            .cloned(),
8582                                        operation: operations
8583                                            .get(*op_index as usize)
8584                                            .map(CommitOperation::from),
8585                                    };
8586                                    journal_results.push(CommitOperationOutcome::Error {
8587                                        rejection: rejection.clone(),
8588                                    });
8589                                    rejections.push(rejection);
8590                                }
8591                            }
8592                        }
8593                        if self.retain_failed_commits {
8594                            self.retain_failed_commit(client_commit_id, &operations)?;
8595                        }
8596                        let outcome_status = if conflicts.is_empty() {
8597                            CommitOutcomeStatus::Rejected
8598                        } else {
8599                            CommitOutcomeStatus::Conflict
8600                        };
8601                        let persisted = self
8602                            .persist_commit_outcome(
8603                                client_commit_id,
8604                                outcome_status,
8605                                &journal_results,
8606                                Some(&operations),
8607                            )
8608                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8609                        persisted?;
8610                        batch.conflicts = !conflicts.is_empty();
8611                        batch.rejections = !rejections.is_empty();
8612                        batch.status = true;
8613                        batch.outcomes = true;
8614                        self.conflicts.extend(conflicts);
8615                        self.rejections.extend(rejections);
8616                    }
8617                }
8618                drained.push(self.outbox.remove(index));
8619                if batch.status {
8620                    for operation in &operations {
8621                        if !self.record_row_scopes(
8622                            &mut batch,
8623                            &operation.table,
8624                            &operation.row_id,
8625                            false,
8626                        ) {
8627                            batch.table(&operation.table);
8628                        }
8629                    }
8630                }
8631                match status {
8632                    PushStatus::Applied | PushStatus::Cached => {
8633                        report.applied.push(client_commit_id.to_owned())
8634                    }
8635                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
8636                }
8637            }
8638            if batch.outcomes && frames.iter().any(|frame| matches!(frame, Frame::PushResult { client_commit_id, .. } if Some(client_commit_id.as_str()) == last_final_push_result_id)) {
8639                self.prune_commit_outcomes()?;
8640            }
8641            self.reconcile_rows(&drained)?;
8642            self.rebuild_overlay_if_dirty()?;
8643            self.finish_observation("syncular_push_results", batch)
8644        })();
8645        if let Err(error) = persisted {
8646            self.rollback_observation("syncular_push_results");
8647            self.restore(rollback);
8648            report.applied.truncate(applied_count);
8649            report.rejected.truncate(rejected_count);
8650            report.retryable.truncate(retryable_count);
8651            return Err(error);
8652        }
8653        if !rollback.sync_needed && self.sync_needed {
8654            self.sync_intent_queue.push_back(SyncIntent::Interactive);
8655        }
8656        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
8657        Ok(())
8658    }
8659
8660    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
8661        status != PushStatus::Rejected
8662            || !results.iter().any(|result| {
8663                matches!(
8664                    result,
8665                    OpResult::Error {
8666                        code,
8667                        retryable: true,
8668                        ..
8669                    } if code == "sync.idempotency_cache_miss"
8670                )
8671            })
8672    }
8673
8674    // -- subscription sections ------------------------------------------------------
8675
8676    #[allow(clippy::too_many_arguments)]
8677    fn process_section(
8678        &mut self,
8679        transport: &mut dyn Transport,
8680        id: &str,
8681        status: SubStatus,
8682        reason_code: &str,
8683        effective_scopes: Vec<(String, Vec<String>)>,
8684        body: Vec<Frame>,
8685        sub_end: Option<(i64, Option<String>)>,
8686        meta: &RequestMeta,
8687        report: &mut SyncReport,
8688        coalesced_deletes: &HashSet<(i64, String, String)>,
8689    ) -> Result<(), SectionError> {
8690        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
8691            return Ok(()); // unknown echo: ignore
8692        };
8693        if status != SubStatus::Active && sub_end.is_none() {
8694            return Err(SectionError::Abort(
8695                "sync.invalid_request".into(),
8696                "subscription section without SUB_END".into(),
8697            ));
8698        }
8699        match status {
8700            SubStatus::Revoked => {
8701                self.begin_observation("syncular_revocation")
8702                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8703                let rollback = self.capture();
8704                let revoked_count = report.revoked.len();
8705                let failed_count = report.failed.len();
8706                let result = (|| {
8707                    let mut batch = ChangeAccumulator::default();
8708                    let registered = self.window_unit_by_sub_id(id);
8709                    // §3.3: stop pulling, purge exactly the last effective grant.
8710                    let (table, effective) = {
8711                        let sub = &self.subs[sub_index];
8712                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8713                    };
8714                    let owners = self.scope_owners(&table, id);
8715                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
8716                        SectionError::Abort(
8717                            "sync.unknown_table".to_owned(),
8718                            "revoked scope references an unknown table".to_owned(),
8719                        )
8720                    })?;
8721                    let dropped = self
8722                        .drop_failed_commits(|name, values| {
8723                            name == table
8724                                && owns_local_row(&scoped_table, &effective, &owners, values)
8725                        })
8726                        .map_err(|message| {
8727                            SectionError::Abort("storage.failed".to_owned(), message)
8728                        })?;
8729                    for commit in dropped {
8730                        batch.conflicts = true;
8731                        batch.rejections = true;
8732                        batch.outcomes = true;
8733                        for operation in commit.ops {
8734                            batch.table(&operation.table);
8735                        }
8736                    }
8737                    let purged = self.purge_scope_rows(&table, &effective, &owners);
8738                    match purged {
8739                        Ok(()) => {
8740                            self.record_scope_map(&mut batch, &table, &effective);
8741                            let sub = &mut self.subs[sub_index];
8742                            sub.state = SubState::Revoked;
8743                            sub.reason_code = Some(if reason_code.is_empty() {
8744                                "sync.scope_revoked".to_owned()
8745                            } else {
8746                                reason_code.to_owned()
8747                            });
8748                            report.revoked.push(id.to_owned());
8749                            self.persist_sub(&self.subs[sub_index].clone())
8750                                .map_err(|message| {
8751                                    SectionError::Abort("storage.failed".into(), message)
8752                                })?;
8753                            let dropped = self
8754                                .drop_doomed_outbox(&scoped_table, &effective, &owners)
8755                                .map_err(|message| {
8756                                    SectionError::Abort("storage.failed".to_owned(), message)
8757                                })?;
8758                            if dropped {
8759                                batch.status = true;
8760                                batch.rejections = true;
8761                                batch.outcomes = true;
8762                            }
8763                            // §5.9.7 B2: revocation deletes now-unauthorized blob
8764                            // bodies (evicted ≠ revoked).
8765                            self.delete_unreferenced_cached_blobs().map_err(|message| {
8766                                SectionError::Abort("storage.failed".into(), message)
8767                            })?;
8768                        }
8769                        Err(()) => {
8770                            // §3.3 fail closed: no local mapping — never clear by
8771                            // approximation; fatal configuration error.
8772                            let sub = &mut self.subs[sub_index];
8773                            sub.state = SubState::Failed;
8774                            sub.reason_code = Some("sync.scope_revoked".to_owned());
8775                            report.failed.push(id.to_owned());
8776                            self.persist_sub(&self.subs[sub_index].clone())
8777                                .map_err(|message| {
8778                                    SectionError::Abort("storage.failed".into(), message)
8779                                })?;
8780                        }
8781                    }
8782                    if let Some((base_key, unit)) = registered {
8783                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
8784                    }
8785                    self.rebuild_overlay_if_dirty()
8786                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8787                    self.finish_observation("syncular_revocation", batch)
8788                        .map_err(|message| {
8789                            SectionError::Abort("storage.failed".to_owned(), message)
8790                        })?;
8791                    Ok(())
8792                })();
8793                if result.is_err() {
8794                    self.rollback_observation("syncular_revocation");
8795                    self.restore(rollback);
8796                    report.revoked.truncate(revoked_count);
8797                    report.failed.truncate(failed_count);
8798                }
8799                result
8800            }
8801            SubStatus::Reset => {
8802                self.begin_observation("syncular_reset")
8803                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8804                let rollback = self.capture();
8805                let reset_count = report.resets.len();
8806                let result = (|| {
8807                    let mut batch = ChangeAccumulator::default();
8808                    let registered = self.window_unit_by_sub_id(id);
8809                    // §4.6: discard cursor + bootstrap state, keep local rows —
8810                    // reset is a staleness signal, not a purge signal.
8811                    let sub = &mut self.subs[sub_index];
8812                    sub.cursor = -1;
8813                    sub.bootstrap_state = None;
8814                    sub.reason_code = Some(if reason_code.is_empty() {
8815                        "sync.cursor_expired".to_owned()
8816                    } else {
8817                        reason_code.to_owned()
8818                    });
8819                    report.resets.push(id.to_owned());
8820                    self.persist_sub(&self.subs[sub_index].clone())
8821                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8822                    if let Some((base_key, unit)) = registered {
8823                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
8824                    }
8825                    self.finish_observation("syncular_reset", batch)
8826                        .map_err(|message| {
8827                            SectionError::Abort("storage.failed".to_owned(), message)
8828                        })?;
8829                    Ok(())
8830                })();
8831                if result.is_err() {
8832                    self.rollback_observation("syncular_reset");
8833                    self.restore(rollback);
8834                    report.resets.truncate(reset_count);
8835                }
8836                result
8837            }
8838            SubStatus::Active => {
8839                let fresh = meta
8840                    .fresh
8841                    .iter()
8842                    .find(|(fid, _)| fid == id)
8843                    .map(|(_, fresh)| *fresh)
8844                    .unwrap_or(false);
8845                let previous = self.subs[sub_index].clone();
8846                self.subs[sub_index].effective = Some(effective_scopes);
8847                let outcome = self
8848                    .apply_section_body(
8849                        transport,
8850                        sub_index,
8851                        body,
8852                        fresh,
8853                        meta,
8854                        report,
8855                        coalesced_deletes,
8856                    )
8857                    .and_then(|()| {
8858                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
8859                            SectionError::Abort(
8860                                "sync.invalid_request".into(),
8861                                "subscription section without SUB_END".into(),
8862                            )
8863                        })?;
8864                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
8865                            .map_err(|message| {
8866                                SectionError::Abort("storage.failed".into(), message)
8867                            })
8868                    });
8869                match outcome {
8870                    Ok(()) => {
8871                        if self.subs[sub_index].bootstrap_state.is_some() {
8872                            report.bootstrapping.push(id.to_owned());
8873                        }
8874                        Ok(())
8875                    }
8876                    Err(SectionError::FailClosed) => {
8877                        self.subs[sub_index] = previous.clone();
8878                        self.begin_observation("syncular_section_failure")
8879                            .map_err(|message| {
8880                                SectionError::Abort("storage.failed".into(), message)
8881                            })?;
8882                        let persisted = (|| {
8883                            let mut batch = ChangeAccumulator::default();
8884                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
8885                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
8886                            }
8887                            self.subs[sub_index].state = SubState::Failed;
8888                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
8889                            self.persist_sub(&self.subs[sub_index])?;
8890                            self.finish_observation("syncular_section_failure", batch)
8891                        })();
8892                        if let Err(message) = persisted {
8893                            self.rollback_observation("syncular_section_failure");
8894                            self.subs[sub_index] = previous;
8895                            return Err(SectionError::Abort("storage.failed".into(), message));
8896                        }
8897                        report.failed.push(id.to_owned());
8898                        Ok(())
8899                    }
8900                    Err(error) => {
8901                        // Frame/block transactions already completed. Preserve their
8902                        // rows and revisions, but leave this subscription cursor unchanged.
8903                        self.subs[sub_index] = previous;
8904                        Err(error)
8905                    }
8906                }
8907            }
8908        }
8909    }
8910
8911    fn apply_sub_end(
8912        &mut self,
8913        sub_index: usize,
8914        cursor: i64,
8915        bootstrap_state: Option<String>,
8916    ) -> Result<(), String> {
8917        #[cfg(feature = "bench-internals")]
8918        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
8919        let previous = self.subs[sub_index].clone();
8920        self.begin_observation("syncular_sub_end")?;
8921        let persisted = (|| {
8922            let mut batch = ChangeAccumulator::default();
8923            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
8924                && cursor >= 0
8925                && bootstrap_state.is_none();
8926            if completed {
8927                let table = self
8928                    .schema
8929                    .table(&previous.table)
8930                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
8931                let effective = previous.effective.as_deref().unwrap_or(&[]);
8932                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
8933                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8934                let rows = statement
8935                    .query_map(rusqlite::params![previous.table, cursor], |row| {
8936                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8937                    })
8938                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8939                let mut covered = HashSet::new();
8940                for row in rows {
8941                    let (id, intent) =
8942                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8943                    let values: Map<String, Value> = serde_json::from_str(&intent)
8944                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8945                    if !effective.is_empty()
8946                        && effective.iter().all(|(variable, allowed)| {
8947                            table
8948                                .scope_column(variable)
8949                                .and_then(|column| values.get(column))
8950                                .is_some_and(|value| {
8951                                    allowed.contains(
8952                                        &value
8953                                            .as_str()
8954                                            .map_or_else(|| value.to_string(), str::to_owned),
8955                                    )
8956                                })
8957                        })
8958                    {
8959                        covered.insert(id);
8960                    }
8961                }
8962                drop(statement);
8963                for id in covered {
8964                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
8965                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8966                    self.overlay_dirty.table(&previous.table);
8967                    batch.table(&previous.table);
8968                }
8969                self.rebuild_overlay_if_dirty()?;
8970                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
8971                    batch.window(&base_key, &previous.table, &unit);
8972                }
8973            }
8974            let sub = &mut self.subs[sub_index];
8975            sub.cursor = cursor;
8976            sub.bootstrap_state = bootstrap_state;
8977            // §4.6: a completed (or continuing) active end clears the reset /
8978            // revocation reason, matching the TS core's active-state persist.
8979            sub.reason_code = None;
8980            sub.synced_once = true;
8981            self.persist_sub(&self.subs[sub_index])?;
8982            self.finish_observation("syncular_sub_end", batch)
8983        })();
8984        if persisted.is_err() {
8985            self.rollback_observation("syncular_sub_end");
8986            self.subs[sub_index] = previous;
8987        }
8988        persisted
8989    }
8990
8991    #[allow(clippy::too_many_arguments)]
8992    fn apply_section_body(
8993        &mut self,
8994        transport: &mut dyn Transport,
8995        sub_index: usize,
8996        body: Vec<Frame>,
8997        fresh: bool,
8998        meta: &RequestMeta,
8999        report: &mut SyncReport,
9000        coalesced_deletes: &HashSet<(i64, String, String)>,
9001    ) -> Result<(), SectionError> {
9002        let mut saw_segment = false;
9003        for frame in body {
9004            match frame {
9005                Frame::Commit {
9006                    tables,
9007                    changes,
9008                    commit_seq,
9009                    ..
9010                } => {
9011                    if self
9012                        .apply_commit_frame_coalesced(
9013                            &tables,
9014                            changes,
9015                            commit_seq,
9016                            coalesced_deletes,
9017                            sub_index,
9018                            AppliedServerCommitSource::Pull,
9019                        )
9020                        .map_err(|(c, m)| SectionError::Abort(c, m))?
9021                    {
9022                        report.commits_applied += 1;
9023                    }
9024                }
9025                Frame::SegmentInline { payload } => {
9026                    let segment = decode_rows_segment(&payload)
9027                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9028                    self.progress.update(|p| {
9029                        p.phase = ProgressPhase::Import;
9030                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9031                        p.table = Some(self.subs[sub_index].table.clone());
9032                        p.segment_id = None;
9033                        p.bytes_received = payload.len() as u64;
9034                        p.bytes_total = Some(payload.len() as u64);
9035                        p.rows_processed = 0;
9036                        p.rows_total =
9037                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9038                    });
9039                    let first = !saw_segment;
9040                    saw_segment = true;
9041                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9042                    report.segment_rows_applied += applied;
9043                }
9044                Frame::SegmentRef {
9045                    segment_id,
9046                    byte_length,
9047                    media_type,
9048                    table,
9049                    row_count,
9050                    as_of_commit_seq,
9051                    scope_digest,
9052                    row_cursor,
9053                    next_row_cursor,
9054                    url,
9055                    url_expires_at_ms,
9056                    ..
9057                } => {
9058                    // §4.2: reject a descriptor whose mediaType was not
9059                    // advertised — never skip or guess.
9060                    let advertised = match media_type {
9061                        MediaType::Rows => {
9062                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9063                                || meta.accept & ACCEPT_INLINE_ROWS != 0
9064                        }
9065                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9066                    };
9067                    if !advertised {
9068                        return Err(SectionError::Abort(
9069                            "sync.invalid_request".to_owned(),
9070                            format!(
9071                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9072                                media_type.name()
9073                            ),
9074                        ));
9075                    }
9076                    self.progress.update(|p| {
9077                        p.phase = ProgressPhase::Download;
9078                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9079                        p.table = Some(table.clone());
9080                        p.segment_id = Some(segment_id.clone());
9081                        p.bytes_received = 0;
9082                        p.bytes_total = u64::try_from(byte_length).ok();
9083                        p.rows_processed = 0;
9084                        p.rows_total = u64::try_from(row_count).ok();
9085                    });
9086                    let observer = self.progress.clone();
9087                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9088                    let bytes = if let Some(url) = url {
9089                        // §5.4: a url-carrying descriptor MUST be fetched
9090                        // from that URL; failure invalidates the whole
9091                        // descriptor (no fall-through to §5.5 — re-pull
9092                        // recovers, §1.4 rule 5).
9093                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9094                            return Err(SectionError::Abort(
9095                                "sync.invalid_request".to_owned(),
9096                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9097                                    .to_owned(),
9098                            ));
9099                        }
9100                        // §5.4: MUST NOT start a fetch at/past expiry.
9101                        if url_expires_at_ms.is_some_and(|exp| {
9102                            exp <= transport
9103                                .segment_fetch_started_at(&url)
9104                                .unwrap_or_else(|| self.clock_now_ms())
9105                        }) {
9106                            return Err(SectionError::Abort(
9107                                "sync.segment_expired".to_owned(),
9108                                format!(
9109                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9110                                ),
9111                            ));
9112                        }
9113                        transport
9114                            .fetch_url(
9115                                &url,
9116                                u64::try_from(byte_length).unwrap_or(0),
9117                                &mut on_progress,
9118                            )
9119                            .map_err(|e| self.segment_fetch_failed(e))?
9120                    } else {
9121                        let requested_scopes_json =
9122                            canonical_scope_json(&self.subs[sub_index].requested);
9123                        transport
9124                            .download_segment(
9125                                &SegmentRequest {
9126                                    segment_id: segment_id.clone(),
9127                                    table,
9128                                    requested_scopes_json,
9129                                    byte_length: u64::try_from(byte_length).unwrap_or(0),
9130                                },
9131                                &mut on_progress,
9132                            )
9133                            .map_err(|e| self.segment_fetch_failed(e))?
9134                    };
9135                    // §5.4: the descriptor's byteLength caps the body before
9136                    // §5.1 verifies the content address; the client discards
9137                    // the segment and re-pulls on either failure.
9138                    if bytes.len() as i64 > byte_length {
9139                        self.schedule_background_retry();
9140                        return Err(SectionError::Abort(
9141                            "sync.invalid_request".to_owned(),
9142                            "segment exceeds its byteLength".to_owned(),
9143                        ));
9144                    }
9145                    on_progress(bytes.len() as u64);
9146                    let digest = Sha256::digest(&bytes);
9147                    let expected = segment_id
9148                        .strip_prefix("sha256:")
9149                        .unwrap_or(segment_id.as_str());
9150                    if bytes_to_hex(&digest) != expected {
9151                        // §5.1: the client discards the segment and re-pulls.
9152                        self.schedule_background_retry();
9153                        return Err(SectionError::Abort(
9154                            "sync.invalid_request".to_owned(),
9155                            "segment bytes do not match the content address (§5.1)".to_owned(),
9156                        ));
9157                    }
9158                    self.progress.update(|p| p.phase = ProgressPhase::Import);
9159                    if media_type == MediaType::Sqlite {
9160                        // §5.3: images are whole-table — a paged sqlite
9161                        // descriptor is invalid.
9162                        if row_cursor.is_some() || next_row_cursor.is_some() {
9163                            return Err(SectionError::Abort(
9164                                "sync.invalid_request".to_owned(),
9165                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9166                                    .to_owned(),
9167                            ));
9168                        }
9169                        let first = !saw_segment;
9170                        saw_segment = true;
9171                        let applied = self.apply_sqlite_segment(
9172                            sub_index,
9173                            &bytes,
9174                            fresh && first,
9175                            row_count,
9176                            as_of_commit_seq,
9177                            &scope_digest,
9178                        )?;
9179                        report.segment_rows_applied += applied;
9180                    } else {
9181                        let segment = decode_rows_segment(&bytes).map_err(|e| {
9182                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9183                        })?;
9184                        let first = row_cursor.is_none();
9185                        saw_segment = true;
9186                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9187                        report.segment_rows_applied += applied;
9188                    }
9189                }
9190                Frame::Unknown { .. } => {}
9191                Frame::Error { code, message, .. } => {
9192                    return Err(SectionError::Abort(code, message))
9193                }
9194                _ => {
9195                    return Err(SectionError::Abort(
9196                        "sync.invalid_request".to_owned(),
9197                        "unexpected frame inside a subscription section".to_owned(),
9198                    ));
9199                }
9200            }
9201        }
9202        Ok(())
9203    }
9204
9205    /// Apply one commit frame after dropping a scope move's duplicate delete
9206    /// when the same commit carries the row's upsert in another applied
9207    /// section (§2.2). A frame that carried both itself is untouched.
9208    fn apply_commit_frame_coalesced(
9209        &mut self,
9210        tables: &[String],
9211        changes: Vec<ssp2::model::Change>,
9212        commit_seq: i64,
9213        coalesced_deletes: &HashSet<(i64, String, String)>,
9214        sub_index: usize,
9215        source: AppliedServerCommitSource,
9216    ) -> Result<bool, (String, String)> {
9217        let changes: Vec<ssp2::model::Change> = changes
9218            .into_iter()
9219            .filter(|change| {
9220                if change.op != Op::Delete {
9221                    return true;
9222                }
9223                let Some(table) = tables.get(change.table_index as usize) else {
9224                    return true;
9225                };
9226                !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
9227            })
9228            .collect();
9229        if changes.is_empty() {
9230            return Ok(false);
9231        }
9232        let affected_tables = changes
9233            .iter()
9234            .filter_map(|change| tables.get(change.table_index as usize))
9235            .cloned()
9236            .collect::<BTreeSet<_>>();
9237        let previous_batches = self.change_queue.len();
9238        self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
9239        if self.change_queue.len() > previous_batches {
9240            let subscription_id = self.subs[sub_index].id.clone();
9241            if let Some(batch) = self.change_queue.back_mut() {
9242                batch
9243                    .applied_commits
9244                    .extend(
9245                        affected_tables
9246                            .into_iter()
9247                            .map(|table| AppliedServerCommit {
9248                                subscription_id: subscription_id.clone(),
9249                                table,
9250                                commit_seq,
9251                                source,
9252                            }),
9253                    );
9254            }
9255        }
9256        Ok(true)
9257    }
9258
9259    // Both pull and realtime frames use the same durable observation boundary.
9260    fn apply_commit_frame(
9261        &mut self,
9262        tables: &[String],
9263        changes: &[ssp2::model::Change],
9264        commit_seq: Option<i64>,
9265    ) -> Result<(), (String, String)> {
9266        #[cfg(feature = "bench-internals")]
9267        let _phase = self.benchmark_phases.start(Phase::CommitApply);
9268        let rollback = self.capture();
9269        self.coalesce_unit(changes.len())
9270            .and_then(|()| self.begin_observation("syncular_commit"))
9271            .map_err(|message| ("storage.failed".into(), message))?;
9272        let applied = (|| {
9273            let mut batch = ChangeAccumulator::default();
9274            self.record_commit_changes(&mut batch, tables, changes);
9275            if let Some(sequence) = commit_seq {
9276                let storage_failed = |error| {
9277                    (
9278                        "storage.failed".to_owned(),
9279                        Self::sqlite_failure(&self.storage_failure, error),
9280                    )
9281                };
9282                let pinned: HashSet<(&str, &str)> = self
9283                    .outbox
9284                    .iter()
9285                    .flat_map(|commit| &commit.ops)
9286                    .map(|op| (op.table.as_str(), op.row_id.as_str()))
9287                    .collect();
9288                for change in changes {
9289                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
9290                        (
9291                            "sync.invalid_request".to_owned(),
9292                            "change tableIndex out of range".to_owned(),
9293                        )
9294                    })?;
9295                    if pinned.contains(&(table.as_str(), change.row_id.as_str())) {
9296                        self.conn.prepare_cached("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)")
9297                            .and_then(|mut statement| statement.execute(rusqlite::params![table, change.row_id, sequence]))
9298                            .map_err(storage_failed)?;
9299                    }
9300                    self.conn.prepare_cached("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3")
9301                        .and_then(|mut statement| statement.execute(rusqlite::params![table, change.row_id, sequence]))
9302                        .map_err(storage_failed)?;
9303                }
9304            }
9305            self.apply_commit_changes(tables, changes)?;
9306            self.rebuild_overlay_if_dirty()
9307                .map_err(|message| ("storage.failed".into(), message))?;
9308            self.finish_observation("syncular_commit", batch)
9309                .map_err(|message| ("storage.failed".into(), message))
9310        })();
9311        if applied.is_err() {
9312            self.rollback_observation("syncular_commit");
9313            self.restore(rollback);
9314        }
9315        applied
9316    }
9317
9318    fn apply_commit_changes(
9319        &mut self,
9320        tables: &[String],
9321        changes: &[ssp2::model::Change],
9322    ) -> Result<(), (String, String)> {
9323        // A clean overlay can reconcile changed primary keys independently
9324        // when their tables have no secondary unique constraints. Such a
9325        // constraint can change whether another row's write succeeds, so a
9326        // table with one and any local intent takes the table rebuild.
9327        let changed_tables: BTreeSet<&str> = changes
9328            .iter()
9329            .filter_map(|change| tables.get(change.table_index as usize))
9330            .map(String::as_str)
9331            .collect();
9332        let mut mirror_visible = !self.overlay_dirty.get();
9333        for name in &changed_tables {
9334            if mirror_visible
9335                && self
9336                    .schema
9337                    .table(name)
9338                    .is_some_and(|table| table.indexes.iter().any(|index| index.unique))
9339            {
9340                let (pending, protected) = self
9341                    .table_intent(name)
9342                    .map_err(|message| ("storage.failed".to_owned(), message))?;
9343                mirror_visible = !pending && !protected;
9344            }
9345        }
9346        for change in changes {
9347            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9348                (
9349                    "sync.invalid_request".to_owned(),
9350                    "change tableIndex out of range".to_owned(),
9351                )
9352            })?;
9353            let table = self.schema.table(table_name).ok_or_else(|| {
9354                (
9355                    "sync.schema_mismatch".to_owned(),
9356                    format!("change targets unknown table {table_name:?}"),
9357                )
9358            })?;
9359            match change.op {
9360                Op::Upsert => {
9361                    let payload = change.row.as_ref().ok_or_else(|| {
9362                        (
9363                            "sync.invalid_request".to_owned(),
9364                            "upsert change without row payload".to_owned(),
9365                        )
9366                    })?;
9367                    // §5.11: decrypt encrypted columns on apply.
9368                    let row = {
9369                        #[cfg(feature = "bench-internals")]
9370                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
9371                        decode_row_bytes(table, payload, &self.encryption).map_err(|m| {
9372                            // §5.11: a decrypt failure keeps its own identity.
9373                            match m.strip_prefix("client.decrypt_failed: ") {
9374                                Some(detail) => {
9375                                    ("client.decrypt_failed".to_owned(), detail.to_owned())
9376                                }
9377                                None => ("sync.invalid_request".to_owned(), m),
9378                            }
9379                        })?
9380                    };
9381                    let version = change.row_version.unwrap_or(0);
9382                    let table_name = table.name.clone();
9383                    self.write_base_row(&table_name, &row, version)
9384                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9385                    if mirror_visible {
9386                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
9387                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9388                    }
9389                }
9390                Op::Delete => {
9391                    self.delete_base_row(table_name, &change.row_id)
9392                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9393                    if mirror_visible {
9394                        let sql = format!(
9395                            "DELETE FROM {} WHERE {}",
9396                            visible_table(table_name),
9397                            row_id_predicate(table)
9398                        );
9399                        self.conn
9400                            .prepare_cached(&sql)
9401                            .and_then(|mut statement| {
9402                                statement.execute(rusqlite::params![change.row_id])
9403                            })
9404                            .map_err(|error| {
9405                                ("sync.invalid_request".to_owned(), error.to_string())
9406                            })?;
9407                    }
9408                }
9409            }
9410        }
9411        if mirror_visible {
9412            let changed_rows: HashSet<(&str, &str)> = changes
9413                .iter()
9414                .map(|change| {
9415                    (
9416                        tables[change.table_index as usize].as_str(),
9417                        change.row_id.as_str(),
9418                    )
9419                })
9420                .collect();
9421            self.replay_overlay(&changed_tables, Some(&changed_rows))
9422                .map_err(|message| ("storage.failed".to_owned(), message))?;
9423            self.overlay_dirty.set(false);
9424        }
9425        Ok(())
9426    }
9427
9428    /// §5.6 segment application: validate against the generated schema,
9429    /// clear the grant on a fresh bootstrap's first page (fail closed
9430    /// without a mapping), then replace-or-upsert each row with its
9431    /// segment-carried server version (§5.2).
9432    fn apply_segment(
9433        &mut self,
9434        sub_index: usize,
9435        segment: &RowsSegment,
9436        first_fresh_page: bool,
9437    ) -> Result<u32, SectionError> {
9438        let (sub_table, effective, owners) = {
9439            let sub = &self.subs[sub_index];
9440            (
9441                sub.table.clone(),
9442                sub.effective.clone().unwrap_or_default(),
9443                self.scope_owners(&sub.table, &sub.id),
9444            )
9445        };
9446        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9447            SectionError::Abort(
9448                "sync.schema_mismatch".to_owned(),
9449                format!("subscription table {sub_table:?} is not in the client schema"),
9450            )
9451        })?;
9452        // §5.2: the column table validates against the generated schema —
9453        // order, names, types, nullability; mismatch is fatal. §5.11: the
9454        // server sends the WIRE types (bytes for an encrypted column), so
9455        // validate against wire_columns.
9456        let matches = segment.table == table.name
9457            && segment.schema_version == self.schema.version
9458            && segment.columns.len() == table.wire_columns.len()
9459            && segment
9460                .columns
9461                .iter()
9462                .zip(table.wire_columns.iter())
9463                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9464        if !matches {
9465            return Err(SectionError::Abort(
9466                "sync.schema_mismatch".to_owned(),
9467                "segment column table does not match the generated schema (§5.2)".to_owned(),
9468            ));
9469        }
9470        let mut applied = 0u32;
9471        let empty = Vec::new();
9472        let blocks = if segment.blocks.is_empty() {
9473            std::slice::from_ref(&empty)
9474        } else {
9475            &segment.blocks
9476        };
9477        for (index, block) in blocks.iter().enumerate() {
9478            let clear = first_fresh_page && index == 0;
9479            if !clear && block.is_empty() {
9480                continue;
9481            }
9482            let rollback = self.capture();
9483            let (pending, protected) = self
9484                .table_intent(&table.name)
9485                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9486            let reconcile_pending =
9487                !protected && pending && table.indexes.iter().any(|index| index.unique);
9488            let mirror_visible = !protected && !reconcile_pending;
9489            self.coalesce_unit(block.len())
9490                .and_then(|()| self.begin_observation("syncular_segment_block"))
9491                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9492            let outcome = (|| {
9493                self.rebuild_overlay_if_dirty()
9494                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9495                let mut changed_keys = Vec::new();
9496                let mut batch = ChangeAccumulator::default();
9497                if !block.is_empty()
9498                    || (clear && self.scoped_rows_exist(&table.name, &effective, &owners))
9499                {
9500                    batch.table(&table.name);
9501                }
9502                if clear {
9503                    self.purge_scope_rows(&table.name, &effective, &owners)
9504                        .map_err(|()| SectionError::FailClosed)?;
9505                }
9506                for (row_index, row) in block.iter().enumerate() {
9507                    let decrypted;
9508                    let values = if table.has_encrypted_columns() {
9509                        let mut values = row.values.clone();
9510                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9511                            .map_err(|message| {
9512                            SectionError::Abort("client.decrypt_failed".into(), message)
9513                        })?;
9514                        decrypted = values;
9515                        &decrypted
9516                    } else {
9517                        &row.values
9518                    };
9519                    self.write_base_row(&table.name, values, row.server_version)
9520                        .map_err(|message| {
9521                            SectionError::Abort("sync.invalid_request".into(), message)
9522                        })?;
9523                    if reconcile_pending {
9524                        changed_keys.push(
9525                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
9526                                |message| SectionError::Abort("storage.failed".into(), message),
9527                            )?,
9528                        );
9529                    }
9530                    if mirror_visible {
9531                        self.write_row(
9532                            &visible_table(&table.name),
9533                            &table.name,
9534                            values,
9535                            row.server_version,
9536                        )
9537                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9538                    }
9539                    let processed = u64::from(applied) + row_index as u64 + 1;
9540                    if processed.is_multiple_of(1024) {
9541                        self.progress.update(|p| p.rows_processed = processed);
9542                    }
9543                }
9544                self.reconcile_import(&table, &mut changed_keys, protected, reconcile_pending)
9545                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9546                self.finish_observation("syncular_segment_block", batch)
9547                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
9548            })();
9549            if let Err(error) = outcome {
9550                self.rollback_observation("syncular_segment_block");
9551                self.restore(rollback);
9552                return Err(error);
9553            }
9554            applied += block.len() as u32;
9555            self.progress
9556                .update(|p| p.rows_processed = u64::from(applied));
9557            std::thread::yield_now();
9558        }
9559        self.progress
9560            .update(|p| p.rows_processed = u64::from(applied));
9561        Ok(applied)
9562    }
9563
9564    /// §5.3 sqlite-image application: validate the in-file metadata
9565    /// against the descriptor, validate column names/order against the
9566    /// generated schema, run the §5.6 first-page clear when fresh, then
9567    /// replace-or-upsert every image row with its `_syncular_version`.
9568    /// Mechanics: the image lands in a temp file read through a second
9569    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
9570    /// Each image chunk commits its writes and revision before readers continue.
9571    fn apply_sqlite_segment(
9572        &mut self,
9573        sub_index: usize,
9574        bytes: &[u8],
9575        first_fresh_page: bool,
9576        row_count: i64,
9577        as_of_commit_seq: i64,
9578        scope_digest: &str,
9579    ) -> Result<u32, SectionError> {
9580        let invalid = |detail: &str| {
9581            SectionError::Abort(
9582                "sync.invalid_request".to_owned(),
9583                format!("sqlite segment rejected: {detail} (§5.3)"),
9584            )
9585        };
9586        let (sub_table, effective, owners) = {
9587            let sub = &self.subs[sub_index];
9588            (
9589                sub.table.clone(),
9590                sub.effective.clone().unwrap_or_default(),
9591                self.scope_owners(&sub.table, &sub.id),
9592            )
9593        };
9594        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9595            SectionError::Abort(
9596                "sync.schema_mismatch".to_owned(),
9597                format!("subscription table {sub_table:?} is not in the client schema"),
9598            )
9599        })?;
9600        // §5.11: an image bypasses the decrypt seam, so a server cannot serve
9601        // one for a table with encrypted columns.
9602        if table.has_encrypted_columns() {
9603            return Err(invalid("table has encrypted columns"));
9604        }
9605
9606        // The image lands beside the replica so it shares the replica's
9607        // directory permissions and volume; an in-memory replica has no
9608        // directory and uses the system temp dir. The file is owner-only.
9609        let directory = self
9610            .previous_version_replica_path()
9611            .and_then(|replica| std::path::Path::new(&replica).parent().map(Into::into))
9612            .unwrap_or_else(std::env::temp_dir);
9613        let path = directory.join(format!(".syncular-image-{}.tmp", uuid::Uuid::new_v4()));
9614        let outcome = (|| {
9615            let mut options = std::fs::OpenOptions::new();
9616            options.write(true).create_new(true);
9617            #[cfg(unix)]
9618            std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
9619            std::io::Write::write_all(
9620                &mut options
9621                    .open(&path)
9622                    .map_err(|_| invalid("image temp file write failed"))?,
9623                bytes,
9624            )
9625            .map_err(|_| invalid("image temp file write failed"))?;
9626            let img = rusqlite::Connection::open_with_flags(
9627                &path,
9628                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
9629            )
9630            .map_err(|_| invalid("bytes do not open as a SQLite database"))?;
9631            self.apply_sqlite_image(
9632                &img,
9633                &table,
9634                first_fresh_page,
9635                &effective,
9636                &owners,
9637                row_count,
9638                as_of_commit_seq,
9639                scope_digest,
9640            )
9641        })();
9642        let _ = std::fs::remove_file(&path);
9643        outcome
9644    }
9645
9646    #[allow(clippy::too_many_arguments)]
9647    fn apply_sqlite_image(
9648        &mut self,
9649        img: &rusqlite::Connection,
9650        table: &crate::schema::TableSchema,
9651        first_fresh_page: bool,
9652        effective: &[(String, Vec<String>)],
9653        owners: &[Vec<(String, Vec<String>)>],
9654        row_count: i64,
9655        as_of_commit_seq: i64,
9656        scope_digest: &str,
9657    ) -> Result<u32, SectionError> {
9658        let invalid = |detail: String| {
9659            SectionError::Abort(
9660                "sync.invalid_request".to_owned(),
9661                format!("sqlite segment rejected: {detail} (§5.3)"),
9662            )
9663        };
9664
9665        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
9666        //    one row).
9667        type MetaRow = (i64, String, i64, i64, String, i64, i64);
9668        let meta: MetaRow = img
9669            .query_row(
9670                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
9671                        \"scopeDigest\", \"rowCount\",
9672                        (SELECT count(*) FROM _syncular_segment)
9673                 FROM _syncular_segment",
9674                [],
9675                |row| {
9676                    Ok((
9677                        row.get(0)?,
9678                        row.get(1)?,
9679                        row.get(2)?,
9680                        row.get(3)?,
9681                        row.get(4)?,
9682                        row.get(5)?,
9683                        row.get(6)?,
9684                    ))
9685                },
9686            )
9687            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
9688        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
9689        if meta_count != 1 {
9690            return Err(invalid(format!(
9691                "_syncular_segment must contain exactly one row, found {meta_count}"
9692            )));
9693        }
9694        if format != 1 {
9695            return Err(invalid(format!("format {format}")));
9696        }
9697        if meta_table != table.name {
9698            return Err(invalid(format!("image table {meta_table:?}")));
9699        }
9700        if schema_version != i64::from(self.schema.version) {
9701            return Err(invalid(format!("schemaVersion {schema_version}")));
9702        }
9703        if pin != as_of_commit_seq {
9704            return Err(invalid(format!("asOfCommitSeq {pin}")));
9705        }
9706        if digest != scope_digest {
9707            return Err(invalid("scopeDigest mismatch".to_owned()));
9708        }
9709        if meta_rows != row_count {
9710            return Err(invalid(format!("rowCount {meta_rows}")));
9711        }
9712
9713        // 2. Column names and order vs the generated schema (§5.3 rule 3).
9714        let mut names: Vec<String> = Vec::new();
9715        let mut primary_keys: Vec<String> = Vec::new();
9716        {
9717            let mut stmt = img
9718                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
9719                .map_err(|_| invalid("image data table missing".to_owned()))?;
9720            let mut rows = stmt
9721                .query([])
9722                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
9723            while let Some(row) = rows
9724                .next()
9725                .map_err(|_| invalid("image data table unreadable".to_owned()))?
9726            {
9727                if row
9728                    .get::<_, i64>(5)
9729                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
9730                    > 0
9731                {
9732                    primary_keys.push(row.get(1).map_err(|error| {
9733                        invalid(Self::sqlite_failure(&self.storage_failure, error))
9734                    })?);
9735                }
9736                names.push(
9737                    row.get::<_, String>(1)
9738                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
9739                );
9740            }
9741        }
9742        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
9743        expected.push("_syncular_version");
9744        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
9745            return Err(SectionError::Abort(
9746                "sync.schema_mismatch".to_owned(),
9747                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
9748            ));
9749        }
9750
9751        if primary_keys != vec![table.primary_key.clone()] {
9752            return Err(invalid(
9753                "image primary key does not match generated schema".into(),
9754            ));
9755        }
9756        let actual_count: i64 = img
9757            .query_row(
9758                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
9759                [],
9760                |row| row.get(0),
9761            )
9762            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9763        if actual_count != row_count {
9764            return Err(invalid("image row count does not match descriptor".into()));
9765        }
9766        let insert = self.insert_row_sql(&base_table(&table.name), table);
9767        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
9768        let column_list = names
9769            .iter()
9770            .map(|n| quote_ident(n))
9771            .collect::<Vec<_>>()
9772            .join(", ");
9773        let mut stmt = img
9774            .prepare(&format!(
9775                "SELECT {column_list} FROM {} ORDER BY {}",
9776                quote_ident(&table.name),
9777                quote_ident(&table.primary_key)
9778            ))
9779            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9780        let mut rows = stmt
9781            .query([])
9782            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9783        let mut applied = 0u32;
9784        loop {
9785            let clear = applied == 0 && first_fresh_page;
9786            let rollback = self.capture();
9787            let (pending, protected) = self
9788                .table_intent(&table.name)
9789                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9790            let reconcile_pending =
9791                !protected && pending && table.indexes.iter().any(|index| index.unique);
9792            let mirror_visible = !protected && !reconcile_pending;
9793            // A chunk holds up to 1,024 rows (§5.3).
9794            self.coalesce_unit(1024)
9795                .and_then(|()| self.begin_observation("syncular_image_chunk"))
9796                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9797            let result = (|| {
9798                self.rebuild_overlay_if_dirty()
9799                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9800                let mut changed_keys = Vec::new();
9801                let mut batch = ChangeAccumulator::default();
9802                if clear {
9803                    if self.scoped_rows_exist(&table.name, effective, owners) {
9804                        batch.table(&table.name);
9805                    }
9806                    self.purge_scope_rows(&table.name, effective, owners)
9807                        .map_err(|()| SectionError::FailClosed)?;
9808                }
9809                let mut processed = 0u32;
9810                {
9811                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
9812                        invalid(Self::sqlite_failure(&self.storage_failure, error))
9813                    })?;
9814                    let mut mirror = if mirror_visible {
9815                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
9816                            invalid(Self::sqlite_failure(&self.storage_failure, error))
9817                        })?)
9818                    } else {
9819                        None
9820                    };
9821                    for _ in 0..1024 {
9822                        let Some(row) = rows.next().map_err(|error| {
9823                            invalid(Self::sqlite_failure(&self.storage_failure, error))
9824                        })?
9825                        else {
9826                            break;
9827                        };
9828                        for (i, column) in table.columns.iter().enumerate() {
9829                            let cell = row.get_ref(i).map_err(|error| {
9830                                invalid(Self::sqlite_failure(&self.storage_failure, error))
9831                            })?;
9832                            let param = image_cell_param(column, cell).map_err(invalid)?;
9833                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
9834                                invalid(Self::sqlite_failure(&self.storage_failure, error))
9835                            })?;
9836                            if let Some(mirror) = &mut mirror {
9837                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
9838                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
9839                                })?;
9840                            }
9841                        }
9842                        let version_index = table.columns.len();
9843                        let version: i64 = row.get(version_index).map_err(|error| {
9844                            invalid(Self::sqlite_failure(&self.storage_failure, error))
9845                        })?;
9846                        if version < 1 {
9847                            return Err(invalid("image row version must be positive".into()));
9848                        }
9849                        ins.raw_bind_parameter(version_index + 1, version)
9850                            .map_err(|error| {
9851                                invalid(Self::sqlite_failure(&self.storage_failure, error))
9852                            })?;
9853                        ins.raw_execute().map_err(|error| {
9854                            invalid(Self::sqlite_failure(&self.storage_failure, error))
9855                        })?;
9856                        if let Some(mirror) = &mut mirror {
9857                            mirror
9858                                .raw_bind_parameter(version_index + 1, version)
9859                                .map_err(|error| {
9860                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
9861                                })?;
9862                            mirror.raw_execute().map_err(|error| {
9863                                invalid(Self::sqlite_failure(&self.storage_failure, error))
9864                            })?;
9865                        }
9866                        if reconcile_pending {
9867                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
9868                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
9869                            )?);
9870                        }
9871                        processed += 1;
9872                    }
9873                }
9874                if processed > 0 {
9875                    batch.table(&table.name);
9876                    self.overlay_dirty.table(&table.name);
9877                }
9878                self.reconcile_import(table, &mut changed_keys, protected, reconcile_pending)
9879                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9880                self.finish_observation("syncular_image_chunk", batch)
9881                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9882                Ok(processed)
9883            })();
9884            let processed = match result {
9885                Ok(processed) => processed,
9886                Err(error) => {
9887                    self.rollback_observation("syncular_image_chunk");
9888                    self.restore(rollback);
9889                    return Err(error);
9890                }
9891            };
9892            applied += processed;
9893            self.progress
9894                .update(|p| p.rows_processed = u64::from(applied));
9895            std::thread::yield_now();
9896            if processed < 1024 || i64::from(applied) == actual_count {
9897                break;
9898            }
9899        }
9900        if i64::from(applied) != actual_count {
9901            return Err(invalid(format!(
9902                "image yielded {applied} rows, expected {actual_count}"
9903            )));
9904        }
9905        Ok(applied)
9906    }
9907
9908    /// §7.1 keyed reconciliation of the rows `drained` commits touch: restore
9909    /// them from the base and replay their remaining intent. A table with a
9910    /// secondary unique index restores and replays every row with intent,
9911    /// since one row's value can admit or defer another row's write.
9912    fn reconcile_rows(&self, drained: &[Arc<OutboxCommit>]) -> Result<(), String> {
9913        let names: BTreeSet<&str> = drained
9914            .iter()
9915            .flat_map(|commit| &commit.ops)
9916            .map(|op| op.table.as_str())
9917            .collect();
9918        for name in names {
9919            let Some(table) = self.schema.table(name) else {
9920                continue;
9921            };
9922            let ops = drained.iter().flat_map(|commit| &commit.ops);
9923            let tables = BTreeSet::from([name]);
9924            let rows: HashSet<_> = ops
9925                .clone()
9926                .map(|op| (op.table.as_str(), op.row_id.as_str()))
9927                .collect();
9928            let unique = table.indexes.iter().any(|index| index.unique);
9929            let acknowledged = if unique {
9930                self.acknowledged_operations(None, Some(&tables))?
9931            } else {
9932                Vec::new()
9933            };
9934            let peers = acknowledged
9935                .iter()
9936                .chain(
9937                    self.failed_commits
9938                        .iter()
9939                        .flat_map(|(commit, _)| &commit.ops),
9940                )
9941                .chain(self.outbox.iter().flat_map(|commit| &commit.ops))
9942                .filter(|_| unique);
9943            self.restore_base_rows(table, &mut Vec::new(), peers.chain(ops.clone()))?;
9944            self.replay_overlay(&tables, (!unique).then_some(&rows))?;
9945        }
9946        Ok(())
9947    }
9948
9949    /// Whether `table` has pending outbox intent, and whether it has protected
9950    /// (acknowledged or retained failed) intent.
9951    fn table_intent(&self, table: &str) -> Result<(bool, bool), String> {
9952        let touches = |op: &OutboxOp| op.table == table;
9953        Ok((
9954            self.outbox
9955                .iter()
9956                .flat_map(|commit| &commit.ops)
9957                .any(touches),
9958            self.failed_commits
9959                .iter()
9960                .flat_map(|(commit, _)| &commit.ops)
9961                .any(touches)
9962                || self
9963                    .conn
9964                    .query_row(
9965                        "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows WHERE tbl=?1)",
9966                        [table],
9967                        |row| row.get(0),
9968                    )
9969                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
9970        ))
9971    }
9972
9973    /// §7.1 reconciliation after an import block or chunk. Keyed replay covers
9974    /// only pending intent, so a table with protected intent rebuilds; a
9975    /// `restore` restores the imported `keys` and every pending row first.
9976    fn reconcile_import(
9977        &mut self,
9978        table: &TableSchema,
9979        keys: &mut Vec<SqlValue>,
9980        protected: bool,
9981        restore: bool,
9982    ) -> Result<(), String> {
9983        if protected {
9984            return self.rebuild_overlay_if_dirty();
9985        }
9986        let pending = self.outbox.iter().flat_map(|commit| &commit.ops);
9987        if restore {
9988            self.restore_base_rows(table, keys, pending.clone())?;
9989        }
9990        self.apply_outbox_ops(pending.filter(|op| op.table == table.name))?;
9991        self.overlay_dirty.set(false);
9992        Ok(())
9993    }
9994
9995    /// Restore the visible rows of `keys` and of the rows `ops` touch in
9996    /// `table` from the base before the caller replays their intent. The
9997    /// caller owns the transaction and publishes its revision after replay.
9998    fn restore_base_rows<'a>(
9999        &self,
10000        table: &TableSchema,
10001        keys: &mut Vec<SqlValue>,
10002        ops: impl IntoIterator<Item = &'a OutboxOp>,
10003    ) -> Result<(), String> {
10004        let visible = visible_table(&table.name);
10005        let base = base_table(&table.name);
10006        let pk = quote_ident(&table.primary_key);
10007        let predicate = row_id_predicate(table);
10008        let mut lookup = self.conn.prepare_cached(&format!(
10009            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10010        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10011        let mut deleted = HashSet::new();
10012        for op in ops.into_iter().filter(|op| op.table == table.name) {
10013            if op.upsert {
10014                if let Some(values) = &op.values {
10015                    let key = json_to_column_value(
10016                        &table.columns[table.pk_index],
10017                        values.get(&table.primary_key),
10018                    )?;
10019                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
10020                }
10021            } else if deleted.insert(&op.row_id) {
10022                keys.extend(
10023                    lookup
10024                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10025                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10026                        .collect::<Result<Vec<_>, _>>()
10027                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
10028                );
10029            }
10030        }
10031        // Delete the complete affected set before restoring any base value, so
10032        // old optimistic unique values cannot collide with the new server rows.
10033        for chunk in keys.chunks(400) {
10034            let holes = vec!["?"; chunk.len()].join(",");
10035            self.conn
10036                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10037                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10038                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10039        }
10040        let updates = table
10041            .columns
10042            .iter()
10043            .map(|column| column.name.as_str())
10044            .chain(std::iter::once("_syncular_version"))
10045            .filter(|name| *name != table.primary_key)
10046            .map(|name| {
10047                let name = quote_ident(name);
10048                format!("{name}=excluded.{name}")
10049            })
10050            .collect::<Vec<_>>()
10051            .join(",");
10052        for chunk in keys.chunks(400) {
10053            let holes = vec!["?"; chunk.len()].join(",");
10054            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10055                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10056                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10057        }
10058        Ok(())
10059    }
10060
10061    // -- application-authorized local purge ---------------------------------------
10062
10063    fn compile_local_data_purge(
10064        &self,
10065        input: &LocalDataPurgeInput,
10066    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10067        let invalid = |message: String| format!("sync.invalid_request: {message}");
10068        if input.purge_id.is_empty()
10069            || input.purge_id.len() > 128
10070            || !is_local_operation_code_like(&input.purge_id)
10071        {
10072            return Err(invalid(
10073                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10074            ));
10075        }
10076        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10077            return Err(invalid(format!(
10078                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10079            )));
10080        }
10081
10082        let mut deduplicated: BTreeMap<
10083            String,
10084            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10085        > = BTreeMap::new();
10086        for target in &input.targets {
10087            let table = self.schema.table(&target.table).ok_or_else(|| {
10088                invalid(format!(
10089                    "local purge names unknown table {:?}",
10090                    target.table
10091                ))
10092            })?;
10093            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10094                return Err(invalid(format!(
10095                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10096                    target.table
10097                )));
10098            }
10099            let mut selectors = Vec::with_capacity(target.selectors.len());
10100            let mut canonical_selectors = BTreeMap::new();
10101            for (column_name, raw_values) in &target.selectors {
10102                let Some((column_index, column)) = table
10103                    .columns
10104                    .iter()
10105                    .enumerate()
10106                    .find(|(_, column)| column.name == *column_name)
10107                else {
10108                    return Err(invalid(format!(
10109                        "local purge target {:?} names unknown column {:?}",
10110                        target.table, column_name
10111                    )));
10112                };
10113                let encrypted = table
10114                    .encrypted_columns
10115                    .iter()
10116                    .any(|candidate| candidate.index == column_index);
10117                if column.ty != ColumnType::String || encrypted {
10118                    return Err(invalid(format!(
10119                        "local purge selector {:?}.{:?} must be a plaintext string column",
10120                        target.table, column_name
10121                    )));
10122                }
10123                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10124                    return Err(invalid(format!(
10125                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10126                        target.table, column_name
10127                    )));
10128                }
10129                let mut values = raw_values.clone();
10130                values.sort();
10131                values.dedup();
10132                if values.iter().any(|value| {
10133                    value.is_empty()
10134                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10135                        || !is_local_operation_code_like(value)
10136                }) {
10137                    return Err(invalid(format!(
10138                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10139                    )));
10140                }
10141                selectors.push((column_name.clone(), values.clone()));
10142                canonical_selectors.insert(column_name.clone(), values);
10143            }
10144            let canonical = LocalDataPurgeTarget {
10145                table: target.table.clone(),
10146                selectors: canonical_selectors,
10147            };
10148            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10149            deduplicated.insert(
10150                key,
10151                (
10152                    CompiledLocalDataPurgeTarget {
10153                        table: target.table.clone(),
10154                        selectors,
10155                    },
10156                    canonical,
10157                ),
10158            );
10159        }
10160        let targets = deduplicated
10161            .values()
10162            .map(|(compiled, _)| compiled.clone())
10163            .collect::<Vec<_>>();
10164        let canonical = deduplicated
10165            .values()
10166            .map(|(_, target)| target.clone())
10167            .collect::<Vec<_>>();
10168        let canonical_plan =
10169            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10170        Ok((targets, canonical_plan))
10171    }
10172
10173    fn local_purge_base_row_ids(
10174        &self,
10175        targets: &[CompiledLocalDataPurgeTarget],
10176    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10177        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10178        for target in targets {
10179            let table = self
10180                .schema
10181                .table(&target.table)
10182                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10183            let mut clauses = Vec::with_capacity(target.selectors.len());
10184            let mut params = Vec::new();
10185            for (column, values) in &target.selectors {
10186                clauses.push(format!(
10187                    "{} IN ({})",
10188                    quote_ident(column),
10189                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10190                ));
10191                params.extend(values.iter().cloned().map(SqlValue::Text));
10192            }
10193            let sql = format!(
10194                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10195                quote_ident(&table.primary_key),
10196                base_table(&target.table),
10197                clauses.join(" AND ")
10198            );
10199            let mut statement = self
10200                .conn
10201                .prepare(&sql)
10202                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10203            let rows = statement
10204                .query_map(rusqlite::params_from_iter(params), |row| {
10205                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
10206                    render_row_id_json(Some(&value))
10207                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
10208                })
10209                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10210            let ids = by_table.entry(target.table.clone()).or_default();
10211            for row in rows {
10212                ids.insert(
10213                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
10214                );
10215            }
10216        }
10217        Ok(by_table)
10218    }
10219
10220    fn local_purge_values_match(
10221        target: &CompiledLocalDataPurgeTarget,
10222        values: &Map<String, Value>,
10223    ) -> bool {
10224        target.selectors.iter().all(|(column, allowed)| {
10225            values
10226                .get(column)
10227                .and_then(Value::as_str)
10228                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10229        })
10230    }
10231
10232    /// Apply one host-authorized local security purge. The host MUST gate the
10233    /// corresponding subscriptions first; this primitive owns local SQLite
10234    /// cleanup and never grants/revokes server authority by itself.
10235    pub fn purge_local_data(
10236        &mut self,
10237        input: &LocalDataPurgeInput,
10238    ) -> Result<LocalDataPurgeResult, String> {
10239        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10240        let meta_key = format!("localPurge:{}", input.purge_id);
10241        let applied_plan = self
10242            .conn
10243            .query_row(
10244                "SELECT value FROM _syncular_meta WHERE key = ?1",
10245                rusqlite::params![meta_key],
10246                |row| row.get::<_, String>(0),
10247            )
10248            .optional()
10249            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10250        if let Some(applied_plan) = applied_plan {
10251            if applied_plan != canonical_plan {
10252                return Err(format!(
10253                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
10254                    input.purge_id
10255                ));
10256            }
10257            return Ok(LocalDataPurgeResult {
10258                already_applied: true,
10259                purged_rows: 0,
10260                dropped_commits: 0,
10261            });
10262        }
10263
10264        // RFC 0005 D7: the container file cannot be a purge target (its name is
10265        // not in the running schema), so purge drops it as a fixed step, before
10266        // any mirror row is touched and unconditionally.
10267        let _ = self.drop_previous_version();
10268
10269        let rollback = self.capture();
10270        self.begin_observation("syncular_local_purge")?;
10271        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10272            let row_ids = self.local_purge_base_row_ids(&targets)?;
10273            let retained_dropped = self.drop_failed_commits(|table, values| {
10274                targets.iter().any(|target| {
10275                    target.table == table && Self::local_purge_values_match(target, values)
10276                })
10277            })?;
10278            let doomed = self
10279                .outbox
10280                .iter()
10281                .filter(|commit| {
10282                    commit.ops.iter().any(|operation| {
10283                        let matching_targets = targets
10284                            .iter()
10285                            .filter(|target| target.table == operation.table)
10286                            .collect::<Vec<_>>();
10287                        if matching_targets.is_empty() {
10288                            return false;
10289                        }
10290                        if row_ids
10291                            .get(&operation.table)
10292                            .is_some_and(|ids| ids.contains(&operation.row_id))
10293                        {
10294                            return true;
10295                        }
10296                        operation.values.as_ref().is_some_and(|values| {
10297                            matching_targets
10298                                .iter()
10299                                .any(|target| Self::local_purge_values_match(target, values))
10300                        })
10301                    })
10302                })
10303                .cloned()
10304                .collect::<Vec<_>>();
10305            let doomed_ids = doomed
10306                .iter()
10307                .map(|commit| commit.client_commit_id.clone())
10308                .collect::<BTreeSet<_>>();
10309            let mut batch = ChangeAccumulator::default();
10310            for commit in &retained_dropped {
10311                batch.conflicts = true;
10312                batch.rejections = true;
10313                batch.outcomes = true;
10314                for operation in &commit.ops {
10315                    batch.table(&operation.table);
10316                }
10317            }
10318            let mut rejections = Vec::new();
10319            for commit in &doomed {
10320                for operation in &commit.ops {
10321                    batch.table(&operation.table);
10322                    self.overlay_dirty.table(&operation.table);
10323                }
10324                let results = commit
10325                    .ops
10326                    .iter()
10327                    .enumerate()
10328                    .map(|(op_index, operation)| {
10329                        let rejection = RejectionRecord {
10330                            client_commit_id: commit.client_commit_id.clone(),
10331                            op_index: op_index as i32,
10332                            code: "client.local_data_purged".to_owned(),
10333                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10334                            retryable: false,
10335                            details: None,
10336                            operation: Some(CommitOperation::from(operation)),
10337                        };
10338                        rejections.push(rejection.clone());
10339                        CommitOperationOutcome::Error { rejection }
10340                    })
10341                    .collect::<Vec<_>>();
10342                self.persist_commit_outcome(
10343                    &commit.client_commit_id,
10344                    CommitOutcomeStatus::Rejected,
10345                    &results,
10346                    Some(&commit.ops),
10347                )?;
10348                self.delete_outbox_persisted(&commit.client_commit_id)?;
10349            }
10350            if !doomed.is_empty() {
10351                self.outbox
10352                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10353                self.rejections.extend(rejections);
10354                self.prune_commit_outcomes()?;
10355                batch.status = true;
10356                batch.rejections = true;
10357                batch.outcomes = true;
10358            }
10359
10360            let mut purged_rows = 0usize;
10361            for (table, ids) in &row_ids {
10362                if ids.is_empty() {
10363                    continue;
10364                }
10365                batch.table(table);
10366                for row_id in ids {
10367                    self.delete_base_row(table, row_id)?;
10368                    purged_rows += 1;
10369                }
10370            }
10371            self.rebuild_overlay_if_dirty()?;
10372            self.delete_unreferenced_cached_blobs()?;
10373            self.conn
10374                .execute(
10375                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10376                    rusqlite::params![meta_key, canonical_plan],
10377                )
10378                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10379            Ok((
10380                batch,
10381                LocalDataPurgeResult {
10382                    already_applied: false,
10383                    purged_rows,
10384                    dropped_commits: doomed.len(),
10385                },
10386            ))
10387        })()
10388        .and_then(|(batch, result)| {
10389            self.finish_observation("syncular_local_purge", batch)
10390                .map(|()| result)
10391        });
10392        let result = match applied {
10393            Ok(result) => result,
10394            Err(error) => {
10395                self.rollback_observation("syncular_local_purge");
10396                self.restore(rollback);
10397                return Err(error);
10398            }
10399        };
10400        self.cancel_sync_round();
10401        Ok(result)
10402    }
10403
10404    /// Application-authorized recovery of the replicated projection. Unlike a
10405    /// local security purge, this retains the entire outbox, device identity,
10406    /// lease, outcomes, subscription registrations, and protected bookkeeping.
10407    /// The projection reset, subscription rewind, optimistic replay, and
10408    /// idempotency marker share one savepoint for interruption safety.
10409    pub fn rebootstrap_local_data(
10410        &mut self,
10411        input: &LocalDataRebootstrapInput,
10412    ) -> Result<LocalDataRebootstrapResult, String> {
10413        if input.rebootstrap_id.is_empty()
10414            || input.rebootstrap_id.len() > 128
10415            || !is_local_operation_code_like(&input.rebootstrap_id)
10416        {
10417            return Err(
10418                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10419                    .to_owned(),
10420            );
10421        }
10422        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10423        if let Some(persisted) = meta_get(&self.conn, &meta_key).map_err(|_| {
10424            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10425        })? {
10426            let (retained_commits, reset_subscriptions) =
10427                decode_local_rebootstrap_receipt(&persisted)?;
10428            return Ok(LocalDataRebootstrapResult {
10429                already_applied: true,
10430                retained_commits,
10431                reset_subscriptions,
10432            });
10433        }
10434        if self.stopped || self.schema_floor.is_some() {
10435            return Err(
10436                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10437                    .to_owned(),
10438            );
10439        }
10440
10441        let retained_commits = self.outbox.len();
10442        let reset_subscriptions = self.subs.len();
10443        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10444        let rollback = self.capture();
10445        self.begin_observation("syncular_local_rebootstrap")?;
10446        let mut batch = ChangeAccumulator::default();
10447        let applied = (|| -> Result<(), String> {
10448            self.run_schema_reset_observed(&mut batch, false)?;
10449            self.conn
10450                .execute(
10451                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10452                    rusqlite::params![meta_key, receipt],
10453                )
10454                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10455            self.sync_needed = true;
10456            self.sync_intent_queue.push_back(SyncIntent::Interactive);
10457            batch.status = true;
10458            Ok(())
10459        })()
10460        .and_then(|()| self.finish_observation("syncular_local_rebootstrap", batch));
10461        if let Err(error) = applied {
10462            self.rollback_observation("syncular_local_rebootstrap");
10463            self.restore(rollback);
10464            return Err(error);
10465        }
10466
10467        Ok(LocalDataRebootstrapResult {
10468            already_applied: false,
10469            retained_commits,
10470            reset_subscriptions,
10471        })
10472    }
10473
10474    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
10475
10476    /// Delete base and visible rows owned by the effective scopes
10477    /// ([`local_scope_predicate`]); `Err(())` = no local scope-column mapping
10478    /// for a key (the fail-closed case).
10479    fn purge_scope_rows(
10480        &mut self,
10481        table_name: &str,
10482        effective: &[(String, Vec<String>)],
10483        owners: &[Vec<(String, Vec<String>)>],
10484    ) -> Result<(), ()> {
10485        let table = self.schema.table(table_name).ok_or(())?;
10486        let Some((predicate, params)) = local_scope_predicate(table, effective, owners)? else {
10487            return Ok(());
10488        };
10489        self.overlay_dirty.table(table_name);
10490        for source in [base_table(table_name), visible_table(table_name)] {
10491            self.conn
10492                .execute(
10493                    &format!("DELETE FROM {source} WHERE {predicate}"),
10494                    rusqlite::params_from_iter(&params),
10495                )
10496                .map_err(|_| ())?;
10497        }
10498        Ok(())
10499    }
10500
10501    /// §3.3: drop pending commits whose upserts provably land in the
10502    /// revoked effective scopes and no other owner covers (row ownership)
10503    /// — whole-commit, never per-operation.
10504    fn drop_doomed_outbox(
10505        &mut self,
10506        table: &TableSchema,
10507        effective: &[(String, Vec<String>)],
10508        owners: &[Vec<(String, Vec<String>)>],
10509    ) -> Result<bool, String> {
10510        let doomed: Vec<_> =
10511            self.outbox
10512                .iter()
10513                .filter(|commit| {
10514                    commit.ops.iter().any(|op| {
10515                        op.upsert
10516                            && op.table == table.name
10517                            && op.values.as_ref().is_some_and(|values| {
10518                                owns_local_row(table, effective, owners, values)
10519                            })
10520                    })
10521                })
10522                .cloned()
10523                .collect();
10524        if doomed.is_empty() {
10525            return Ok(false);
10526        }
10527        let mut rejections = Vec::new();
10528        for commit in &doomed {
10529            let results = commit
10530                .ops
10531                .iter()
10532                .enumerate()
10533                .map(|(op_index, operation)| {
10534                    self.overlay_dirty.table(&operation.table);
10535                    let rejection = RejectionRecord {
10536                        client_commit_id: commit.client_commit_id.clone(),
10537                        op_index: op_index as i32,
10538                        code: "sync.scope_revoked".to_owned(),
10539                        message: "the commit was dropped because its effective scope was revoked"
10540                            .to_owned(),
10541                        retryable: false,
10542                        details: None,
10543                        operation: Some(CommitOperation::from(operation)),
10544                    };
10545                    rejections.push(rejection.clone());
10546                    CommitOperationOutcome::Error { rejection }
10547                })
10548                .collect::<Vec<_>>();
10549            self.persist_commit_outcome(
10550                &commit.client_commit_id,
10551                CommitOutcomeStatus::Rejected,
10552                &results,
10553                Some(&commit.ops),
10554            )?;
10555            self.delete_outbox_persisted(&commit.client_commit_id)?;
10556        }
10557        self.prune_commit_outcomes()?;
10558        let doomed_ids = doomed
10559            .iter()
10560            .map(|commit| commit.client_commit_id.as_str())
10561            .collect::<BTreeSet<_>>();
10562        self.outbox
10563            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10564        self.rejections.extend(rejections);
10565        Ok(true)
10566    }
10567
10568    // -- blobs (§5.9) ----------------------------------------------------------------
10569
10570    /// §5.9.7: hash bytes into the content address, cache them, queue the
10571    /// upload (flushed before the next push, B4). Returns the canonical
10572    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
10573    /// column value.
10574    pub fn upload_blob(
10575        &mut self,
10576        bytes: &[u8],
10577        media_type: Option<String>,
10578        name: Option<String>,
10579    ) -> Result<Value, String> {
10580        let blob_id = blob_id_for(bytes);
10581        let now = self.clock_now_ms();
10582        let tx = self
10583            .conn
10584            .transaction()
10585            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10586        tx.execute(
10587            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10588             ON CONFLICT(blob_id) DO NOTHING",
10589            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10590        )
10591        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10592        tx.execute(
10593            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10594            rusqlite::params![blob_id, media_type, now],
10595        )
10596        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10597        tx.commit()
10598            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10599        self.enforce_blob_cache_cap();
10600        let mut obj = Map::new();
10601        obj.insert("blobId".to_owned(), Value::from(blob_id));
10602        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
10603        if let Some(mt) = media_type {
10604            obj.insert("mediaType".to_owned(), Value::from(mt));
10605        }
10606        if let Some(n) = name {
10607            obj.insert("name".to_owned(), Value::from(n));
10608        }
10609        Ok(Value::Object(obj))
10610    }
10611
10612    /// §5.9.7: resolve an owned blob body without driver encoding. A
10613    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
10614    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
10615    pub fn fetch_blob_bytes(
10616        &mut self,
10617        transport: &mut dyn Transport,
10618        blob_id_or_ref: &str,
10619    ) -> Result<FetchedBlob, (String, String)> {
10620        let simple = |m: String| ("client.failed".to_owned(), m);
10621        // A BlobRef declares the size that caps the download (§5.9.5); a bare
10622        // blobId declares none.
10623        let (blob_id, byte_length) = if blob_id_or_ref.starts_with("sha256:") {
10624            (blob_id_or_ref.to_owned(), None)
10625        } else {
10626            let value: Value = serde_json::from_str(blob_id_or_ref)
10627                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
10628            (
10629                value
10630                    .get("blobId")
10631                    .and_then(Value::as_str)
10632                    .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
10633                    .to_owned(),
10634                value.get("byteLength").and_then(Value::as_u64),
10635            )
10636        };
10637        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
10638            return Ok(cached);
10639        }
10640        if !self.transport_enabled {
10641            return Err(("sync.offline".into(), "transport gate is closed".into()));
10642        }
10643        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
10644        // blob.not_found) verbatim so the harness can assert on it. The
10645        // authorized endpoint serves bytes inline OR (always-issue, presign
10646        // configured) a signed url the client fetches directly — no host auth,
10647        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
10648        #[cfg(feature = "bench-internals")]
10649        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
10650        let bytes = match transport
10651            .blob_download(&blob_id, byte_length)
10652            .map_err(|e| (e.code, e.message))?
10653        {
10654            BlobDownload::Bytes(bytes) => bytes,
10655            BlobDownload::Url {
10656                url,
10657                url_expires_at_ms,
10658            } => {
10659                // §5.9.5: MUST NOT start a fetch at/past expiry.
10660                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10661                    return Err((
10662                        "sync.segment_expired".to_owned(),
10663                        format!(
10664                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
10665                        ),
10666                    ));
10667                }
10668                transport
10669                    .fetch_blob_url(&url, byte_length)
10670                    .map_err(|e| (e.code, e.message))?
10671            }
10672        };
10673        #[cfg(feature = "bench-internals")]
10674        drop(download_phase);
10675        #[cfg(feature = "bench-internals")]
10676        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
10677        // §5.9.5 inherits §5.1: the declared size caps the body before the
10678        // content address is verified.
10679        if byte_length.is_some_and(|cap| bytes.len() as u64 > cap) {
10680            return Err((
10681                "sync.invalid_request".to_owned(),
10682                "blob exceeds its byteLength".to_owned(),
10683            ));
10684        }
10685        if blob_id_for(&bytes) != blob_id {
10686            return Err(simple(format!(
10687                "blob content address mismatch for {blob_id}"
10688            )));
10689        }
10690        #[cfg(feature = "bench-internals")]
10691        drop(validate_phase);
10692        #[cfg(feature = "bench-internals")]
10693        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
10694        let now = self.clock_now_ms();
10695        self.conn
10696            .execute(
10697                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
10698                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
10699            )
10700            .map_err(|e| simple(e.to_string()))?;
10701        self.conn
10702            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
10703            .map_err(|e| simple(e.to_string()))?;
10704        self.enforce_blob_cache_cap();
10705        #[cfg(feature = "bench-internals")]
10706        drop(insert_phase);
10707        self.get_cached_blob_bytes(&blob_id)
10708            .map_err(simple)?
10709            .ok_or_else(|| simple("blob cache write failed".to_owned()))
10710    }
10711
10712    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
10713        #[cfg(feature = "bench-internals")]
10714        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
10715        let mut stmt = self
10716            .conn
10717            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
10718            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10719        let mut rows = stmt
10720            .query(rusqlite::params![blob_id])
10721            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10722        if let Some(row) = rows
10723            .next()
10724            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10725        {
10726            let bytes: Vec<u8> = row
10727                .get(0)
10728                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10729            let byte_length: i64 = row
10730                .get(1)
10731                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10732            let media_type: Option<String> = row
10733                .get(2)
10734                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10735            return Ok(Some(FetchedBlob {
10736                blob_id: blob_id.to_owned(),
10737                byte_length,
10738                bytes,
10739                media_type,
10740            }));
10741        }
10742        Ok(None)
10743    }
10744
10745    /// §5.9.7 B4: upload every queued blob before push.
10746    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
10747        let pending: Vec<(String, Option<String>)> = {
10748            let mut stmt = self
10749                .conn
10750                .prepare(
10751                    "SELECT blob_id, media_type FROM (
10752                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
10753                      UNION ALL
10754                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
10755                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
10756                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
10757                    ) ORDER BY created_at_ms, blob_id",
10758                )
10759                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10760            let rows = stmt
10761                .query_map([], |row| {
10762                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
10763                })
10764                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10765            rows.collect::<Result<_, _>>()
10766                .map_err(|error| match error {
10767                    rusqlite::Error::InvalidColumnType(..)
10768                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
10769                        "sync.local_corrupt",
10770                        "Pending blob upload metadata is invalid",
10771                    ),
10772                    _ => TransportError::new("client.failed", error.to_string()),
10773                })?
10774        };
10775        let mut uploads = Vec::new();
10776        for (blob_id, media_type) in pending {
10777            let bytes = (|| {
10778                let stored: Option<(SqlValue, SqlValue)> = self
10779                    .conn
10780                    .query_row(
10781                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
10782                        rusqlite::params![blob_id],
10783                        |row| Ok((row.get(0)?, row.get(1)?)),
10784                    )
10785                    .optional()
10786                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10787                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
10788                    return Err(TransportError::new(
10789                        "sync.local_corrupt",
10790                        "Pending blob upload body is missing or corrupt",
10791                    ));
10792                };
10793                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
10794                    return Err(TransportError::new(
10795                        "sync.local_corrupt",
10796                        "Pending blob upload body is missing or corrupt",
10797                    ));
10798                }
10799                Ok(bytes)
10800            })();
10801            let failed = bytes.is_err();
10802            uploads.push(crate::round::PendingUpload {
10803                id: blob_id,
10804                bytes,
10805                media_type,
10806            });
10807            if failed {
10808                break;
10809            }
10810        }
10811        Ok(uploads)
10812    }
10813
10814    fn visible_blob_ids_sql(&self) -> String {
10815        let mut selects = Vec::new();
10816        for table in &self.schema.tables {
10817            for column in table
10818                .columns
10819                .iter()
10820                .filter(|column| column.ty == ColumnType::BlobRef)
10821            {
10822                let identifier = quote_ident(&column.name);
10823                selects.push(format!(
10824                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
10825                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
10826                     AND json_type({identifier}, '$.blobId') = 'text'",
10827                    quote_ident(&table.name),
10828                ));
10829            }
10830        }
10831        if selects.is_empty() {
10832            "SELECT NULL AS blob_id WHERE 0".to_owned()
10833        } else {
10834            selects.join(" UNION ")
10835        }
10836    }
10837
10838    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
10839    /// staged uploads pin bodies directly; no derived refcount is stored.
10840    fn enforce_blob_cache_cap(&self) {
10841        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
10842            return;
10843        };
10844        let mut total: i64 = self
10845            .conn
10846            .query_row(
10847                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
10848                [],
10849                |row| row.get(0),
10850            )
10851            .unwrap_or(0);
10852        if total <= max_bytes {
10853            return;
10854        }
10855        let query = format!(
10856            "SELECT blob_id, byte_length FROM _syncular_blobs
10857             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10858               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10859               AND blob_id NOT IN ({})
10860             ORDER BY created_at_ms ASC, blob_id ASC",
10861            self.visible_blob_ids_sql(),
10862        );
10863        let candidates: Vec<(String, i64)> = {
10864            let Ok(mut stmt) = self.conn.prepare(&query) else {
10865                return;
10866            };
10867            let Ok(rows) = stmt.query_map([], |row| {
10868                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10869            }) else {
10870                return;
10871            };
10872            rows.filter_map(Result::ok).collect()
10873        };
10874        for (blob_id, byte_length) in candidates {
10875            if total <= max_bytes {
10876                break;
10877            }
10878            let _ = self.conn.execute(
10879                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
10880                rusqlite::params![blob_id],
10881            );
10882            total -= byte_length;
10883        }
10884    }
10885
10886    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
10887        #[cfg(feature = "bench-internals")]
10888        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
10889        if !self.schema_has_blobs() {
10890            return Ok(());
10891        }
10892        self.rebuild_overlay_if_dirty()?;
10893        self.conn
10894            .execute(
10895                &format!(
10896                    "DELETE FROM _syncular_blobs
10897                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10898                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10899                   AND blob_id NOT IN ({})",
10900                    self.visible_blob_ids_sql(),
10901                ),
10902                [],
10903            )
10904            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10905        Ok(())
10906    }
10907
10908    // -- local row storage ----------------------------------------------------------
10909
10910    /// The cached per-table primary-key upsert SQL for `full_table` (see
10911    /// the `insert_sql` field: built once, reused per row).
10912    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
10913        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
10914            return sql.clone();
10915        }
10916        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
10917        columns.push(quote_ident("_syncular_version"));
10918        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
10919        let primary_key = quote_ident(&table.primary_key);
10920        let updates = columns
10921            .iter()
10922            .filter(|column| **column != primary_key)
10923            .map(|column| format!("{column}=excluded.{column}"))
10924            .collect::<Vec<_>>()
10925            .join(", ");
10926        let sql = format!(
10927            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
10928            columns.join(", "),
10929            placeholders.join(", ")
10930        );
10931        self.insert_sql
10932            .borrow_mut()
10933            .insert(full_table.to_owned(), sql.clone());
10934        sql
10935    }
10936
10937    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
10938        self.overlay_dirty.table(table_name);
10939        self.write_row(&base_table(table_name), table_name, row, version)
10940    }
10941
10942    fn write_row(
10943        &self,
10944        full_table: &str,
10945        table_name: &str,
10946        row: &Row,
10947        version: i64,
10948    ) -> Result<(), String> {
10949        #[cfg(feature = "bench-internals")]
10950        let _phase = self.benchmark_phases.start(Phase::RowWrite);
10951        let table = self
10952            .schema
10953            .table(table_name)
10954            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10955        let sql = self.insert_row_sql(full_table, table);
10956        let mut stmt = self
10957            .conn
10958            .prepare_cached(&sql)
10959            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10960        let params = row
10961            .iter()
10962            .map(RowParam::Cell)
10963            .chain(std::iter::once(RowParam::Version(version)));
10964        stmt.execute(rusqlite::params_from_iter(params))
10965            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10966        Ok(())
10967    }
10968
10969    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
10970        let table = self
10971            .schema
10972            .table(table_name)
10973            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10974        self.overlay_dirty.table(table_name);
10975        let sql = format!(
10976            "DELETE FROM {} WHERE {}",
10977            base_table(table_name),
10978            row_id_predicate(table)
10979        );
10980        let mut stmt = self
10981            .conn
10982            .prepare_cached(&sql)
10983            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10984        stmt.execute(rusqlite::params![row_id])
10985            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10986        Ok(())
10987    }
10988
10989    fn has_acknowledged_rows(&self) -> Result<bool, String> {
10990        self.conn
10991            .query_row(
10992                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
10993                [],
10994                |row| row.get(0),
10995            )
10996            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10997    }
10998
10999    fn acknowledged_operations(
11000        &self,
11001        rows: Option<&HashSet<(&str, &str)>>,
11002        tables: Option<&BTreeSet<&str>>,
11003    ) -> Result<Vec<OutboxOp>, String> {
11004        let keys = rows.map(|rows| {
11005            serde_json::to_string(
11006                &rows
11007                    .iter()
11008                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
11009                    .collect::<Vec<_>>(),
11010            )
11011            .expect("row keys")
11012        });
11013        let mut statement = self
11014            .conn
11015            .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
11016            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11017        let rows = statement
11018            .query_map(
11019                rusqlite::params![
11020                    keys,
11021                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
11022                ],
11023                |row| {
11024                    #[cfg(test)]
11025                    self.acknowledged_replay_count
11026                        .set(self.acknowledged_replay_count.get() + 1);
11027                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11028                },
11029            )
11030            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11031        rows.map(|row| {
11032            let (op, intent) =
11033                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11034            let op: CommitOperation =
11035                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
11036            let table = self
11037                .schema
11038                .table(&op.table)
11039                .ok_or_else(|| "sync.unknown_table".to_owned())?;
11040            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
11041                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
11042            } else {
11043                op.values
11044            };
11045            Ok(OutboxOp {
11046                table: op.table,
11047                row_id: op.row_id,
11048                upsert: op.op == "upsert",
11049                base_version: op.base_version,
11050                values,
11051                insert: op.insert,
11052            })
11053        })
11054        .collect()
11055    }
11056
11057    fn drop_acknowledged_rows(
11058        &self,
11059        matches: impl Fn(&str, &Map<String, Value>) -> bool,
11060    ) -> Result<Vec<OutboxCommit>, String> {
11061        let mut statement = self
11062            .conn
11063            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
11064            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11065        let rows = statement
11066            .query_map([], |row| {
11067                Ok((
11068                    row.get::<_, String>(0)?,
11069                    row.get::<_, String>(1)?,
11070                    row.get::<_, String>(2)?,
11071                    row.get::<_, String>(3)?,
11072                ))
11073            })
11074            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11075        let mut doomed = HashSet::new();
11076        for row in rows {
11077            let (id, table, intent, row_id) =
11078                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11079            let values =
11080                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
11081            let base_matches = self
11082                .schema
11083                .table(&table)
11084                .map(|schema| self.stored_row(schema, &row_id, true))
11085                .transpose()?
11086                .flatten()
11087                .is_some_and(|(base, _)| {
11088                    let schema = self.schema.table(&table).expect("acknowledged table");
11089                    let values = schema
11090                        .columns
11091                        .iter()
11092                        .zip(base.iter())
11093                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
11094                        .collect();
11095                    matches(&table, &values)
11096                });
11097            if matches(&table, &values) || base_matches {
11098                doomed.insert(id);
11099            }
11100        }
11101        let mut dropped = Vec::new();
11102        for id in doomed {
11103            let mut statement = self
11104                .conn
11105                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
11106                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11107            let operations = statement
11108                .query_map([&id], |row| row.get::<_, String>(0))
11109                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11110            let mut ops = Vec::new();
11111            for operation in operations {
11112                let json = operation
11113                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11114                let op: CommitOperation =
11115                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
11116                self.overlay_dirty.table(&op.table);
11117                ops.push(OutboxOp {
11118                    table: op.table,
11119                    row_id: op.row_id,
11120                    upsert: op.op == "upsert",
11121                    base_version: op.base_version,
11122                    values: op.values,
11123                    insert: op.insert,
11124                });
11125            }
11126            dropped.push(OutboxCommit {
11127                client_commit_id: id.clone(),
11128                ops,
11129            });
11130            self.conn
11131                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
11132                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11133        }
11134        Ok(dropped)
11135    }
11136
11137    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
11138    /// the outbox changed since the last rebuild (the no-op sync round).
11139    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
11140        if self.overlay_dirty.get() {
11141            self.rebuild_overlay()?;
11142        }
11143        Ok(())
11144    }
11145
11146    /// §7.1: local reads see outbox state applied optimistically — rebuild
11147    /// every visible table as (base server state) + (pending outbox replay
11148    /// on top). Optimistic rows carry version `-1`.
11149    fn rebuild_overlay(&mut self) -> Result<(), String> {
11150        #[cfg(feature = "bench-internals")]
11151        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11152        #[cfg(test)]
11153        self.overlay_rebuild_count
11154            .set(self.overlay_rebuild_count.get() + 1);
11155        let dirty = self.overlay_dirty.snapshot();
11156        let tables: Vec<_> = self
11157            .schema
11158            .tables
11159            .iter()
11160            .filter(|table| {
11161                dirty
11162                    .as_ref()
11163                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
11164            })
11165            .cloned()
11166            .collect();
11167        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
11168        self.begin_observation("syncular_overlay")?;
11169        let rebuilt = (|| {
11170            for table in &tables {
11171                for index in &table.fts_indexes {
11172                    self.drop_fts_triggers(index)?;
11173                }
11174                let visible = visible_table(&table.name);
11175                let base = base_table(&table.name);
11176                self.conn
11177                    .execute_batch(&format!(
11178                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
11179                    ))
11180                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11181            }
11182            self.replay_overlay(&names, None)?;
11183            for table in &tables {
11184                for index in &table.fts_indexes {
11185                    self.rebuild_fts_projection(table, index)?;
11186                    self.create_fts_triggers(table, index)?;
11187                }
11188            }
11189            self.conn
11190                .execute_batch("RELEASE syncular_overlay")
11191                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11192            Ok(())
11193        })();
11194        if rebuilt.is_err() {
11195            self.conn
11196                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
11197                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11198        } else {
11199            self.overlay_dirty.set(false);
11200        }
11201        rebuilt
11202    }
11203
11204    /// §7.1 replay of acknowledged, retained failed and pending intent, in
11205    /// that order, for the operations of `tables` on `rows` (every row for
11206    /// `None`) over their current visible state.
11207    fn replay_overlay(
11208        &self,
11209        tables: &BTreeSet<&str>,
11210        rows: Option<&HashSet<(&str, &str)>>,
11211    ) -> Result<(), String> {
11212        let replays = |op: &&OutboxOp| {
11213            tables.contains(op.table.as_str())
11214                && rows.is_none_or(|rows| rows.contains(&(op.table.as_str(), op.row_id.as_str())))
11215        };
11216        self.apply_outbox_ops(&self.acknowledged_operations(rows, Some(tables))?)?;
11217        for (commit, initial) in self.failed_commits.iter() {
11218            for operation in commit.ops.iter().filter(replays) {
11219                let Some(table) = self.schema.table(&operation.table) else {
11220                    continue;
11221                };
11222                if operation.upsert
11223                    && self.visible_row(table, &operation.row_id)?.is_none()
11224                    && operation.presented_values(table).is_some_and(|values| {
11225                        table
11226                            .columns
11227                            .iter()
11228                            .all(|column| values.contains_key(&column.name))
11229                    })
11230                {
11231                    let key = serde_json::to_string(&(&operation.table, &operation.row_id))
11232                        .expect("row key serialization");
11233                    if let Some(Value::Object(values)) = initial.get(&key) {
11234                        self.apply_outbox_ops(&[OutboxOp {
11235                            values: Some(values.clone()),
11236                            ..operation.clone()
11237                        }])?;
11238                    }
11239                }
11240            }
11241            self.apply_outbox_ops(commit.ops.iter().filter(replays))?;
11242        }
11243        self.apply_outbox_ops(
11244            self.outbox
11245                .iter()
11246                .flat_map(|commit| &commit.ops)
11247                .filter(replays),
11248        )
11249    }
11250
11251    /// §7.1 replay: every operation is applied over the current visible
11252    /// state. An operation that no longer applies (a server row now holds its
11253    /// unique value) stays pending and invisible until a later replay admits
11254    /// it or the server answers its push.
11255    fn apply_outbox_ops<'a>(
11256        &self,
11257        ops: impl IntoIterator<Item = &'a OutboxOp>,
11258    ) -> Result<(), String> {
11259        #[cfg(feature = "bench-internals")]
11260        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11261        for op in ops {
11262            #[cfg(feature = "bench-internals")]
11263            if let Some(phase) = &mut phase {
11264                phase.unit();
11265            }
11266            if op.upsert {
11267                if let Some((table, values)) = self
11268                    .schema
11269                    .table(&op.table)
11270                    .filter(|table| table.indexes.iter().any(|index| index.unique))
11271                    .and_then(|table| Some((table, op.presented_values(table)?)))
11272                {
11273                    let mut intended: Map<String, Value> = self
11274                        .visible_row(table, &op.row_id)?
11275                        .map(|(row, _)| {
11276                            table
11277                                .columns
11278                                .iter()
11279                                .zip(&row)
11280                                .map(|(column, value)| {
11281                                    (column.name.clone(), column_value_to_json(value))
11282                                })
11283                                .collect()
11284                        })
11285                        .unwrap_or_default();
11286                    intended.extend(values.into_owned());
11287                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
11288                        continue;
11289                    }
11290                }
11291            }
11292            self.apply_outbox_op(op)?;
11293        }
11294        Ok(())
11295    }
11296
11297    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11298        let Some(table) = self.schema.table(&op.table) else {
11299            return Ok(());
11300        };
11301        let visible = visible_table(&table.name);
11302        if !op.upsert {
11303            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11304            self.conn
11305                .prepare_cached(&sql)
11306                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11307                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11308            return Ok(());
11309        }
11310        // §7.1: an insert-intent operation that lacks a non-nullable column
11311        // cannot encode; it stays invisible until the send-time prepass drops
11312        // it as sync.outbox_incompatible (§7.4.4).
11313        let Some(values) = op.presented_values(table) else {
11314            return Ok(());
11315        };
11316        // §7.1: the overlay applies the operation's PRESENT columns
11317        // over the current local row. A partial operation over an
11318        // absent local row leaves it absent — the server answers it
11319        // with sync.row_deleted or sync.row_missing (§6.2). An
11320        // insert-intent operation presents every column.
11321        let full = table
11322            .columns
11323            .iter()
11324            .all(|column| values.contains_key(&column.name));
11325        let local = self.visible_row(table, &op.row_id)?;
11326        if local.is_none() && !full {
11327            return Ok(());
11328        }
11329        let mut row: Row = Vec::with_capacity(table.columns.len());
11330        for (index, column) in table.columns.iter().enumerate() {
11331            let incoming = match values.get(&column.name) {
11332                Some(raw) => json_to_column_value(
11333                    if raw.get("$bytes").is_some()
11334                        && table
11335                            .encrypted_columns
11336                            .iter()
11337                            .any(|encrypted| encrypted.index == index)
11338                    {
11339                        &table.wire_columns[index]
11340                    } else {
11341                        column
11342                    },
11343                    Some(raw),
11344                ),
11345                None => Ok(local
11346                    .as_ref()
11347                    .and_then(|(values, _)| values.get(index).cloned())
11348                    .flatten()),
11349            };
11350            row.push(incoming.map_err(|_| {
11351                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
11352            })?);
11353        }
11354        let version = local.as_ref().map_or(-1, |(_, version)| *version);
11355        self.write_row(&visible, &table.name, &row, version)
11356            .map_err(|error| {
11357                if error.starts_with("UNIQUE constraint failed:") {
11358                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
11359                } else {
11360                    error
11361                }
11362            })
11363    }
11364
11365    /// Whether the row is present in the overlay. Existence reads only the
11366    /// key: locked encrypted values stay opaque, and decoding the complete row
11367    /// would reject a valid plain write.
11368    fn visible_row_exists(&self, table: &TableSchema, row_id: &str) -> Result<bool, String> {
11369        self.conn
11370            .query_row(
11371                &format!(
11372                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
11373                    visible_table(&table.name),
11374                    row_id_predicate(table)
11375                ),
11376                rusqlite::params![row_id],
11377                |row| row.get(0),
11378            )
11379            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11380    }
11381
11382    /// The visible (optimistic) row's values plus version, or `None` when the
11383    /// row is absent from the overlay.
11384    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
11385        self.stored_row(table, row_id, false)
11386    }
11387
11388    fn stored_row(
11389        &self,
11390        table: &TableSchema,
11391        row_id: &str,
11392        base: bool,
11393    ) -> Result<Option<(Row, i64)>, String> {
11394        let sql = format!(
11395            "SELECT * FROM {} WHERE {}",
11396            if base {
11397                base_table(&table.name)
11398            } else {
11399                visible_table(&table.name)
11400            },
11401            row_id_predicate(table)
11402        );
11403        let mut stmt = self
11404            .conn
11405            .prepare_cached(&sql)
11406            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11407        let mut rows = stmt
11408            .query(rusqlite::params![row_id])
11409            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11410        let Some(row) = rows
11411            .next()
11412            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11413        else {
11414            return Ok(None);
11415        };
11416        let mut values = Vec::with_capacity(table.columns.len());
11417        for (index, column) in table.columns.iter().enumerate() {
11418            let raw = row
11419                .get_ref(index)
11420                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11421            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
11422                if table
11423                    .encrypted_columns
11424                    .iter()
11425                    .any(|encrypted| encrypted.index == index)
11426                {
11427                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
11428                    continue;
11429                }
11430            }
11431            let json = sql_ref_to_json(column, raw);
11432            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
11433                "sync.local_corrupt: persisted local row value is invalid".to_owned()
11434            })?);
11435        }
11436        let version: i64 = row
11437            .get(table.columns.len())
11438            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11439        Ok(Some((values, version)))
11440    }
11441
11442    // -- realtime (§8) ---------------------------------------------------------------
11443
11444    /// §8.8: the configured realtime policy.
11445    #[must_use]
11446    pub fn realtime_policy(&self) -> RealtimePolicy {
11447        self.realtime_policy
11448    }
11449
11450    /// §8.8: the explicit realtime availability state.
11451    #[must_use]
11452    pub fn realtime_state(&self) -> RealtimeState {
11453        self.realtime_state
11454    }
11455
11456    /// §8.8: set the realtime policy. Refused while the socket is connected:
11457    /// a policy change never silently re-routes an ownership state.
11458    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
11459        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
11460            return Err(
11461                "sync.invalid_request: disconnect realtime before changing the realtime policy"
11462                    .to_owned(),
11463            );
11464        }
11465        self.realtime_policy = policy;
11466        match policy {
11467            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
11468            RealtimePolicy::Required | RealtimePolicy::Optional
11469                if self.realtime_state == RealtimeState::Disabled =>
11470            {
11471                self.set_realtime_state(RealtimeState::Disconnected, None);
11472            }
11473            RealtimePolicy::Required | RealtimePolicy::Optional => {}
11474        }
11475        Ok(())
11476    }
11477
11478    /// Record an availability transition, keeping the reason code bounded and
11479    /// code-like (§7.6) and clearing it where it cannot apply.
11480    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
11481        self.realtime_state = state;
11482        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
11483        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
11484            self.realtime_reason_code = None;
11485            self.realtime_retry_delay_ms = None;
11486        }
11487    }
11488
11489    #[must_use]
11490    fn realtime_connected(&self) -> bool {
11491        self.realtime_state == RealtimeState::Connected
11492    }
11493
11494    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11495        if !self.transport_enabled {
11496            return Err("sync.offline: transport gate is closed".into());
11497        }
11498        if self.realtime_policy == RealtimePolicy::Off {
11499            return Err(
11500                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
11501            );
11502        }
11503        if self.realtime_connected() {
11504            return Ok(());
11505        }
11506        self.realtime_state = RealtimeState::Connecting;
11507        match transport.realtime_connect_for_client(&self.client_id) {
11508            Ok(()) => {
11509                self.set_realtime_state(RealtimeState::Connected, None);
11510                Ok(())
11511            }
11512            Err(error) => {
11513                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
11514                Err(format!("{}: {}", error.code, error.message))
11515            }
11516        }
11517    }
11518
11519    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11520        self.cancel_sync_round();
11521        // A deliberately disconnected or policy-disabled client has nothing
11522        // to release; a `lost` socket still does (the host owns the handle).
11523        if self.realtime_state == RealtimeState::Disconnected
11524            || self.realtime_state == RealtimeState::Disabled
11525        {
11526            return;
11527        }
11528        let _ = transport.realtime_close();
11529        self.set_realtime_state(
11530            if self.realtime_policy == RealtimePolicy::Off {
11531                RealtimeState::Disabled
11532            } else {
11533                RealtimeState::Disconnected
11534            },
11535            None,
11536        );
11537        self.presence.clear(); // §8.6.1: presence is per-connection
11538    }
11539
11540    /// §8.8: the host observed the socket end without disconnecting it (a
11541    /// peer close, an I/O error, a failed send; the native transport reports
11542    /// these as `Inbound::Disconnected`). Releases the transport, moves a
11543    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
11544    /// one background retry intent so the host reconnects and catches up.
11545    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
11546        if self.realtime_state != RealtimeState::Connected {
11547            return;
11548        }
11549        self.cancel_sync_round();
11550        let _ = transport.realtime_close();
11551        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
11552        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
11553        self.presence.clear();
11554    }
11555
11556    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
11557    /// document for `scope_key`. Requires a live socket; the document is
11558    /// ephemeral (lost on disconnect). Authorization is the connection's
11559    /// registration (§8.6.3) — an unheld key is rejected loudly by the
11560    /// server with `presence.forbidden`.
11561    pub fn set_presence(
11562        &mut self,
11563        transport: &mut dyn Transport,
11564        scope_key: &str,
11565        doc: Option<&Value>,
11566    ) -> Result<(), String> {
11567        if !self.transport_enabled {
11568            return Err("sync.offline: transport gate is closed".into());
11569        }
11570        if !self.realtime_connected() {
11571            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11572        }
11573        let text = encode_presence_publish(scope_key, doc);
11574        transport
11575            .realtime_send(&text)
11576            .map_err(|e| format!("{}: {}", e.code, e.message))
11577    }
11578
11579    /// §8.6: the peers currently present on a scope key (ephemeral).
11580    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11581        self.presence
11582            .get(scope_key)
11583            .map(|peers| peers.values().cloned().collect())
11584            .unwrap_or_default()
11585    }
11586
11587    /// §8.6 apply an inbound presence fanout to the local map.
11588    fn apply_presence(
11589        &mut self,
11590        scope_key: String,
11591        kind: Option<PresenceKind>,
11592        actor_id: Option<String>,
11593        client_id: Option<String>,
11594        doc: Option<Value>,
11595        error: Option<String>,
11596    ) {
11597        // A refused publish (§8.6.2) changes no peer; the native host surfaces
11598        // it as a `presence` event carrying the server's `error` code.
11599        if error.is_some() {
11600            return;
11601        }
11602        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11603            return;
11604        };
11605        let peer_key = format!("{actor_id} {client_id}");
11606        match kind {
11607            PresenceKind::Leave => {
11608                if let Some(peers) = self.presence.get_mut(&scope_key) {
11609                    peers.remove(&peer_key);
11610                    if peers.is_empty() {
11611                        self.presence.remove(&scope_key);
11612                    }
11613                }
11614            }
11615            _ => {
11616                let doc = match doc {
11617                    Some(Value::Object(_)) => doc.unwrap(),
11618                    _ => return,
11619                };
11620                self.presence.entry(scope_key).or_default().insert(
11621                    peer_key,
11622                    PresencePeer {
11623                        actor_id,
11624                        client_id,
11625                        doc,
11626                    },
11627                );
11628            }
11629        }
11630    }
11631
11632    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
11633    pub fn on_realtime_text(&mut self, text: &str) {
11634        if self.stopped || !self.transport_enabled || self.security_preflight {
11635            return;
11636        }
11637        match parse_control(text) {
11638            Ok(ControlMessage::Hello { requires_sync, .. }) => {
11639                if requires_sync {
11640                    // §8.1: pull before trusting the socket for continuity.
11641                    self.set_sync_needed(true, true);
11642                }
11643            }
11644            Ok(ControlMessage::Presence {
11645                scope_key,
11646                kind,
11647                actor_id,
11648                client_id,
11649                doc,
11650                error,
11651                ..
11652            }) => {
11653                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11654            }
11655            Ok(ControlMessage::Wake { .. }) => {
11656                // §8.3: any wake-up means "run a pull soon", never data.
11657                self.set_sync_needed(true, true);
11658            }
11659            _ => {}
11660        }
11661    }
11662
11663    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
11664    /// a pull response per section; an unapplied delta is a wake-up.
11665    /// Mailbox hosts send durable acknowledgements on their I/O executor.
11666    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
11667        let mut transport = crate::round::DownloadResults::default();
11668        self.on_realtime_binary(&mut transport, bytes);
11669        transport.controls
11670    }
11671
11672    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11673        if self.stopped || !self.transport_enabled || self.security_preflight {
11674            return;
11675        }
11676        #[cfg(feature = "bench-internals")]
11677        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11678        let message = match decode_message(bytes) {
11679            Ok(m) if m.msg_kind == MsgKind::Response => m,
11680            _ => {
11681                self.set_sync_needed(true, true);
11682                return;
11683            }
11684        };
11685        #[cfg(feature = "bench-internals")]
11686        drop(decode_phase);
11687        let coalesced_deletes =
11688            coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
11689                if status != SubStatus::Active {
11690                    return false;
11691                }
11692                let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
11693                    return false;
11694                };
11695                sub.takes_delta() && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
11696            });
11697        let mut frames = message.frames.into_iter();
11698        let mut applied_cursor: Option<i64> = None;
11699        let mut any_covered = false;
11700        let mut dropped = self.begin_coalesced_apply().is_err();
11701        while let Some(frame) = frames.next() {
11702            let Frame::SubStart {
11703                id,
11704                status,
11705                effective_scopes,
11706                ..
11707            } = frame
11708            else {
11709                continue;
11710            };
11711            let mut body = Vec::new();
11712            let mut next_cursor: Option<i64> = None;
11713            for inner in frames.by_ref() {
11714                match inner {
11715                    Frame::SubEnd {
11716                        next_cursor: nc, ..
11717                    } => {
11718                        next_cursor = Some(nc);
11719                        break;
11720                    }
11721                    Frame::Unknown { .. } => {}
11722                    other => body.push(other),
11723                }
11724            }
11725            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11726                dropped = true;
11727                continue;
11728            };
11729            let sub = &self.subs[sub_index];
11730            // §8.2: skipped sections are repaired by the next pull.
11731            if status != SubStatus::Active || !sub.takes_delta() {
11732                dropped = true;
11733                continue;
11734            }
11735            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11736                // Idempotent redelivery of an already-covered window.
11737                any_covered = true;
11738                continue;
11739            }
11740            let previous_effective = self.subs[sub_index].effective.clone();
11741            let previous_cursor = self.subs[sub_index].cursor;
11742            self.subs[sub_index].effective = Some(effective_scopes);
11743            let mut failed = false;
11744            for inner in body {
11745                match inner {
11746                    Frame::Commit {
11747                        tables,
11748                        changes,
11749                        commit_seq,
11750                        ..
11751                    } => {
11752                        if self
11753                            .apply_commit_frame_coalesced(
11754                                &tables,
11755                                changes,
11756                                commit_seq,
11757                                &coalesced_deletes,
11758                                sub_index,
11759                                AppliedServerCommitSource::Delta,
11760                            )
11761                            .is_err()
11762                        {
11763                            failed = true;
11764                            break;
11765                        }
11766                    }
11767                    _ => {
11768                        failed = true;
11769                        break;
11770                    }
11771                }
11772            }
11773            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11774                self.apply_sub_end(sub_index, cursor, None)
11775                    .ok()
11776                    .map(|()| cursor)
11777            });
11778            let Some(next_cursor) = committed else {
11779                self.subs[sub_index].effective = previous_effective;
11780                self.subs[sub_index].cursor = previous_cursor;
11781                dropped = true;
11782                continue;
11783            };
11784            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11785        }
11786        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
11787        // the cursors, so the delta counts as a wake-up.
11788        if self.finish_coalesced_apply().is_err() {
11789            applied_cursor = None;
11790            dropped = true;
11791        }
11792        if let Some(cursor) = applied_cursor {
11793            // §8.2 ack point: the highest applied SUB_END.nextCursor.
11794            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11795            let _ = transport.realtime_send(&ack);
11796        }
11797        if dropped || (applied_cursor.is_none() && !any_covered) {
11798            // §8.2: a skipped section or an unapplied delta is a wake-up.
11799            self.set_sync_needed(true, true);
11800        }
11801    }
11802
11803    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
11804    /// cursor across active, non-bootstrapping subscriptions that have
11805    /// synced at least once. No such subscription, no ack.
11806    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11807        if !self.realtime_connected() {
11808            return;
11809        }
11810        let floor = self
11811            .subs
11812            .iter()
11813            .filter(|s| {
11814                s.state == SubState::Active
11815                    && s.bootstrap_state.is_none()
11816                    && s.synced_once
11817                    && s.cursor >= 0
11818            })
11819            .map(|s| s.cursor)
11820            .min();
11821        if let Some(cursor) = floor {
11822            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11823            let _ = transport.realtime_send(&ack);
11824        }
11825    }
11826}
11827
11828#[cfg(test)]
11829mod previous_version_wiring_tests;
11830
11831#[cfg(test)]
11832mod scope_move_delivery_tests;
11833
11834#[cfg(test)]
11835mod insert_intent_tests;
11836
11837#[cfg(test)]
11838mod robustness_tests;