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::{
26    FencedRecord, Frame, LiveStatus, MediaType, Message, MsgKind, Op, OpResult, PushStatus,
27    SubStatus,
28};
29use ssp2::primitives::RawJson;
30use ssp2::registration::canonical_scope_json;
31use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
32use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
33
34use crate::api::{
35    AppliedServerCommit, AppliedServerCommitSource, ClientChangeBatch, ClientDiagnosticsHost,
36    ClientDiagnosticsLease, ClientDiagnosticsReplica, ClientDiagnosticsSchema,
37    ClientDiagnosticsSnapshot, ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects,
38    CommitDelivery, CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
39    CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
40    CurrentBaseProof, CurrentBaseProofReason, DiagnosticLastChange, DiagnosticLastRound,
41    DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticUnits, EchoRecord, FetchedBlob,
42    LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
43    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
44    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
45    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
46    RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SyncIntent, SyncOutcome,
47    SyncReport, SyncStatusSnapshot, TableChange, UnitCatchup, UnitRecord, UnitScopes, UnitStatus,
48    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
49    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_QUERY_FAILURES,
50};
51use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
52#[cfg(feature = "bench-internals")]
53use crate::bench::{Phase, Recorder};
54use crate::previous_version::{
55    capture_previous_version_from_replica, first_incompatibility,
56    reconcile_previous_version_at_boot, set_local_schema_descriptor,
57    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
58    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
59};
60use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
61use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
62use crate::values::{
63    bytes_to_hex, column_value_to_json, decode_row_bytes, encode_sparse_row_json, full_row_values,
64    is_canonical_bytes_envelope, json_to_column_value, json_to_scope_map, normalize_values_casing,
65    render_row_id_json, scope_map_to_json,
66};
67
68/// §4.2 default: rows and SQLite images, both applied in committed chunks.
69/// Bit 3 (signed URLs, §5.4) is
70/// added per transport capability at request-build time.
71const DEFAULT_ACCEPT: u8 = 0b0111;
72const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
73const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
74const ACCEPT_SQLITE: u8 = 1 << 2;
75const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
76const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
77
78/// §10.1 `recommendedAction` carried by a server HTTP error body.
79fn recommended_action_of(details: &Option<Value>) -> Option<String> {
80    details
81        .as_ref()?
82        .get("recommendedAction")?
83        .as_str()
84        .map(str::to_owned)
85}
86
87/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
88pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
89const LOCAL_REVISION_KEY: &str = "localRevision";
90/// §4.5 replica cursor; absent means `-1`.
91const REPLICA_CURSOR_KEY: &str = "replicaCursor";
92/// Hex of the last pull `LIVE_END.echoDigest` (§4.2); absent means empty.
93const ECHO_DIGEST_KEY: &str = "echoDigest";
94/// Hex of the registration digest of the list the server stores (§4.1).
95const REGISTERED_DIGEST_KEY: &str = "registrationDigest";
96/// §7.1: the identity of the outbox sequence.
97const OUTBOX_ID_KEY: &str = "outboxId";
98const CLIENT_ID_KEY: &str = "clientId";
99const LEASE_STATE_KEY: &str = "leaseState";
100const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
101pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
102/// Persisted fail-closed quarantine marker: present while a security preflight
103/// is pending and `activateSecurity` has yet to run. Storing it in the replica
104/// keeps the gate with the data it protects, so a rebuilt host handle reopening
105/// the same file returns in preflight even though its in-memory state is fresh.
106const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
107const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
108const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
109/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
110/// the new schema after a bump. Never a wire code (§10.3).
111const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
112/// §5.11 client-local code: an encode at the push seam resolved no usable key
113/// id or named an unknown key. Never a wire code (§10.3).
114const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
115const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
116/// §7.4.4: static text for a persisted commit the current codec refuses.
117const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
118    "the persisted commit carries values the current codec refuses";
119/// §7.4.4: bounded reason on every intrinsic value drop.
120const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
121
122#[derive(Debug, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124struct PersistedLocalDataRebootstrapReceipt {
125    version: u8,
126    retained_commits: u64,
127    reset_subscriptions: u64,
128}
129
130fn invalid_local_rebootstrap_receipt() -> String {
131    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
132}
133
134fn encode_local_rebootstrap_receipt(
135    retained_commits: usize,
136    reset_subscriptions: usize,
137) -> Result<String, String> {
138    let retained_commits =
139        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
140    let reset_subscriptions =
141        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
142    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
143        return Err(invalid_local_rebootstrap_receipt());
144    }
145    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
146        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
147        retained_commits,
148        reset_subscriptions,
149    })
150    .map_err(|_| invalid_local_rebootstrap_receipt())
151}
152
153fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
154    // Pre-0.15.36 markers proved application but did not retain the receipt.
155    if value == "v1" {
156        return Ok((0, 0));
157    }
158    let receipt: PersistedLocalDataRebootstrapReceipt =
159        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
160    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
161        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
162        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
163    {
164        return Err(invalid_local_rebootstrap_receipt());
165    }
166    Ok((
167        usize::try_from(receipt.retained_commits)
168            .map_err(|_| invalid_local_rebootstrap_receipt())?,
169        usize::try_from(receipt.reset_subscriptions)
170            .map_err(|_| invalid_local_rebootstrap_receipt())?,
171    ))
172}
173
174#[cfg(test)]
175mod observation_tests;
176
177/// Internal state for hosts that perform network I/O outside the client owner.
178#[doc(hidden)]
179pub struct CurrentBasePreparation {
180    client_commit_id: String,
181    sequence: i64,
182    units: Vec<UnitRecord>,
183    transport_epoch: u64,
184    registration_epoch: u64,
185    security_epoch: u64,
186}
187
188#[cfg(test)]
189mod units_tests;
190
191#[derive(Debug, Clone)]
192struct OutboxOp {
193    upsert: bool,
194    table: String,
195    row_id: String,
196    base_version: Option<i64>,
197    /// Schema-agnostic local form (§0): driver JSON values, encoded with
198    /// the current codec at send time. A key's presence IS the §6.1 sparse
199    /// presence set — both the overlay and the wire payload derive from it.
200    values: Option<Map<String, Value>>,
201    /// §7.1 insert intent: `mutate` recorded this upsert while the row was
202    /// absent from the visible replica. Replay and send-time encoding present
203    /// every current-schema column; a column `values` lacks is NULL.
204    insert: bool,
205}
206
207impl OutboxOp {
208    /// The values this upsert presents under the current `table` (§7.1). An
209    /// insert-intent operation presents every column, a missing one as NULL;
210    /// `None` when a missing column is non-nullable, which leaves the commit
211    /// unencodable (§7.4.4), or when the operation carries no values.
212    fn presented_values(&self, table: &TableSchema) -> Option<Cow<'_, Map<String, Value>>> {
213        let values = self.values.as_ref()?;
214        if !self.insert || table.columns.iter().all(|c| values.contains_key(&c.name)) {
215            return Some(Cow::Borrowed(values));
216        }
217        full_row_values(table, values.clone()).ok().map(Cow::Owned)
218    }
219}
220
221impl From<&OutboxOp> for CommitOperation {
222    fn from(operation: &OutboxOp) -> Self {
223        Self {
224            table: operation.table.clone(),
225            row_id: operation.row_id.clone(),
226            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
227            base_version: operation.base_version,
228            values: operation.values.clone(),
229            insert: operation.insert,
230        }
231    }
232}
233
234#[derive(Debug, Clone)]
235struct OutboxCommit {
236    client_commit_id: String,
237    ops: Vec<OutboxOp>,
238}
239
240/// One commit's `(table, value keys, insert intent)` per upsert operation — the classification
241/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
242/// can never disagree about which commits cannot re-encode.
243fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>, bool)> {
244    commit
245        .ops
246        .iter()
247        .filter(|operation| operation.upsert)
248        .map(|operation| {
249            (
250                operation.table.as_str(),
251                operation
252                    .values
253                    .as_ref()
254                    .map(|values| values.keys().map(String::as_str).collect())
255                    .unwrap_or_default(),
256                operation.insert,
257            )
258        })
259        .collect()
260}
261
262type FailedCommit = (OutboxCommit, Map<String, Value>);
263
264#[derive(Debug, Clone)]
265struct CompiledLocalDataPurgeTarget {
266    table: String,
267    selectors: Vec<(String, Vec<String>)>,
268}
269
270struct StoredCommitOutcomeRow {
271    sequence: i64,
272    client_commit_id: String,
273    status: String,
274    recorded_at_ms: i64,
275    results_json: String,
276    operations_json: Option<String>,
277    resolution: String,
278    resolved_at_ms: Option<i64>,
279    replacement_client_commit_id: Option<String>,
280}
281
282impl StoredCommitOutcomeRow {
283    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
284        Ok(Self {
285            sequence: row.get(0)?,
286            client_commit_id: row.get(1)?,
287            status: row.get(2)?,
288            recorded_at_ms: row.get(3)?,
289            results_json: row.get(4)?,
290            operations_json: row.get(5)?,
291            resolution: row.get(6)?,
292            resolved_at_ms: row.get(7)?,
293            replacement_client_commit_id: row.get(8)?,
294        })
295    }
296}
297
298/// Section outcome distinguishing the §5.6 unit-local fail-closed
299/// path from a round-aborting failure (§1.4 rule 5).
300/// §4.4 a unit section's `SUB_START`.
301struct SectionStart {
302    id: String,
303    status: SubStatus,
304    reason_code: String,
305    bootstrap: bool,
306    fenced: Vec<FencedRecord>,
307}
308
309enum SectionError {
310    FailClosed,
311    Abort(String, String),
312    Transport(TransportError),
313}
314
315#[derive(Debug)]
316pub(crate) struct RequestMeta {
317    pushed_ids: Vec<String>,
318    /// Hex of the registration digest this pull carried; the server stores
319    /// that list once the round completes (§4.1).
320    registration_digest: String,
321    pub(crate) accept: u8,
322    /// §6.1 splitBatch: outbox commits held back from THIS request because
323    /// the running operation count reached the push cap — the next round
324    /// pushes them (`sync_needed` stays set while any remain).
325    deferred_commits: usize,
326}
327
328#[derive(Default)]
329struct ChangeAccumulator {
330    /// None means table-wide; Some is the exact scoped domain.
331    tables: BTreeMap<String, Option<BTreeSet<String>>>,
332    windows: BTreeMap<String, BTreeSet<String>>,
333    status: bool,
334    conflicts: bool,
335    rejections: bool,
336    outcomes: bool,
337}
338
339impl ChangeAccumulator {
340    fn table(&mut self, table: &str) {
341        self.tables.insert(table.to_owned(), None);
342    }
343
344    fn scope(&mut self, table: &str, key: String) {
345        match self.tables.get_mut(table) {
346            Some(None) => {}
347            Some(Some(keys)) => {
348                keys.insert(key);
349            }
350            None => {
351                self.tables
352                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
353            }
354        }
355    }
356
357    fn window(&mut self, base_key: &str, value: &str) {
358        self.windows
359            .entry(base_key.to_owned())
360            .or_default()
361            .insert(value.to_owned());
362    }
363
364    fn touched(&self) -> bool {
365        !self.tables.is_empty()
366            || !self.windows.is_empty()
367            || self.status
368            || self.conflicts
369            || self.rejections
370            || self.outcomes
371    }
372}
373
374/// §6.1: the server caps total operations per request (reference default
375/// 500) and rejects the whole batch with `sync.too_many_operations`; the
376/// client "splits and retries". Splitting happens at build time: commits are
377/// included IN ORDER until the operation budget is spent, the rest wait for
378/// the next round.
379const PUSH_OPS_PER_REQUEST: usize = 500;
380const MAX_LOCAL_PURGE_TARGETS: usize = 64;
381const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
382const MAX_LOCAL_PURGE_VALUES: usize = 128;
383const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
384pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
385
386/// Dirty visible tables; `None` means a whole-replica recovery/reset.
387struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
388
389impl OverlayDirty {
390    fn clean() -> Self {
391        Self(RefCell::new(Some(BTreeSet::new())))
392    }
393    fn get(&self) -> bool {
394        self.0
395            .borrow()
396            .as_ref()
397            .is_none_or(|tables| !tables.is_empty())
398    }
399    fn set(&self, dirty: bool) {
400        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
401    }
402    fn table(&self, table: &str) {
403        if let Some(tables) = self.0.borrow_mut().as_mut() {
404            tables.insert(table.to_owned());
405        }
406    }
407    fn snapshot(&self) -> Option<BTreeSet<String>> {
408        self.0.borrow().clone()
409    }
410    fn restore(&self, tables: Option<BTreeSet<String>>) {
411        *self.0.borrow_mut() = tables;
412    }
413}
414
415/// A copy-on-write in-memory mirror: a [`Rollback`] shares it, and the first
416/// write after a capture copies it once.
417#[derive(Debug, Clone, Default)]
418struct Shared<T>(Arc<T>);
419
420impl<T> std::ops::Deref for Shared<T> {
421    type Target = T;
422    fn deref(&self) -> &T {
423        &self.0
424    }
425}
426
427impl<T: Clone> std::ops::DerefMut for Shared<T> {
428    fn deref_mut(&mut self) -> &mut T {
429        Arc::make_mut(&mut self.0)
430    }
431}
432
433impl<T> From<T> for Shared<T> {
434    fn from(value: T) -> Self {
435        Self(Arc::new(value))
436    }
437}
438
439/// The in-memory mirrors of durable state, captured before a local
440/// transaction and restored when it rolls back. Queues only grow inside a
441/// transaction, so their lengths suffice.
442#[derive(Debug, Clone)]
443struct Rollback {
444    outbox: Shared<Vec<Arc<OutboxCommit>>>,
445    failed_commits: Shared<Vec<FailedCommit>>,
446    conflicts: Shared<Vec<ConflictRecord>>,
447    rejections: Shared<Vec<RejectionRecord>>,
448    schema_floor: Option<SchemaFloor>,
449    lease_state: Option<LeaseState>,
450    stopped: bool,
451    upgrading: bool,
452    sync_needed: bool,
453    active_round: Option<uuid::Uuid>,
454    overlay_dirty: Option<BTreeSet<String>>,
455    change_queue: usize,
456    sync_intent_queue: usize,
457    last_change: Option<DiagnosticLastChange>,
458}
459
460pub struct SyncClient {
461    #[cfg(feature = "bench-internals")]
462    benchmark_phases: Recorder,
463    conn: Connection,
464    schema: ClientSchema,
465    client_id: String,
466    limits: ClientLimits,
467    outbox: Shared<Vec<Arc<OutboxCommit>>>,
468    failed_commits: Shared<Vec<FailedCommit>>,
469    retain_failed_commits: bool,
470    conflicts: Shared<Vec<ConflictRecord>>,
471    rejections: Shared<Vec<RejectionRecord>>,
472    schema_floor: Option<SchemaFloor>,
473    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
474    lease_state: Option<LeaseState>,
475    /// §1.6: the schema-floor response stops syncing until an upgrade.
476    stopped: bool,
477    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
478    upgrading: bool,
479    /// §8.4 coalesced sync-needed signal.
480    sync_needed: bool,
481    /// §8.8: how sync rounds treat the realtime binding, and its exact
482    /// availability state with the evidence behind a lost or refused one.
483    realtime_policy: RealtimePolicy,
484    realtime_state: RealtimeState,
485    realtime_reason_code: Option<String>,
486    realtime_retry_delay_ms: Option<u64>,
487    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
488    presence: HashMap<String, HashMap<String, PresencePeer>>,
489    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
490    /// host may pin it (conformance runs on a virtual clock).
491    now_ms: Option<i64>,
492    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
493    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
494    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
495    encryption: crate::values::EncryptionConfig,
496    /// Fail-closed host bootstrap gate. While set, command hosts permit only
497    /// status/lifecycle inspection and an exact authorized local purge.
498    security_preflight: bool,
499    security_epoch: u64,
500    transport_enabled: bool,
501    transport_epoch: u64,
502    /// Raised by every registration change; a current-base proof spanning
503    /// one reports missing coverage.
504    registration_epoch: u64,
505    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
506    /// means the feature is off; the descriptor write and the container orphan
507    /// sweep still happen.
508    previous_version: Option<PreviousVersionContextConfig>,
509    /// Per-table primary-key upsert SQL, built once per (full table name) —
510    /// the row write path runs per row during bootstrap (§5.6), so the SQL
511    /// string (and, via `prepare_cached`, its compiled statement) is reused
512    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
513    /// schema reset (the column lists may have changed).
514    insert_sql: RefCell<HashMap<String, String>>,
515    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
516    /// reset and restart explicitly invalidate every table.
517    overlay_dirty: OverlayDirty,
518    // First SQLite failure of the current operation, before savepoint cleanup.
519    storage_failure: RefCell<Option<QueryReadFailure>>,
520    /// Test-only structural performance signal: response processing must not
521    /// turn a batch of acknowledgements into one full overlay rebuild each.
522    #[cfg(test)]
523    overlay_rebuild_count: Cell<usize>,
524    #[cfg(test)]
525    acknowledged_replay_count: Cell<usize>,
526    /// Test-only structural performance signal: outcome retention is enforced
527    /// once per response rather than once per acknowledgement.
528    #[cfg(test)]
529    outcome_prune_count: Cell<usize>,
530    /// Exact observer-transaction output drained by command/FFI hosts.
531    progress: ProgressObserver,
532    change_queue: VecDeque<ClientChangeBatch>,
533    sync_intent_queue: VecDeque<SyncIntent>,
534    /// Explicit exponential retry policy for transient transport failures.
535    retry_delay_ms: u64,
536    /// §7.7: the background retry the current round scheduled, if any.
537    round_retry_delay_ms: Option<u64>,
538    /// A round whose response never completed (§1.5): the client keeps its
539    /// code and reports it on every following request until a round receives a
540    /// complete, decodable response. The server already recorded the round, so
541    /// nothing else can tell it the round failed on this side.
542    round_failure: Option<String>,
543    active_round: Option<uuid::Uuid>,
544    last_round: Option<DiagnosticLastRound>,
545    last_change: Option<DiagnosticLastChange>,
546    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
547    query_failures: Vec<DiagnosticQueryFailure>,
548    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
549    coalesced: Option<CoalescedApply>,
550    /// A coalesced apply transaction of the current response failed; every
551    /// later observation of that response fails until it ends.
552    coalesced_failure: Option<String>,
553}
554
555/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
556/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
557/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
558struct CoalescedApply {
559    rows: usize,
560    rollback: Rollback,
561}
562
563const COALESCED_APPLY_ROWS: usize = 1024;
564
565pub(crate) fn quote_ident(name: &str) -> String {
566    format!("\"{}\"", name.replace('"', "\"\""))
567}
568/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
569/// previous-version module reads and writes the SAME records as the client
570/// instead of growing a parallel meta layer.
571pub(crate) fn meta_get(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
572    conn.query_row(
573        "SELECT value FROM _syncular_meta WHERE key = ?1",
574        rusqlite::params![key],
575        |row| row.get::<_, String>(0),
576    )
577    .optional()
578}
579
580pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
581    conn.execute(
582        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
583        rusqlite::params![key, value],
584    )
585    .map(|_| ())
586}
587
588pub(crate) fn meta_delete(conn: &Connection, key: &str) -> rusqlite::Result<()> {
589    conn.execute(
590        "DELETE FROM _syncular_meta WHERE key = ?1",
591        rusqlite::params![key],
592    )
593    .map(|_| ())
594}
595
596/// Seek by the existing key while retaining the exact legacy text match.
597fn row_id_predicate(table: &TableSchema) -> String {
598    let key = quote_ident(&table.primary_key);
599    let text_match = format!("CAST({key} AS TEXT) = ?1");
600    match table.columns[table.pk_index].ty {
601        ColumnType::String | ColumnType::Json => {
602            format!("{key} = ?1 AND {text_match}")
603        }
604        // Native columns have no declared SQLite affinity. Match the stored
605        // integer bind type explicitly. Unary + removes expression affinity so
606        // SQLite can seek the typeless key; the text check preserves exact IDs.
607        ColumnType::Integer | ColumnType::Boolean => {
608            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
609        }
610        // REAL-to-text conversion can round distinct values to the same text.
611        // Narrowing those matches by numeric equality would change behavior.
612        _ => text_match,
613    }
614}
615
616/// §3.3/§4.8/§5.6 row ownership: rows whose local scope columns match
617/// `effective` and that no entry of `owners` matches. `owners` holds the
618/// pair scopes of every owner of the table (§3.3 row ownership); a scoped delete never removes a row one of them still covers.
619/// Scope values bind by the column's declared type, as `row_id_predicate`
620/// does. `Ok(None)` matches nothing (an empty map or value list); `Err(())`
621/// is a missing local scope-column mapping (the fail-closed case).
622fn local_scope_predicate(
623    table: &TableSchema,
624    effective: &[(String, Vec<String>)],
625    owners: &[Vec<(String, Vec<String>)>],
626) -> Result<Option<(String, Vec<SqlValue>)>, ()> {
627    if effective.is_empty() || effective.iter().any(|(_, values)| values.is_empty()) {
628        return Ok(None);
629    }
630    let mut clauses = Vec::new();
631    let mut params = Vec::new();
632    for (variable, values) in effective {
633        let name = table.scope_column(variable).ok_or(())?;
634        let column = table
635            .columns
636            .iter()
637            .find(|column| column.name == name)
638            .ok_or(())?;
639        let key = quote_ident(name);
640        let holes = vec!["?"; values.len()].join(", ");
641        clauses.push(if column.ty == ColumnType::Integer {
642            params.extend(values.iter().cloned().map(SqlValue::Text));
643            let seek = vec!["+CAST(? AS INTEGER)"; values.len()].join(", ");
644            format!("{key} IN ({seek}) AND CAST({key} AS TEXT) IN ({holes})")
645        } else {
646            format!("{key} IN ({holes})")
647        });
648        params.extend(values.iter().cloned().map(SqlValue::Text));
649    }
650    for owner in owners {
651        if let Some((sql, owner_params)) = local_scope_predicate(table, owner, &[])? {
652            clauses.push(format!("NOT ({sql})"));
653            params.extend(owner_params);
654        }
655    }
656    Ok(Some((clauses.join(" AND "), params)))
657}
658
659/// [`local_scope_predicate`] over decoded row values (retained rows).
660fn owns_local_row(
661    table: &TableSchema,
662    effective: &[(String, Vec<String>)],
663    owners: &[Vec<(String, Vec<String>)>],
664    values: &Map<String, Value>,
665) -> bool {
666    let matches = |scopes: &[(String, Vec<String>)]| {
667        !scopes.is_empty()
668            && scopes.iter().all(|(variable, allowed)| {
669                table
670                    .scope_column(variable)
671                    .and_then(|column| values.get(column))
672                    .filter(|value| !value.is_null())
673                    .is_some_and(|value| {
674                        allowed.contains(
675                            &value
676                                .as_str()
677                                .map_or_else(|| value.to_string(), str::to_owned),
678                        )
679                    })
680            })
681    };
682    matches(effective) && !owners.iter().any(|owner| matches(owner))
683}
684
685fn is_local_operation_code_like(value: &str) -> bool {
686    let bytes = value.as_bytes();
687    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
688        && bytes
689            .iter()
690            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
691}
692
693fn base_table(name: &str) -> String {
694    quote_ident(&format!("_syncular_base_{name}"))
695}
696
697/// §4.8 deferred eviction record: (unit id, table, effective scope map).
698type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
699
700/// §4.8: a stable key for a window base: variable + canonical fixed scopes.
701fn window_base_key(base: &WindowBase) -> String {
702    format!(
703        "{}\0{}",
704        base.variable,
705        ssp2::registration::canonical_unit_json(&scope_pairs(&base.fixed_scopes))
706    )
707}
708
709/// The unit of one window value: the fixed scopes plus the value.
710fn window_unit_scopes(base: &WindowBase, value: &str) -> UnitScopes {
711    let mut scopes = base.fixed_scopes.clone();
712    scopes.insert(base.variable.clone(), value.to_owned());
713    scopes
714}
715
716/// A unit's scope map as the codec and the digests take it.
717fn scope_pairs(scopes: &UnitScopes) -> Vec<(String, String)> {
718    let mut pairs: Vec<(String, String)> = scopes
719        .iter()
720        .map(|(variable, value)| (variable.clone(), value.clone()))
721        .collect();
722    pairs.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));
723    pairs
724}
725
726/// §4.1: the unit covers every table declaring all of its variables.
727fn covers(table: &TableSchema, scopes: &UnitScopes) -> bool {
728    scopes
729        .keys()
730        .all(|variable| table.scope_column(variable).is_some())
731}
732
733/// §4.4: the effective scopes of the (unit, table) pair under `echo`: the
734/// scope map as one-element lists plus the fenced lists. `None` when the
735/// fence revoked the pair.
736fn pair_scopes(
737    scopes: &UnitScopes,
738    table: &str,
739    echo: Option<&[EchoRecord]>,
740) -> Option<Vec<(String, Vec<String>)>> {
741    let fenced = echo.and_then(|echo| echo.iter().find(|record| record.table == table));
742    if fenced.is_some_and(|record| record.status == "revoked") {
743        return None;
744    }
745    let mut effective: BTreeMap<String, Vec<String>> = fenced
746        .map(|record| record.scopes.clone())
747        .unwrap_or_default();
748    for (variable, value) in scopes {
749        effective.insert(variable.clone(), vec![value.clone()]);
750    }
751    Some(effective.into_iter().collect())
752}
753
754/// §3.3 row ownership: the pair scopes of every owner of `table` except
755/// `except_id`. An owner is active with a persisted echo and live or
756/// post-bootstrap, or bootstrapping with a segment of the table in its
757/// current bootstrap.
758fn unit_owners(
759    units: &[UnitRecord],
760    table: &TableSchema,
761    except_id: &str,
762) -> Vec<Vec<(String, Vec<String>)>> {
763    units
764        .iter()
765        .filter(|unit| {
766            unit.id != except_id
767                && unit.status == UnitStatus::Active
768                && unit.echo.is_some()
769                && (!unit.bootstrapping() || unit.seeded.contains(&table.name))
770                && covers(table, &unit.scopes)
771        })
772        .filter_map(|unit| pair_scopes(&unit.scopes, &table.name, unit.echo.as_deref()))
773        .collect()
774}
775
776/// §5.5 / §5.6: the unit's scope map, each value a one-element list.
777fn unit_scope_lists(scopes: &UnitScopes) -> Vec<(String, Vec<String>)> {
778    scopes
779        .iter()
780        .map(|(variable, value)| (variable.clone(), vec![value.clone()]))
781        .collect()
782}
783
784/// §7.1 acknowledged intent of the rows `?1` names (`[[table, id], ...]`).
785/// It joins through the (tbl, id) index: a scan per acknowledgement would
786/// make draining a batch quadratic.
787const ACKED_ROWS_BY_ROW: &str = "SELECT a.op_json,a.intent_json FROM json_each(?1) k JOIN _syncular_acked_rows a ON a.tbl=json_extract(k.value,'$[0]') AND a.id=json_extract(k.value,'$[1]') WHERE ?2 IS NULL OR a.tbl IN(SELECT value FROM json_each(?2)) ORDER BY a.commit_seq,a.idx";
788
789const UNIT_COLUMNS: &str =
790    "id, scopes, explicit, status, reason_code, cursor, bootstrap_state, seeded, echo";
791
792/// A unit row; a field that does not parse is a conversion failure, which
793/// both read paths classify as `sync.local_corrupt`.
794fn unit_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UnitRecord> {
795    fn json<T: serde::de::DeserializeOwned>(index: usize, raw: &str) -> rusqlite::Result<T> {
796        serde_json::from_str(raw).map_err(|error| {
797            rusqlite::Error::FromSqlConversionFailure(
798                index,
799                rusqlite::types::Type::Text,
800                Box::new(error),
801            )
802        })
803    }
804    Ok(UnitRecord {
805        id: row.get(0)?,
806        scopes: json(1, &row.get::<_, String>(1)?)?,
807        explicit: row.get::<_, i64>(2)? == 1,
808        status: json(
809            3,
810            &serde_json::to_string(&row.get::<_, String>(3)?).expect("text"),
811        )?,
812        reason_code: row.get(4)?,
813        cursor: row.get(5)?,
814        bootstrap_state: row.get(6)?,
815        seeded: json(7, &row.get::<_, String>(7)?)?,
816        echo: row
817            .get::<_, Option<String>>(8)?
818            .map(|echo| json::<Vec<EchoRecord>>(8, &echo))
819            .transpose()?
820            .map(|echo| {
821                if echo
822                    .iter()
823                    .all(|record| record.status == "active" || record.status == "revoked")
824                {
825                    Ok(echo)
826                } else {
827                    Err(rusqlite::Error::FromSqlConversionFailure(
828                        8,
829                        rusqlite::types::Type::Text,
830                        "echo status is neither active nor revoked".into(),
831                    ))
832                }
833            })
834            .transpose()?,
835    })
836}
837
838/// Every unit in registration order (§4.1).
839fn load_units(conn: &Connection) -> rusqlite::Result<Vec<UnitRecord>> {
840    conn.prepare_cached(&format!(
841        "SELECT {UNIT_COLUMNS} FROM _syncular_units ORDER BY rowid"
842    ))?
843    .query_map([], unit_from_row)?
844    .collect()
845}
846
847fn get_unit(conn: &Connection, id: &str) -> rusqlite::Result<Option<UnitRecord>> {
848    conn.query_row(
849        &format!("SELECT {UNIT_COLUMNS} FROM _syncular_units WHERE id = ?1"),
850        [id],
851        unit_from_row,
852    )
853    .optional()
854}
855
856/// Insert or update in place: an update keeps the registration order.
857fn save_unit(conn: &Connection, unit: &UnitRecord) -> rusqlite::Result<()> {
858    conn.prepare_cached(&format!(
859        "INSERT INTO _syncular_units({UNIT_COLUMNS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)
860         ON CONFLICT(id) DO UPDATE SET explicit=excluded.explicit, status=excluded.status,
861           reason_code=excluded.reason_code, cursor=excluded.cursor,
862           bootstrap_state=excluded.bootstrap_state, seeded=excluded.seeded, echo=excluded.echo"
863    ))?
864    .execute(rusqlite::params![
865        unit.id,
866        serde_json::to_string(&unit.scopes).expect("scope map serializes"),
867        i64::from(unit.explicit),
868        unit.status.name(),
869        unit.reason_code,
870        unit.cursor,
871        unit.bootstrap_state,
872        serde_json::to_string(&unit.seeded).expect("table list serializes"),
873        unit.echo
874            .as_ref()
875            .map(|echo| serde_json::to_string(echo).expect("echo serializes")),
876    ])
877    .map(|_| ())
878}
879
880/// §4.1 references: every window registry entry `(base, value)` holding a unit.
881fn window_refs(conn: &Connection, unit_id: &str) -> rusqlite::Result<Vec<(String, String)>> {
882    conn.prepare_cached("SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1")?
883        .query_map([unit_id], |row| Ok((row.get(0)?, row.get(1)?)))?
884        .collect()
885}
886
887/// §7.4.3 reset, §2.1 log reset and local rebootstrap: keep the unit
888/// registrations and discard every synced state, so the next round sends the
889/// list and fresh-bootstraps every unit. The caller owns the transaction.
890fn reset_units(conn: &Connection) -> rusqlite::Result<()> {
891    conn.execute_batch(
892        "UPDATE _syncular_units SET status='active', reason_code=NULL, cursor=-1,
893           bootstrap_state=NULL, seeded='[]', echo=NULL;
894         DELETE FROM _syncular_meta WHERE key IN ('replicaCursor','echoDigest','registrationDigest');",
895    )
896}
897
898/// §4.5: the replica cursor, `-1` while no unit is live.
899fn replica_cursor(conn: &Connection) -> rusqlite::Result<i64> {
900    meta_get(conn, REPLICA_CURSOR_KEY)?.map_or(Ok(-1), |raw| {
901        raw.parse().map_err(|error| {
902            rusqlite::Error::FromSqlConversionFailure(
903                0,
904                rusqlite::types::Type::Text,
905                Box::new(error),
906            )
907        })
908    })
909}
910
911fn visible_table(name: &str) -> String {
912    quote_ident(name)
913}
914
915const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
916
917/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
918/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
919fn is_synced_table_name(name: &str) -> bool {
920    if name.starts_with("sqlite_") {
921        return false;
922    }
923    if name.starts_with("_syncular_base_") {
924        return true; // the base half of a synced table pair
925    }
926    // Bookkeeping: outbox, units, meta, blob cache/uploads.
927    !name.starts_with("_syncular_")
928}
929
930/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
931fn blob_id_for(bytes: &[u8]) -> String {
932    let digest = Sha256::digest(bytes);
933    format!("sha256:{}", bytes_to_hex(&digest))
934}
935
936/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
937/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
938/// per row on the §5.6 bootstrap path), scalars bind owned.
939enum RowParam<'a> {
940    Cell(&'a Option<ColumnValue>),
941    Version(i64),
942}
943
944impl rusqlite::ToSql for RowParam<'_> {
945    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
946        Ok(match self {
947            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
948            RowParam::Cell(cell) => match cell {
949                None => ToSqlOutput::Owned(SqlValue::Null),
950                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
951                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
952                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
953                Some(ColumnValue::Boolean(b)) => {
954                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
955                }
956                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
957                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
958                }
959                // §5.10: crdt bytes store as BLOB, like bytes.
960                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
961                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
962                }
963            },
964        })
965    }
966}
967
968// Retain a typed primary key across the current import chunk without copying row payloads.
969fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
970    match param.to_sql().map_err(|error| error.to_string())? {
971        ToSqlOutput::Owned(value) => Ok(value),
972        ToSqlOutput::Borrowed(value) => {
973            SqlValue::try_from(value).map_err(|error| error.to_string())
974        }
975        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
976    }
977}
978
979/// §5.3 image cell → bind parameter, strict per the declared column type
980/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
981/// nullable). Mismatches are image-producer violations. Returns the cell
982/// borrowed when the stored representation already matches what the row
983/// codec would write, or the normalized scalar (boolean → 0/1, float from
984/// INTEGER → REAL) otherwise — the local write is byte-identical to the
985/// old convert-then-insert path without allocating per cell.
986fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
987    use ssp2::segment::ColumnType;
988    let mismatch = || {
989        Err(format!(
990            "image column {:?} holds a value of the wrong type",
991            column.name
992        ))
993    };
994    match value {
995        ValueRef::Null => {
996            if !column.nullable {
997                return Err(format!(
998                    "image column {:?} is NULL but not nullable",
999                    column.name
1000                ));
1001            }
1002            Ok(ToSqlOutput::Owned(SqlValue::Null))
1003        }
1004        ValueRef::Integer(i) => match column.ty {
1005            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
1006            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
1007            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
1008            _ => mismatch(),
1009        },
1010        ValueRef::Real(_) => match column.ty {
1011            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
1012            _ => mismatch(),
1013        },
1014        ValueRef::Text(t) => {
1015            std::str::from_utf8(t)
1016                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
1017            match column.ty {
1018                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
1019                    Ok(ToSqlOutput::Borrowed(value))
1020                }
1021                _ => mismatch(),
1022            }
1023        }
1024        ValueRef::Blob(_) => match column.ty {
1025            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
1026            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
1027            _ => mismatch(),
1028        },
1029    }
1030}
1031
1032pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
1033    use rusqlite::types::ValueRef;
1034    match value {
1035        ValueRef::Null => Value::Null,
1036        ValueRef::Integer(i) => match column.ty {
1037            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
1038            ssp2::segment::ColumnType::Float => {
1039                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
1040            }
1041            _ => Value::from(i),
1042        },
1043        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
1044        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
1045        ValueRef::Blob(b) => {
1046            let mut map = Map::new();
1047            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
1048            Value::Object(map)
1049        }
1050    }
1051}
1052
1053/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
1054/// Objects are accepted in lossless `{"$bytes": hex}` and
1055/// `{"$bigint": decimal}` envelope forms.
1056fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
1057    Ok(match value {
1058        Value::Null => SqlValue::Null,
1059        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
1060        Value::Number(n) => {
1061            if let Some(i) = n.as_i64() {
1062                SqlValue::Integer(i)
1063            } else if let Some(f) = n.as_f64() {
1064                SqlValue::Real(f)
1065            } else {
1066                return Err(format!("query param number {n} is out of range"));
1067            }
1068        }
1069        Value::String(s) => SqlValue::Text(s.clone()),
1070        Value::Object(_) => {
1071            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
1072                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
1073            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
1074                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
1075                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
1076                })?)
1077            } else {
1078                return Err(
1079                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
1080                        .to_owned(),
1081                );
1082            }
1083        }
1084        Value::Array(_) => return Err("query array params are not supported".to_owned()),
1085    })
1086}
1087
1088/// Map a rusqlite value with no schema column to consult (arbitrary query
1089/// output): integers/reals/text pass through by stored affinity, blobs ride
1090/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
1091/// schema column type to recover booleans/floats/json.
1092fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
1093    use rusqlite::types::ValueRef;
1094    match value {
1095        ValueRef::Null => Value::Null,
1096        ValueRef::Integer(i) => {
1097            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
1098            // ordinary UI-sized integers ergonomic and envelope only values
1099            // beyond JavaScript's safe range.
1100            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
1101            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
1102                Value::from(i)
1103            } else {
1104                let mut map = Map::new();
1105                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
1106                Value::Object(map)
1107            }
1108        }
1109        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
1110        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
1111        ValueRef::Blob(b) => {
1112            let mut map = Map::new();
1113            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
1114            Value::Object(map)
1115        }
1116    }
1117}
1118
1119/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
1120fn query_connection(
1121    conn: &Connection,
1122    sql: &str,
1123    params: &[QueryValue],
1124) -> Result<Vec<QueryRow>, QueryReadFailure> {
1125    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
1126    let mut stmt = crate::query_guard::prepare_read_only(conn, &lowered_sql)?;
1127    let bound: Vec<SqlValue> = params
1128        .iter()
1129        .map(json_param_to_sql)
1130        .collect::<Result<_, _>>()?;
1131    // §2.4: an unaliased physical version column stays engine-internal, so a
1132    // `SELECT *` row feeds straight back into mutation values. An explicit
1133    // alias (`_sync_version AS v`) returns it as ordinary query data.
1134    let columns: Vec<(usize, String)> = stmt
1135        .column_names()
1136        .enumerate()
1137        .filter(|(_, name)| *name != "_syncular_version")
1138        .map(|(i, name)| (i, name.to_owned()))
1139        .collect();
1140    let bound_refs: Vec<&dyn rusqlite::ToSql> =
1141        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
1142    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
1143    let mut out = Vec::new();
1144    while let Some(row) = sql_rows.next()? {
1145        let mut record = Map::new();
1146        for (i, name) in &columns {
1147            let value = row.get_ref(*i)?;
1148            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
1149        }
1150        out.push(record);
1151    }
1152    Ok(out)
1153}
1154
1155fn persisted_window_state(
1156    conn: &Connection,
1157    base: &WindowBase,
1158) -> Result<WindowState, QueryReadFailure> {
1159    let mut stmt = conn.prepare(
1160        "SELECT windows.unit, units.status, units.cursor, units.bootstrap_state
1161           FROM _syncular_windows AS windows
1162           LEFT JOIN _syncular_units AS units ON units.id = windows.sub_id
1163          WHERE windows.base = ?1
1164          ORDER BY windows.unit ASC",
1165    )?;
1166    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
1167        Ok((
1168            row.get::<_, String>(0)?,
1169            row.get::<_, Option<String>>(1)?,
1170            row.get::<_, Option<i64>>(2)?,
1171            row.get::<_, Option<String>>(3)?,
1172        ))
1173    })?;
1174    let mut units = Vec::new();
1175    let mut pending = Vec::new();
1176    for row in rows {
1177        let (unit, status, cursor, bootstrap_state) = row?;
1178        // §4.8: complete iff registered, active and not bootstrapping.
1179        if status.as_deref() != Some("active")
1180            || cursor.is_some_and(|cursor| cursor < 0 || bootstrap_state.is_some())
1181        {
1182            pending.push(unit.clone());
1183        }
1184        units.push(unit);
1185    }
1186    Ok(WindowState { units, pending })
1187}
1188
1189/// One read-only statement in a §7.5 snapshot read.
1190#[derive(Debug, Clone, Copy)]
1191pub struct SnapshotStatement<'a> {
1192    pub sql: &'a str,
1193    pub params: &'a [Value],
1194}
1195
1196/// A §7.5 atomic read request: read-only statements, window coverage, unit
1197/// catch-up states, and commit deliveries, all resolved from one SQLite read
1198/// transaction and one revision.
1199#[derive(Debug, Clone, Default)]
1200pub struct SnapshotReadRequest<'a> {
1201    pub statements: Vec<SnapshotStatement<'a>>,
1202    pub coverage: &'a [WindowCoverage],
1203    pub units: Vec<String>,
1204    pub commit_ids: Vec<String>,
1205}
1206
1207#[cfg(test)]
1208thread_local! {
1209    /// Test-only rendezvous that runs between the statements of one snapshot
1210    /// read. A test uses it to commit on another connection mid-read and prove
1211    /// the read transaction's snapshot and revision stay stable.
1212    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
1213        std::cell::RefCell::new(None);
1214}
1215
1216#[cfg(test)]
1217fn run_snapshot_read_hook() {
1218    SNAPSHOT_READ_HOOK.with(|slot| {
1219        if let Some(hook) = slot.borrow_mut().take() {
1220            hook();
1221        }
1222    });
1223}
1224
1225/// §7.5: resolve a request over one SQLite read transaction. The revision,
1226/// every statement's rows, coverage, catch-up states, and deliveries are
1227/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
1228/// read back and releases the connection.
1229fn snapshot_read_connection(
1230    conn: &Connection,
1231    request: &SnapshotReadRequest<'_>,
1232) -> Result<SnapshotRead, QueryReadFailure> {
1233    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
1234    let corrupt = |message: &str| QueryReadFailure {
1235        code: Some("sync.local_corrupt"),
1236        sqlite_code: None,
1237        sqlite_message: None,
1238        rollback_failure: None,
1239        message: message.to_owned(),
1240    };
1241    let stored_failure = |error: rusqlite::Error, message: &str| match error {
1242        rusqlite::Error::Utf8Error(..)
1243        | rusqlite::Error::InvalidColumnType(..)
1244        | rusqlite::Error::FromSqlConversionFailure(..)
1245        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
1246        _ => QueryReadFailure::from(error),
1247    };
1248    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
1249        // The durable revision as a canonical decimal `u64`; a missing marker
1250        // is the legacy zero, and any other non-canonical value is corrupt.
1251        let revision: Option<String> = conn
1252            .query_row(
1253                "SELECT value FROM _syncular_meta WHERE key = ?1",
1254                rusqlite::params![LOCAL_REVISION_KEY],
1255                |row| row.get(0),
1256            )
1257            .optional()
1258            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
1259        let revision = match revision {
1260            None => "0".to_owned(),
1261            Some(value)
1262                if value == "0"
1263                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
1264                        && value.bytes().all(|b| b.is_ascii_digit())
1265                        && value.parse::<u64>().is_ok()) =>
1266            {
1267                value
1268            }
1269            Some(_) => return Err(corrupt("persisted local revision is invalid")),
1270        };
1271        let mut queries = Vec::with_capacity(request.statements.len());
1272        for statement in &request.statements {
1273            queries.push(query_connection(conn, statement.sql, statement.params)?);
1274            #[cfg(test)]
1275            run_snapshot_read_hook();
1276        }
1277        let mut pending = Vec::new();
1278        let mut missing = Vec::new();
1279        for requested in request.coverage {
1280            let base_key = window_base_key(&requested.base);
1281            let state = persisted_window_state(conn, &requested.base)?;
1282            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
1283                let reference = WindowUnitRef {
1284                    base_key: base_key.clone(),
1285                    unit: unit.clone(),
1286                };
1287                if !state.units.iter().any(|held| held == &unit) {
1288                    missing.push(reference);
1289                } else if state.pending.iter().any(|held| held == &unit) {
1290                    pending.push(reference);
1291                }
1292            }
1293        }
1294        let mut units = Vec::with_capacity(request.units.len());
1295        for id in &request.units {
1296            let exists: bool = conn.query_row(
1297                "SELECT EXISTS(SELECT 1 FROM _syncular_units WHERE id = ?1)",
1298                [id],
1299                |row| row.get(0),
1300            )?;
1301            if !exists {
1302                units.push(UnitCatchup::Unknown { id: id.clone() });
1303                continue;
1304            }
1305            // Every persisted field is required to be well typed; a corrupt
1306            // record fails typed instead of inventing a state.
1307            let unit = get_unit(conn, id)
1308                .map_err(|error| stored_failure(error, "persisted unit state is invalid"))?
1309                .ok_or_else(|| corrupt("persisted unit state is invalid"))?;
1310            let cursor = match unit.cursor {
1311                Some(cursor) => cursor,
1312                None => replica_cursor(conn)
1313                    .map_err(|error| stored_failure(error, "persisted unit state is invalid"))?,
1314            };
1315            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
1316                return Err(corrupt("persisted unit state is invalid"));
1317            }
1318            let active = unit.status == UnitStatus::Active;
1319            let pending = unit.bootstrapping();
1320            units.push(UnitCatchup::Known {
1321                id: id.clone(),
1322                status: unit.status,
1323                live: unit.cursor.is_none(),
1324                cursor,
1325                has_resume_token: unit.bootstrap_state.is_some(),
1326                bootstrap_complete: active && !pending,
1327                known_pending_pages: active && pending,
1328                reason_code: unit.reason_code,
1329            });
1330        }
1331        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
1332        for id in &request.commit_ids {
1333            let pending: bool = conn.query_row(
1334                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
1335                rusqlite::params![id],
1336                |row| row.get(0),
1337            )?;
1338            if pending {
1339                deliveries.push(CommitDelivery::Pending {
1340                    client_commit_id: id.clone(),
1341                });
1342                continue;
1343            }
1344            let row = conn
1345                .query_row(
1346                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
1347                            resolution, resolved_at_ms, replacement_client_commit_id
1348                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
1349                    rusqlite::params![id],
1350                    StoredCommitOutcomeRow::from_row,
1351                )
1352                .optional()
1353                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
1354            deliveries.push(match row {
1355                Some(row) => CommitDelivery::Known {
1356                    client_commit_id: id.clone(),
1357                    outcome: SyncClient::outcome_from_row(row)
1358                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
1359                },
1360                None => CommitDelivery::Unknown {
1361                    client_commit_id: id.clone(),
1362                },
1363            });
1364        }
1365        Ok(SnapshotRead {
1366            revision,
1367            queries,
1368            coverage: CoverageSnapshot {
1369                complete: pending.is_empty() && missing.is_empty(),
1370                pending,
1371                missing,
1372            },
1373            units,
1374            deliveries,
1375        })
1376    })();
1377    match result {
1378        Ok(read) => {
1379            conn.execute_batch("RELEASE syncular_snapshot_read")?;
1380            Ok(read)
1381        }
1382        Err(mut error) => {
1383            if let Err(rollback) = conn
1384                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
1385            {
1386                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
1387            }
1388            Err(error)
1389        }
1390    }
1391}
1392
1393fn snapshot_connection(
1394    conn: &Connection,
1395    sql: &str,
1396    params: &[Value],
1397    coverage: &[WindowCoverage],
1398) -> Result<QuerySnapshot, QueryReadFailure> {
1399    let read = snapshot_read_connection(
1400        conn,
1401        &SnapshotReadRequest {
1402            statements: vec![SnapshotStatement { sql, params }],
1403            coverage,
1404            units: Vec::new(),
1405            commit_ids: Vec::new(),
1406        },
1407    )?;
1408    Ok(QuerySnapshot {
1409        revision: read.revision,
1410        rows: read
1411            .queries
1412            .into_iter()
1413            .next()
1414            .expect("single-query snapshot returns one result"),
1415        coverage: read.coverage,
1416    })
1417}
1418
1419/// A long-lived read-only SQLite sidecar for latency-critical native views.
1420/// Network rounds stay serialized on the mutable core owner, while atomic
1421/// query snapshots use this independent connection and therefore never queue
1422/// behind HTTP/WebSocket latency.
1423pub struct FileQuerySnapshotReader {
1424    path: String,
1425    conn: Option<Connection>,
1426}
1427
1428impl FileQuerySnapshotReader {
1429    #[must_use]
1430    pub fn new(path: impl Into<String>) -> Self {
1431        Self {
1432            path: path.into(),
1433            conn: None,
1434        }
1435    }
1436
1437    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
1438        if self.conn.is_none() {
1439            let conn = Connection::open_with_flags(
1440                &self.path,
1441                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1442            )
1443            .map_err(QueryReadFailure::from)?;
1444            conn.busy_timeout(std::time::Duration::from_millis(250))
1445                .map_err(QueryReadFailure::from)?;
1446            self.conn = Some(conn);
1447        }
1448        self.conn
1449            .as_ref()
1450            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
1451    }
1452
1453    /// One atomic read over the shared primitive: every statement, coverage,
1454    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
1455    pub fn snapshot_read(
1456        &mut self,
1457        request: &SnapshotReadRequest<'_>,
1458    ) -> Result<SnapshotRead, QueryReadFailure> {
1459        snapshot_read_connection(self.connection()?, request)
1460    }
1461
1462    /// The current durable local revision. A host can skip a read when it is
1463    /// unchanged; a corrupt or unreadable marker fails typed.
1464    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
1465        Ok(self
1466            .snapshot_read(&SnapshotReadRequest::default())?
1467            .revision)
1468    }
1469
1470    /// One unit's persisted catch-up state (see the §7.5 contract).
1471    pub fn unit_catchup(&mut self, id: &str) -> Result<UnitCatchup, QueryReadFailure> {
1472        let read = self.snapshot_read(&SnapshotReadRequest {
1473            units: vec![id.to_owned()],
1474            ..Default::default()
1475        })?;
1476        Ok(read
1477            .units
1478            .into_iter()
1479            .next()
1480            .expect("one requested catch-up"))
1481    }
1482
1483    /// One client commit's delivery status: pending, its retained outcome, or
1484    /// an explicit unknown (never held, or pruned by retention).
1485    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
1486        let read = self.snapshot_read(&SnapshotReadRequest {
1487            commit_ids: vec![id.to_owned()],
1488            ..Default::default()
1489        })?;
1490        Ok(read
1491            .deliveries
1492            .into_iter()
1493            .next()
1494            .expect("one requested delivery"))
1495    }
1496
1497    /// Hosts report an owned failure, and the first success after it, to the
1498    /// owning core through [`SyncClient::record_query_read`] (§7.6).
1499    pub fn query_snapshot(
1500        &mut self,
1501        sql: &str,
1502        params: &[Value],
1503        coverage: &[WindowCoverage],
1504    ) -> Result<QuerySnapshot, QueryReadFailure> {
1505        snapshot_connection(self.connection()?, sql, params, coverage)
1506    }
1507}
1508
1509fn validate_authority_reads(
1510    schema: &ClientSchema,
1511    declarations: &[crate::api::AuthorityReadDeclaration],
1512) -> Result<(), String> {
1513    let mut seen = BTreeSet::new();
1514    for read in declarations {
1515        let table = schema
1516            .table(&read.table)
1517            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
1518        let plain = |name: &str| {
1519            table.columns.iter().enumerate().any(|(i, c)| {
1520                c.name == name
1521                    && name != "authority_version"
1522                    && !name.starts_with("_sync")
1523                    && !table.encrypted_columns.iter().any(|e| e.index == i)
1524                    && matches!(
1525                        c.ty,
1526                        ssp2::segment::ColumnType::String
1527                            | ssp2::segment::ColumnType::Integer
1528                            | ssp2::segment::ColumnType::Float
1529                            | ssp2::segment::ColumnType::Boolean
1530                            | ssp2::segment::ColumnType::Json
1531                    )
1532            })
1533        };
1534        if !seen.insert(&read.table)
1535            || read.columns.is_empty()
1536            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
1537            || !read.columns.contains(&table.primary_key)
1538            || !read.columns.iter().all(|c| plain(c))
1539            || read.scopes.is_empty()
1540            || !read.scopes.iter().all(|(v, values)| {
1541                table
1542                    .scope_column(v)
1543                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
1544                    && !values.is_empty()
1545                    && values.iter().all(|v| !v.is_empty() && v != "*")
1546            })
1547        {
1548            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
1549        }
1550    }
1551    Ok(())
1552}
1553
1554// §7.4.2: every open and recreation checks before any persistent writes.
1555/// A marker read that hit a recognized SQLite storage failure keeps that
1556/// classification (BUSY/LOCKED stay retryable); only an unclassified read
1557/// failure is the invalid-marker `sync.local_corrupt`.
1558fn schema_marker_read_failure(error: rusqlite::Error) -> String {
1559    let failure = QueryReadFailure::from(error);
1560    if failure.code.is_some() {
1561        failure.to_string()
1562    } else {
1563        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
1564    }
1565}
1566
1567/// §7.1: reject an invalid configured client limit before any storage is
1568/// opened or written. Zero would wedge the push queue and an out-of-range
1569/// value would differ per target; the TS core enforces the same bounds.
1570fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
1571    if limits.outcome_retention_max_entries == Some(0) {
1572        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
1573    }
1574    if [
1575        limits.max_push_commits_per_request,
1576        limits.max_push_operations_per_request,
1577        limits.max_push_request_bytes,
1578    ]
1579    .into_iter()
1580    .flatten()
1581    .any(|value| value == 0 || value > u32::MAX as usize)
1582    {
1583        return Err(
1584            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
1585        );
1586    }
1587    Ok(())
1588}
1589
1590fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
1591    let corrupt =
1592        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
1593    let table_type: Option<String> = conn
1594        .query_row(
1595            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
1596            [],
1597            |row| row.get(0),
1598        )
1599        .optional()
1600        .map_err(schema_marker_read_failure)?;
1601    let Some(table_type) = table_type else {
1602        return Ok(None);
1603    };
1604    if table_type != "table" {
1605        return Err(corrupt());
1606    }
1607    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
1608    // whichever row SQLite returns first, and an older one would drive a
1609    // destructive reset over a newer replica.
1610    let mut statement = conn
1611        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
1612        .map_err(schema_marker_read_failure)?;
1613    let mut rows = statement
1614        .query([LOCAL_SCHEMA_VERSION_KEY])
1615        .map_err(schema_marker_read_failure)?;
1616    let value: Option<String> = rows
1617        .next()
1618        .map_err(schema_marker_read_failure)?
1619        .map(|row| row.get(0))
1620        .transpose()
1621        .map_err(schema_marker_read_failure)?;
1622    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
1623        return Err(corrupt());
1624    }
1625    let Some(value) = value else {
1626        let has_descriptor = conn
1627            .query_row(
1628                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
1629                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
1630                |_| Ok(()),
1631            )
1632            .optional()
1633            .map_err(schema_marker_read_failure)?
1634            .is_some();
1635        if has_descriptor {
1636            return Err(corrupt());
1637        }
1638        return Ok(None);
1639    };
1640    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
1641        || !value.bytes().all(|b| b.is_ascii_digit())
1642    {
1643        return Err(corrupt());
1644    }
1645    let version = value.parse::<i32>().map_err(|_| corrupt())?;
1646    if version > requested {
1647        return Err(
1648            "client.schema_downgrade: persisted local schema is newer than the requested schema"
1649                .to_owned(),
1650        );
1651    }
1652    Ok(Some(version))
1653}
1654
1655/// A file replica on a browser worker must recover a hot journal before any
1656/// statement reads it. `wasm_vfs` replaces the installed OPFS VFS's reserved
1657/// lock callback, so install it before the connection opens its file.
1658#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1659fn prepare_wasm_replica(path: &str) -> Result<(), String> {
1660    if path.is_empty() || path == ":memory:" {
1661        return Ok(());
1662    }
1663    crate::wasm_vfs::disable_foreign_lock_check()
1664}
1665
1666#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1667fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
1668    Ok(())
1669}
1670
1671impl SyncClient {
1672    pub fn new_with_identity(
1673        client_id: Option<String>,
1674        schema_json: &Value,
1675        limits: ClientLimits,
1676    ) -> Result<Self, String> {
1677        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1678        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1679        Self::with_connection(resolved, schema_json, limits, conn)
1680    }
1681
1682    pub fn new(
1683        client_id: String,
1684        schema_json: &Value,
1685        limits: ClientLimits,
1686    ) -> Result<Self, String> {
1687        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1688        Self::with_connection(client_id, schema_json, limits, conn)
1689    }
1690
1691    /// Build a client backed by an on-disk SQLite database at `path` — the
1692    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
1693    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
1694    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
1695    /// of the command router's dependency set (the router only holds a path).
1696    pub fn open_path(
1697        client_id: String,
1698        schema_json: &Value,
1699        limits: ClientLimits,
1700        path: &str,
1701    ) -> Result<Self, String> {
1702        validate_client_limits(&limits)?;
1703        parse_schema_json(schema_json)?;
1704        prepare_wasm_replica(path)?;
1705        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1706        Self::with_connection(client_id, schema_json, limits, conn)
1707    }
1708
1709    /// Open a file replica and resolve its client id. Native targets require
1710    /// WAL for the independent snapshot reader; `wasm32-unknown-unknown`
1711    /// accepts the sqlite-wasm worker journal instead.
1712    pub fn open_path_with_identity(
1713        client_id: Option<String>,
1714        schema_json: &Value,
1715        limits: ClientLimits,
1716        path: &str,
1717    ) -> Result<Self, String> {
1718        Self::open_path_with_identity_journal(
1719            client_id,
1720            schema_json,
1721            limits,
1722            path,
1723            !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
1724        )
1725    }
1726
1727    fn open_path_with_identity_journal(
1728        client_id: Option<String>,
1729        schema_json: &Value,
1730        limits: ClientLimits,
1731        path: &str,
1732        require_wal: bool,
1733    ) -> Result<Self, String> {
1734        validate_client_limits(&limits)?;
1735        // §7.4.2: an invalid requested version is refused before the replica is
1736        // even created, so a rejected constructor leaves no file behind.
1737        parse_schema_json(schema_json)?;
1738        prepare_wasm_replica(path)?;
1739        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1740        // A file-backed native client uses an independent read connection for
1741        // latency-critical snapshots. WAL is SQLite's intended reader/writer
1742        // concurrency mode: a view read never holds a rollback-journal lock
1743        // that delays the client's next commit. The busy timeout must precede
1744        // this replica's own startup transaction; WAL is applied only after
1745        // startup succeeded, because a refused open must leave the replica's
1746        // journal mode and contents untouched (§7.4.2).
1747        conn.busy_timeout(std::time::Duration::from_millis(250))
1748            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
1749        // Durability is the caller's contract here, not the VFS default: a
1750        // rollback-journal commit needs FULL before it writes the database
1751        // pages, or a killed worker can leave a half-written image behind.
1752        // WAL below inherits the same setting.
1753        conn.pragma_update(None, "synchronous", "FULL")
1754            .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
1755        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
1756        // A browser worker's sqlite-wasm VFS has no WAL mode, and the wasm
1757        // host has no independent snapshot reader. The WAL requirement exists
1758        // for native file replicas, where it is the reader/writer boundary.
1759        if require_wal {
1760            let journal_mode: String = client
1761                .conn
1762                .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
1763                .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1764            if journal_mode != "wal" {
1765                return Err(
1766                    "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
1767                );
1768            }
1769        }
1770        Ok(client)
1771    }
1772
1773    /// Delete a file replica at `path`, including its `-journal`, `-wal`, and
1774    /// `-shm` sidecars, through the VFS that owns it. Use this after
1775    /// `client.storage_corrupt`: an OPFS SAH pool stores those files under
1776    /// opaque names, so deleting the pool directory behind SQLite bypasses the
1777    /// pool's slot accounting.
1778    ///
1779    /// Close the client and every [`FileQuerySnapshotReader`] on `path` first;
1780    /// a VFS cannot delete a file another handle still owns. A file that does
1781    /// not exist counts as deleted, so a cleanly closed replica without
1782    /// sidecars resets. The next open
1783    /// creates a fresh replica (new client id) and re-bootstraps from the
1784    /// server. A memory replica has nothing to delete and fails with
1785    /// `sync.invalid_request`.
1786    pub fn reset_replica(path: &str) -> Result<(), String> {
1787        if path.is_empty() || path == ":memory:" {
1788            return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
1789        }
1790        // SAFETY: a null VFS name asks SQLite for its process-wide default VFS,
1791        // the same one `Connection::open(path)` above selects. `xDelete` is
1792        // that VFS's own function pointer, and `c_name` outlives the call.
1793        let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
1794        if vfs.is_null() {
1795            return Err("client.storage_io: no SQLite VFS is registered".to_owned());
1796        }
1797        let delete = unsafe { (*vfs).xDelete }
1798            .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
1799        for suffix in ["-wal", "-shm", "-journal", ""] {
1800            let file_name = format!("{path}{suffix}");
1801            let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
1802                format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
1803            })?;
1804            // SAFETY: `vfs` and its `xDelete` pointer come from the live
1805            // SQLite library; `c_name` is a valid NUL-terminated path.
1806            let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
1807            // A cleanly closed WAL replica has no sidecars; a missing file is
1808            // already deleted.
1809            if code != rusqlite::ffi::SQLITE_OK && code != rusqlite::ffi::SQLITE_IOERR_DELETE_NOENT
1810            {
1811                return Err(format!(
1812                    "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
1813                ));
1814            }
1815        }
1816        Ok(())
1817    }
1818
1819    #[must_use]
1820    pub fn client_id(&self) -> &str {
1821        &self.client_id
1822    }
1823
1824    /// Rotate the caller's client id (§1.5) and return the new one. Use it
1825    /// after a `sync.invalid_client_id` refusal (`recommendedAction:
1826    /// resetClientId`): the next round carries the new id. A live realtime
1827    /// socket is closed so the next connection registers with the new id.
1828    /// The replica and its outbox stay in place; wipe them yourself when the
1829    /// stored data belongs to the account that signed out.
1830    pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> Result<String, String> {
1831        self.disconnect_realtime(transport);
1832        let client_id = uuid::Uuid::new_v4().to_string();
1833        self.set_meta(CLIENT_ID_KEY, &client_id)?;
1834        self.client_id.clone_from(&client_id);
1835        self.round_failure = None;
1836        Ok(client_id)
1837    }
1838
1839    /// Build a client over a caller-supplied rusqlite connection — the seam a
1840    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
1841    /// with an on-disk database (`Connection::open(path)`) rather than the
1842    /// default `:memory:`. The connection MUST be fresh (no pre-existing
1843    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
1844    /// the same file across process restarts reuses the persisted rows.
1845    pub fn with_connection(
1846        client_id: String,
1847        schema_json: &Value,
1848        limits: ClientLimits,
1849        conn: Connection,
1850    ) -> Result<Self, String> {
1851        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
1852    }
1853
1854    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
1855    /// means a durable replica's persisted `clientId` wins over a fresh one.
1856    fn with_connection_identity(
1857        client_id: Option<String>,
1858        schema_json: &Value,
1859        limits: ClientLimits,
1860        conn: Connection,
1861    ) -> Result<Self, String> {
1862        validate_client_limits(&limits)?;
1863        let schema = parse_schema_json(schema_json)?;
1864        validate_authority_reads(&schema, &limits.authority_reads)?;
1865        // RFC 0005 D8: resolve the previous-version config before the opening
1866        // reset runs — the capture happens inside it. Bad bounds fail loud.
1867        let previous_version = match limits.previous_version_context {
1868            Some(config) => {
1869                config.validate()?;
1870                Some(config)
1871            }
1872            None => None,
1873        };
1874        let mut client = SyncClient {
1875            #[cfg(feature = "bench-internals")]
1876            benchmark_phases: Recorder::default(),
1877            conn,
1878            schema,
1879            client_id: client_id
1880                .clone()
1881                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
1882            limits,
1883            outbox: Shared::default(),
1884            failed_commits: Shared::default(),
1885            retain_failed_commits: false,
1886            conflicts: Shared::default(),
1887            rejections: Shared::default(),
1888            schema_floor: None,
1889            lease_state: None,
1890            stopped: false,
1891            upgrading: false,
1892            sync_needed: false,
1893            realtime_policy: RealtimePolicy::Optional,
1894            realtime_state: RealtimeState::Disconnected,
1895            realtime_reason_code: None,
1896            realtime_retry_delay_ms: None,
1897            presence: HashMap::new(),
1898            now_ms: None,
1899            encryption: crate::values::EncryptionConfig::default(),
1900            security_preflight: false,
1901            security_epoch: 0,
1902            transport_enabled: true,
1903            transport_epoch: 0,
1904            registration_epoch: 0,
1905            previous_version,
1906            insert_sql: RefCell::new(HashMap::new()),
1907            overlay_dirty: OverlayDirty::clean(),
1908            storage_failure: RefCell::new(None),
1909            #[cfg(test)]
1910            overlay_rebuild_count: Cell::new(0),
1911            #[cfg(test)]
1912            acknowledged_replay_count: Cell::new(0),
1913            #[cfg(test)]
1914            outcome_prune_count: Cell::new(0),
1915            progress: ProgressObserver::default(),
1916            change_queue: VecDeque::new(),
1917            sync_intent_queue: VecDeque::new(),
1918            retry_delay_ms: 250,
1919            round_retry_delay_ms: None,
1920            round_failure: None,
1921            active_round: None,
1922            last_round: None,
1923            last_change: None,
1924            query_failures: Vec::new(),
1925            coalesced: None,
1926            coalesced_failure: None,
1927        };
1928        // The row write path leans on the prepared-statement cache (two
1929        // insert statements per synced table, plus the bookkeeping
1930        // statements); size it so a multi-table schema never thrashes.
1931        client
1932            .conn
1933            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
1934        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
1935        // writer) covers the marker read, the identity check, and every
1936        // bookkeeping/schema write, so a concurrent open that upgraded the
1937        // replica cannot interleave between the guard and the writes, and a
1938        // refusal leaves the replica unchanged.
1939        client
1940            .conn
1941            .execute_batch("BEGIN IMMEDIATE")
1942            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1943        let startup = (|| -> Result<(), String> {
1944            // The marker guard runs inside the write transaction, so the version
1945            // it validates is the version every write below commits under.
1946            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
1947            // New app indexes may reference columns that only exist after a
1948            // version-bump reset.
1949            client.create_bookkeeping_tables()?;
1950            match client.get_meta(CLIENT_ID_KEY)? {
1951                Some(existing) => match client_id {
1952                    Some(requested) if existing != requested => {
1953                        return Err(format!(
1954                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
1955                        ))
1956                    }
1957                    _ => client.client_id = existing,
1958                },
1959                None => client.set_meta(CLIENT_ID_KEY, &client.client_id)?,
1960            }
1961            client.restore_persisted_state()?;
1962            client.failed_commits = client.load_failed_commits()?.into();
1963            match marker {
1964                None => {
1965                    client.create_synced_tables()?;
1966                    client.persist_schema_version()?;
1967                    client.save_unit_scope_schema()?;
1968                }
1969                Some(version) if version == client.schema.version => {
1970                    client.create_synced_tables()?;
1971                    // RFC 0005 D1: the same-version open backfills the descriptor
1972                    // for a database first opened by an unaware binary, so the
1973                    // NEXT bump can capture without needing a schema bump first.
1974                    set_local_schema_descriptor(&client.conn, &client.schema)
1975                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1976                    client.save_unit_scope_schema()?;
1977                }
1978                Some(_) => client.run_schema_reset()?,
1979            }
1980            // RFC 0005 D9: the reset sweep cannot see a container whose capture
1981            // committed in its OWN file while the replica's savepoint never
1982            // released — a crash at that seam leaves a container beside a matching
1983            // OLD marker, and the next open at that version runs no reset. Discard
1984            // it before anything can read it. No-op when no container exists.
1985            client.reconcile_previous_version_at_boot()?;
1986            client.clear_satisfied_persisted_schema_floor()?;
1987            client.prune_unknown_units(false)?;
1988            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
1989            // the full startup/reset reconciliation boundary, inside this open
1990            // transaction and after the schema and identity guards, so a commit
1991            // an earlier version accepted is dropped with durable rejection
1992            // evidence before any replay. A table the current schema removed
1993            // keeps the §7.4.4 send-time classification, and an unresolved
1994            // encryption key stays with the send seam because hosts configure
1995            // keys after open.
1996            client.drop_unencodable_outbox(false, None)?;
1997            if marker == Some(client.schema.version)
1998                && (!client.outbox.is_empty()
1999                    || !client.failed_commits.is_empty()
2000                    || client.has_acknowledged_rows()?)
2001            {
2002                // Reconstruct the visible optimistic overlay from the durable
2003                // base plus outbox instead of trusting a process-interrupted
2004                // mirror.
2005                client.overlay_dirty.set(true);
2006                client.rebuild_overlay()?;
2007            }
2008            // Every persisted active unit needs one catch-up pull on
2009            // open: realtime only covers changes after connection, while an
2010            // idempotent setWindow correctly creates no fresh command effect.
2011            // Pending outbox work has the same restart requirement. The core
2012            // owns this intent so native hosts never poll or require an
2013            // application-issued sync().
2014            client.enqueue_startup_sync_if_needed()?;
2015            Ok(())
2016        })();
2017        match startup {
2018            Ok(()) => match client.conn.execute_batch("COMMIT") {
2019                Ok(()) => Ok(client),
2020                Err(error) => {
2021                    let failure = Self::sqlite_failure(&client.storage_failure, error);
2022                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
2023                        // Keep a failed rollback's typed storage classification.
2024                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
2025                    }
2026                    Err(failure)
2027                }
2028            },
2029            Err(error) => {
2030                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
2031                    // Keep a failed rollback's typed storage classification.
2032                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
2033                }
2034                Err(error)
2035            }
2036        }
2037    }
2038
2039    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
2040    /// against this instead of system time (conformance virtual clock).
2041    pub fn set_now_ms(&mut self, now_ms: i64) {
2042        self.now_ms = Some(now_ms);
2043    }
2044
2045    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
2046    /// The command router parses these from the `create` command's
2047    /// `encryption` config (keys as `{$bytes: hex}`).
2048    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
2049        self.encryption = encryption;
2050    }
2051
2052    #[must_use]
2053    pub fn security_lifecycle(&self) -> &'static str {
2054        if self.security_preflight {
2055            "preflight"
2056        } else {
2057            "active"
2058        }
2059    }
2060
2061    #[must_use]
2062    pub fn security_preflight(&self) -> bool {
2063        self.security_preflight
2064    }
2065
2066    /// Host-owned, non-persisted network gate. Local operations remain available.
2067    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
2068        if self.transport_enabled != enabled {
2069            self.transport_enabled = enabled;
2070            self.transport_epoch = self.transport_epoch.wrapping_add(1);
2071            self.sync_intent_queue.clear();
2072            self.sync_intent_queue
2073                .push_back(if enabled && !self.security_preflight {
2074                    SyncIntent::Interactive
2075                } else {
2076                    SyncIntent::None
2077                });
2078        }
2079        // A captured reply keeps its normal apply/revocation semantics.
2080        if !enabled && self.active_round.is_none() {
2081            self.disconnect_realtime(transport);
2082        }
2083    }
2084
2085    pub fn transport_enabled(&self) -> bool {
2086        self.transport_enabled
2087    }
2088
2089    /// Quarantine this replica: enter the fail-closed gate, release all
2090    /// core-owned key material, and record the gate in the database so
2091    /// reopening re-enters preflight until `activate_security` clears it.
2092    ///
2093    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
2094    /// which must NOT leave that durable mark.
2095    pub fn begin_security_preflight(&mut self) -> Result<(), String> {
2096        self.seal_security_on_teardown();
2097        // Persist the quarantine so it survives handle teardown and restart:
2098        // reopening this replica re-enters preflight until activation clears it.
2099        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1")
2100    }
2101
2102    /// Release core-owned key material as a host tears the client down,
2103    /// WITHOUT recording a quarantine.
2104    ///
2105    /// Shutting an activated client down is not a quarantine event. Persisting
2106    /// the gate here marks every cleanly closed replica as pending, and the
2107    /// next plain `create` is then refused permanently: the reopen path
2108    /// restores the flag from the marker and the create guard rejects it. The
2109    /// in-memory flag still closes the gate for anything still holding this
2110    /// instance, which is all a teardown barrier needs.
2111    pub fn seal_security_on_teardown(&mut self) {
2112        self.cancel_sync_round();
2113        self.security_preflight = true;
2114        self.security_epoch = self.security_epoch.wrapping_add(1);
2115        self.encryption = crate::values::EncryptionConfig::default();
2116        self.sync_intent_queue.clear();
2117    }
2118
2119    /// Install the post-authentication keyring and release the host loop.
2120    pub fn activate_security(
2121        &mut self,
2122        encryption: crate::values::EncryptionConfig,
2123    ) -> Result<(), String> {
2124        if !self.security_preflight {
2125            return Err(
2126                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
2127            );
2128        }
2129        self.enqueue_startup_sync_if_needed()?;
2130        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY)?;
2131        self.encryption = encryption;
2132        self.security_preflight = false;
2133        Ok(())
2134    }
2135
2136    fn clock_now_ms(&self) -> i64 {
2137        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
2138    }
2139
2140    fn create_bookkeeping_tables(&self) -> Result<(), String> {
2141        let exists = |name: &str| {
2142            self.conn
2143                .query_row(
2144                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
2145                    [name],
2146                    |row| row.get::<_, bool>(0),
2147                )
2148                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2149        };
2150        let outbox_existed = exists("_syncular_outbox")?;
2151        // Durable client bookkeeping (outbox + units + meta).
2152        self.conn
2153            .execute_batch(
2154                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
2155                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
2156                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
2157                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
2158                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
2159                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
2160                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
2161                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
2162                   PRIMARY KEY(commit_id, idx));
2163                 CREATE INDEX IF NOT EXISTS _syncular_acked_rows_row
2164                   ON _syncular_acked_rows(tbl, id);
2165                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
2166                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
2167                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
2168                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
2169                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
2170                   client_commit_id TEXT NOT NULL UNIQUE,
2171                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
2172                   recorded_at_ms INTEGER NOT NULL,
2173                   results_json TEXT NOT NULL,
2174                   operations_json TEXT,
2175                   resolution TEXT NOT NULL DEFAULT 'active'
2176                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
2177                   resolved_at_ms INTEGER,
2178                   replacement_client_commit_id TEXT);
2179                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
2180                   ON _syncular_commit_outcomes(resolution, seq);
2181                 CREATE TABLE IF NOT EXISTS _syncular_units (
2182                   id TEXT PRIMARY KEY, scopes TEXT NOT NULL,
2183                   explicit INTEGER NOT NULL DEFAULT 0,
2184                   status TEXT NOT NULL DEFAULT 'active', reason_code TEXT,
2185                   cursor INTEGER DEFAULT -1, bootstrap_state TEXT,
2186                   seeded TEXT NOT NULL DEFAULT '[]', echo TEXT);
2187                 CREATE TABLE IF NOT EXISTS _syncular_meta (
2188                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
2189                 CREATE TABLE IF NOT EXISTS _syncular_windows (
2190                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
2191                   PRIMARY KEY (base, unit));
2192                 CREATE TABLE IF NOT EXISTS _syncular_pending_evictions (
2193                   unit_id TEXT NOT NULL, tbl TEXT NOT NULL,
2194                   effective_scopes TEXT NOT NULL, PRIMARY KEY (unit_id, tbl));
2195                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
2196                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
2197                   PRIMARY KEY(commit_id, blob_id));
2198                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
2199                   ON _syncular_blob_commit_refs(blob_id);",
2200            )
2201            .map_err(|e| e.to_string())?;
2202        // Migrate an outcome journal created before failed aggregate
2203        // envelopes were retained. Historical rows intentionally stay NULL.
2204        let _ = self
2205            .conn
2206            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
2207        if self.get_meta(LOCAL_REVISION_KEY)?.is_none() {
2208            self.set_meta(LOCAL_REVISION_KEY, "0")?;
2209        }
2210        // §7.1: `outboxId` names the outbox sequence. A new outbox table starts
2211        // a new sequence, so it replaces the id in the same transaction.
2212        if !outbox_existed || self.get_meta(OUTBOX_ID_KEY)?.is_none() {
2213            self.set_meta(OUTBOX_ID_KEY, &uuid::Uuid::new_v4().to_string())?;
2214        }
2215        // §9: a replica written under wire version 3 keeps its outbox, journal,
2216        // rows and pending evictions and registers its units fresh. Its
2217        // subscriptions, cursors, echoes, resume tokens and window entries go;
2218        // units left from an earlier version 4 life restart.
2219        if exists("_syncular_window_pending_evict")? {
2220            let invalid: bool = self
2221                .conn
2222                .query_row(
2223                    "SELECT EXISTS(SELECT 1 FROM _syncular_window_pending_evict
2224                       WHERE NOT json_valid(effective_scopes)
2225                          OR json_type(effective_scopes) <> 'object'
2226                          OR EXISTS(SELECT 1 FROM json_each(effective_scopes)
2227                               WHERE type <> 'array'
2228                                  OR EXISTS(SELECT 1 FROM json_each(value) AS v
2229                                       WHERE v.type <> 'text')))",
2230                    [],
2231                    |row| row.get(0),
2232                )
2233                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
2234            if invalid {
2235                return Err(
2236                    "sync.local_corrupt: a version 3 pending eviction cannot be upgraded"
2237                        .to_owned(),
2238                );
2239            }
2240            self.conn
2241                .execute_batch(
2242                    "INSERT OR REPLACE INTO _syncular_pending_evictions
2243                       SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict;
2244                     DROP TABLE _syncular_window_pending_evict;
2245                     DROP TABLE IF EXISTS _syncular_subscriptions;
2246                     DELETE FROM _syncular_windows;",
2247                )
2248                .and_then(|()| reset_units(&self.conn))
2249                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
2250        }
2251        // §5.9.7 blob cache. Mutable upload state stays off the body row so
2252        // completing a large upload does not rewrite its BLOB record.
2253        if self.schema_has_blobs() {
2254            if exists("_syncular_blobs")? {
2255                let mut statement = self
2256                    .conn
2257                    .prepare("PRAGMA table_info(_syncular_blobs)")
2258                    .map_err(|e| e.to_string())?;
2259                let mut columns = statement
2260                    .query_map([], |row| row.get::<_, String>(1))
2261                    .map_err(|e| e.to_string())?
2262                    .collect::<Result<Vec<_>, _>>()
2263                    .map_err(|e| e.to_string())?;
2264                columns.sort();
2265                let mut expected = vec![
2266                    "blob_id",
2267                    "byte_length",
2268                    "bytes",
2269                    "created_at_ms",
2270                    "media_type",
2271                ];
2272                expected.sort_unstable();
2273                if columns != expected {
2274                    return Err(
2275                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
2276                    );
2277                }
2278            } else {
2279                self.conn
2280                    .execute_batch(
2281                        "CREATE TABLE _syncular_blobs(
2282                           blob_id TEXT PRIMARY KEY,
2283                           bytes BLOB NOT NULL,
2284                           byte_length INTEGER NOT NULL,
2285                           media_type TEXT,
2286                           created_at_ms INTEGER NOT NULL);",
2287                    )
2288                    .map_err(|e| e.to_string())?;
2289            }
2290            self.conn
2291                .execute_batch(
2292                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
2293                       blob_id TEXT PRIMARY KEY,
2294                       media_type TEXT,
2295                       created_at_ms INTEGER NOT NULL);",
2296                )
2297                .map_err(|e| e.to_string())?;
2298        }
2299        Ok(())
2300    }
2301
2302    /// True iff any synced table declares a `blob_ref` column (§5.9).
2303    fn schema_has_blobs(&self) -> bool {
2304        self.schema
2305            .tables
2306            .iter()
2307            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
2308    }
2309
2310    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
2311
2312    fn get_meta(&self, key: &str) -> Result<Option<String>, String> {
2313        meta_get(&self.conn, key)
2314            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2315    }
2316
2317    // The caller holds the startup/reset transaction. Either both schema
2318    // records persist or the enclosing operation fails and rolls back.
2319    fn persist_schema_version(&self) -> Result<(), String> {
2320        self.conn
2321            .execute(
2322                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
2323                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
2324            )
2325            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
2326        set_local_schema_descriptor(&self.conn, &self.schema)
2327            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2328    }
2329
2330    pub(crate) fn set_meta(&self, key: &str, value: &str) -> Result<(), String> {
2331        meta_set(&self.conn, key, value)
2332            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2333    }
2334
2335    fn delete_meta(&self, key: &str) -> Result<(), String> {
2336        meta_delete(&self.conn, key)
2337            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2338    }
2339
2340    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
2341    fn load_outbox(&self) -> Result<Vec<Arc<OutboxCommit>>, String> {
2342        let mut stmt = self
2343            .conn
2344            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
2345            .map_err(|error| error.to_string())?;
2346        let rows = stmt
2347            .query_map([], |row| {
2348                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2349            })
2350            .map_err(|error| error.to_string())?;
2351        let mut commits = Vec::new();
2352        for row in rows {
2353            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
2354            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
2355                format!("invalid persisted outbox {client_commit_id:?}: {error}")
2356            })?;
2357            let mut ops = Vec::with_capacity(entries.len());
2358            for entry in entries {
2359                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
2360                ops.push(OutboxOp {
2361                    upsert: op == "upsert",
2362                    table: entry
2363                        .get("table")
2364                        .and_then(Value::as_str)
2365                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
2366                        .to_owned(),
2367                    row_id: entry
2368                        .get("rowId")
2369                        .and_then(Value::as_str)
2370                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
2371                        .to_owned(),
2372                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
2373                    // A legacy entry's `changedFields` key is ignored:
2374                    // the values map is the presence set (§6.1).
2375                    values: entry.get("values").and_then(Value::as_object).cloned(),
2376                    insert: entry.get("insert") == Some(&Value::Bool(true)),
2377                });
2378            }
2379            commits.push(Arc::new(OutboxCommit {
2380                client_commit_id,
2381                ops,
2382            }));
2383        }
2384        Ok(commits)
2385    }
2386
2387    fn restore_persisted_state(&mut self) -> Result<(), String> {
2388        self.outbox = self.load_outbox()?.into();
2389
2390        self.prune_commit_outcomes()?;
2391        let active = self.commit_outcomes(CommitOutcomeQuery {
2392            active_only: true,
2393            ..CommitOutcomeQuery::default()
2394        })?;
2395        self.conflicts = active
2396            .iter()
2397            .flat_map(|outcome| outcome.results.iter())
2398            .filter_map(|result| match result {
2399                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
2400                _ => None,
2401            })
2402            .collect::<Vec<_>>()
2403            .into();
2404        self.rejections = active
2405            .iter()
2406            .flat_map(|outcome| outcome.results.iter())
2407            .filter_map(|result| match result {
2408                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
2409                _ => None,
2410            })
2411            .collect::<Vec<_>>()
2412            .into();
2413
2414        self.lease_state = self
2415            .get_meta(LEASE_STATE_KEY)?
2416            .map(|raw| serde_json::from_str(&raw))
2417            .transpose()
2418            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
2419        self.schema_floor = self
2420            .get_meta(SCHEMA_FLOOR_KEY)?
2421            .map(|raw| serde_json::from_str(&raw))
2422            .transpose()
2423            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
2424        self.stopped = self.schema_floor.is_some();
2425        // A replica left in unactivated preflight reopens gated: the quarantine
2426        // decision persists with the data across handle teardown and restart.
2427        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY)?.as_deref() == Some("1") {
2428            self.security_preflight = true;
2429        }
2430        Ok(())
2431    }
2432
2433    fn save_unit_scope_schema(&self) -> Result<(), String> {
2434        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
2435            .schema
2436            .tables
2437            .iter()
2438            .map(|table| {
2439                (
2440                    table.name.clone(),
2441                    table
2442                        .scope_variables
2443                        .iter()
2444                        .map(|scope| {
2445                            (
2446                                scope.variable.clone(),
2447                                [scope.prefix.clone(), scope.column.clone()],
2448                            )
2449                        })
2450                        .collect(),
2451                )
2452            })
2453            .collect();
2454        self.set_meta(
2455            "subscriptionScopeSchema",
2456            &serde_json::to_string(&declarations).expect("scope schema serialization"),
2457        )
2458    }
2459
2460    /// Drop units that cover no table of the running schema, with their window
2461    /// registry rows and pending evictions; never translate scopes. At a bump
2462    /// (§7.4.3) a unit also needs every variable unchanged in prefix and column
2463    /// on every table it covered before.
2464    fn prune_unknown_units(&mut self, bump: bool) -> Result<(), String> {
2465        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
2466            self.get_meta("subscriptionScopeSchema")?
2467                .map(|raw| serde_json::from_str(&raw))
2468                .transpose()
2469                .map_err(|_| "sync.local_corrupt: invalid persisted scope schema".to_owned())?
2470        } else {
2471            None
2472        };
2473        // A table keeps the variables' meaning when each has the same prefix
2474        // and column as before. An old binary stored no declaration evidence;
2475        // matching names cannot prove unchanged meaning, so the app registers
2476        // again.
2477        let unchanged = |name: &str, variables: &[&String]| {
2478            let (Some(old), Some(table)) = (
2479                previous.as_ref().and_then(|previous| previous.get(name)),
2480                self.schema.table(name),
2481            ) else {
2482                return false;
2483            };
2484            variables.iter().all(|variable| {
2485                table
2486                    .scope_variables
2487                    .iter()
2488                    .find(|scope| &scope.variable == *variable)
2489                    .is_some_and(|scope| {
2490                        old.get(*variable) == Some(&[scope.prefix.clone(), scope.column.clone()])
2491                    })
2492            })
2493        };
2494        let compatible = |scopes: &UnitScopes| {
2495            if !self.schema.tables.iter().any(|table| covers(table, scopes)) {
2496                return false;
2497            }
2498            if !bump {
2499                return true;
2500            }
2501            let Some(previous) = &previous else {
2502                return false;
2503            };
2504            previous.iter().all(|(name, old)| {
2505                !scopes.keys().all(|variable| old.contains_key(variable))
2506                    || unchanged(name, &scopes.keys().collect::<Vec<_>>())
2507            })
2508        };
2509        let mut stale: BTreeSet<String> = self
2510            .stored(load_units(&self.conn))?
2511            .into_iter()
2512            .filter(|unit| !compatible(&unit.scopes))
2513            .map(|unit| unit.id)
2514            .collect();
2515        // A shrunk window's deferred eviction outlives its unit row and keeps
2516        // its per-table scopes. A table the schema no longer has cannot be
2517        // evicted, and at a bump a table whose scope declarations changed must
2518        // not be evicted by the outdated rule.
2519        for (id, table, effective) in self.load_pending_evictions()? {
2520            if self.schema.table(&table).is_none()
2521                || (bump
2522                    && !unchanged(
2523                        &table,
2524                        &effective
2525                            .iter()
2526                            .map(|(variable, _)| variable)
2527                            .collect::<Vec<_>>(),
2528                    ))
2529            {
2530                stale.insert(id);
2531            }
2532        }
2533        for id in &stale {
2534            for sql in [
2535                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
2536                "DELETE FROM _syncular_pending_evictions WHERE unit_id = ?1",
2537                "DELETE FROM _syncular_units WHERE id = ?1",
2538            ] {
2539                self.conn
2540                    .execute(sql, rusqlite::params![id])
2541                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
2542            }
2543        }
2544        Ok(())
2545    }
2546
2547    pub fn local_revision(&self) -> Result<u64, String> {
2548        Ok(self
2549            .get_meta(LOCAL_REVISION_KEY)?
2550            .and_then(|value| value.parse().ok())
2551            .unwrap_or(0))
2552    }
2553
2554    /// §7.5: resolve a snapshot read on the mutable owner connection through
2555    /// the same primitive the read-only sidecar uses, so the command surface
2556    /// and the file sidecar return one revision, rows, coverage, catch-up, and
2557    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
2558    /// `owner` records its failure (and its next success) in diagnostics.
2559    pub fn snapshot_read(
2560        &mut self,
2561        request: &SnapshotReadRequest<'_>,
2562        owner: Option<QueryOwner<'_>>,
2563    ) -> Result<SnapshotRead, QueryReadFailure> {
2564        let result = snapshot_read_connection(&self.conn, request);
2565        if let Some(owner) = owner {
2566            self.record_query_read(owner, result.as_ref().err());
2567        }
2568        result
2569    }
2570
2571    #[must_use]
2572    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
2573        SyncStatusSnapshot {
2574            current_schema_version: self.schema.version,
2575            outbox: self.outbox.len(),
2576            upgrading: self.upgrading,
2577            lease_state: self.lease_state.clone(),
2578            schema_floor: self.schema_floor.clone(),
2579            sync_needed: self.sync_needed,
2580            previous_version_context: self.previous_version_status(),
2581        }
2582    }
2583
2584    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
2585    /// rollback precondition), read without creating the file.
2586    fn previous_version_status(&self) -> PreviousVersionStatus {
2587        let Some(replica_path) = self.previous_version_replica_path() else {
2588            return PreviousVersionStatus {
2589                present: false,
2590                created_at_ms: None,
2591            };
2592        };
2593        match crate::previous_version::read_previous_version_record(&replica_path) {
2594            Ok(Some(record)) => PreviousVersionStatus {
2595                present: true,
2596                created_at_ms: Some(record.created_at_ms),
2597            },
2598            _ => PreviousVersionStatus {
2599                present: std::path::Path::new(
2600                    &crate::previous_version::previous_version_container_path(&replica_path),
2601                )
2602                .exists(),
2603                created_at_ms: None,
2604            },
2605        }
2606    }
2607
2608    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
2609    /// is always `previousVersion`; the container never makes
2610    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
2611    /// revocation, coverage completion and the TTL DISCARD the container and
2612    /// close the read with their own reason.
2613    pub fn previous_version_snapshot(
2614        &self,
2615        spec: &PreviousVersionReadSpec,
2616    ) -> Result<PreviousVersionSnapshot, String> {
2617        let now_ms = self.clock_now_ms();
2618        crate::previous_version::previous_version_snapshot(
2619            &self.conn,
2620            self.previous_version_replica_path().as_deref(),
2621            self.previous_version.as_ref(),
2622            PreviousVersionLifecycle {
2623                lease_inactive: self.previous_version_lease_inactive(now_ms),
2624                scope_revoked: self
2625                    .stored(load_units(&self.conn))?
2626                    .iter()
2627                    .any(|unit| unit.status == UnitStatus::Revoked),
2628                coverage_complete: self.previous_version_coverage_complete()?,
2629            },
2630            now_ms,
2631            self.schema.version,
2632            spec,
2633        )
2634    }
2635
2636    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
2637    pub fn previous_version_audit(
2638        &self,
2639    ) -> Result<Option<crate::previous_version::PreviousVersionAudit>, String> {
2640        crate::previous_version::stored_previous_version_audit(&self.conn)
2641            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2642    }
2643
2644    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
2645    /// and both metadata records, and report whether anything was present.
2646    /// Idempotent: absence is a successful no-op.
2647    pub fn previous_version_discard(
2648        &self,
2649    ) -> Result<crate::previous_version::PreviousVersionDiscardOutcome, String> {
2650        self.drop_previous_version()
2651    }
2652
2653    fn drop_previous_version(
2654        &self,
2655    ) -> Result<crate::previous_version::PreviousVersionDiscardOutcome, String> {
2656        let Some(replica_path) = self.previous_version_replica_path() else {
2657            return Ok(crate::previous_version::PreviousVersionDiscardOutcome {
2658                present: false,
2659                discarded: false,
2660            });
2661        };
2662        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
2663    }
2664
2665    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
2666    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
2667        let Some(lease) = &self.lease_state else {
2668            return false;
2669        };
2670        if lease.error_code.is_some() {
2671            return true;
2672        }
2673        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
2674    }
2675
2676    /// RFC 0005 D7: §7.4.5 replacement coverage — no active unit is
2677    /// bootstrapping. An empty active set is not completion (mirrors the TS
2678    /// predicate exactly).
2679    fn previous_version_coverage_complete(&self) -> Result<bool, String> {
2680        let units = self.stored(load_units(&self.conn))?;
2681        let mut active = units
2682            .iter()
2683            .filter(|unit| unit.status == UnitStatus::Active)
2684            .peekable();
2685        Ok(active.peek().is_some() && !active.any(UnitRecord::bootstrapping))
2686    }
2687
2688    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
2689    /// apply. A container that no longer has a reason to exist is discarded so
2690    /// the rows do not outlive the window they were kept for.
2691    fn previous_version_lifetime_check(&self) {
2692        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
2693            return;
2694        };
2695        let now_ms = self.clock_now_ms();
2696        if self.previous_version_lease_inactive(now_ms) {
2697            let _ = self.drop_previous_version();
2698            return;
2699        }
2700        let Some(replica_path) = self.previous_version_replica_path() else {
2701            return;
2702        };
2703        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
2704        else {
2705            return;
2706        };
2707        let Ok(units) = load_units(&self.conn) else {
2708            return;
2709        };
2710        if units.iter().any(|unit| unit.status == UnitStatus::Revoked) {
2711            let _ = self.drop_previous_version();
2712            return;
2713        }
2714        if now_ms - record.created_at_ms > config.max_age_ms {
2715            let _ = self.drop_previous_version();
2716            return;
2717        }
2718        if self.previous_version_coverage_complete() == Ok(true) {
2719            let _ = self.drop_previous_version();
2720        }
2721    }
2722
2723    pub fn diagnostics_snapshot(&self) -> Result<ClientDiagnosticsSnapshot, String> {
2724        let registered = self.stored(load_units(&self.conn))?;
2725        // Both meta values in one statement: the FFI host recomputes
2726        // diagnostics after every command.
2727        let (local_revision, replica_cursor): (Option<String>, Option<String>) = self.stored(
2728            self.conn
2729                .prepare_cached(
2730                    "SELECT (SELECT value FROM _syncular_meta WHERE key = ?1),
2731                            (SELECT value FROM _syncular_meta WHERE key = ?2)",
2732                )
2733                .and_then(|mut statement| {
2734                    statement.query_row([LOCAL_REVISION_KEY, REPLICA_CURSOR_KEY], |row| {
2735                        Ok((row.get(0)?, row.get(1)?))
2736                    })
2737                }),
2738        )?;
2739        let mut units = DiagnosticUnits::default();
2740        for unit in &registered {
2741            *match unit.status {
2742                UnitStatus::Revoked => &mut units.revoked,
2743                UnitStatus::Failed => &mut units.failed,
2744                UnitStatus::Active if !unit.bootstrapping() => &mut units.complete,
2745                UnitStatus::Active
2746                    if unit.reason_code.as_deref() == Some("sync.cursor_expired") =>
2747                {
2748                    &mut units.reset
2749                }
2750                UnitStatus::Active => &mut units.bootstrapping,
2751            } += 1;
2752        }
2753        units.reason_codes = registered
2754            .iter()
2755            .filter_map(|unit| unit.reason_code.as_deref().map(Self::diagnostic_code))
2756            .collect::<BTreeSet<_>>()
2757            .into_iter()
2758            .collect();
2759        let captured_at_ms = self.clock_now_ms();
2760        let lease = if let Some(error_code) = self
2761            .lease_state
2762            .as_ref()
2763            .and_then(|state| state.error_code.clone())
2764        {
2765            ClientDiagnosticsLease {
2766                state: "stopped".to_owned(),
2767                expires_at_ms: self
2768                    .lease_state
2769                    .as_ref()
2770                    .and_then(|state| state.expires_at_ms),
2771                error_code: Some(Self::diagnostic_code(&error_code)),
2772            }
2773        } else if let Some(expires_at_ms) = self
2774            .lease_state
2775            .as_ref()
2776            .and_then(|state| state.expires_at_ms)
2777        {
2778            ClientDiagnosticsLease {
2779                state: if expires_at_ms <= captured_at_ms {
2780                    "expired".to_owned()
2781                } else {
2782                    "active".to_owned()
2783                },
2784                expires_at_ms: Some(expires_at_ms),
2785                error_code: None,
2786            }
2787        } else {
2788            ClientDiagnosticsLease {
2789                state: "none".to_owned(),
2790                expires_at_ms: None,
2791                error_code: None,
2792            }
2793        };
2794        let connectivity = match self.last_round.as_ref() {
2795            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
2796            Some(round) if round.status == "succeeded" => "online",
2797            Some(round)
2798                if round.status == "failed"
2799                    && matches!(
2800                        round.cause_kind,
2801                        Some(
2802                            crate::TransportCauseKind::Network | crate::TransportCauseKind::Connect
2803                        )
2804                    ) =>
2805            {
2806                "offline"
2807            }
2808            Some(round) if round.status == "failed" && round.http_status.is_some() => "online",
2809            _ => "unknown",
2810        };
2811        Ok(ClientDiagnosticsSnapshot {
2812            version: CLIENT_DIAGNOSTICS_VERSION,
2813            captured_at_ms,
2814            host: ClientDiagnosticsHost {
2815                kind: "direct".to_owned(),
2816                role: "single".to_owned(),
2817                connectivity: connectivity.to_owned(),
2818                realtime: self.realtime_state,
2819                realtime_policy: self.realtime_policy,
2820                realtime_reason_code: self.realtime_reason_code.clone(),
2821                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
2822            },
2823            security_lifecycle: self.security_lifecycle().to_owned(),
2824            schema: ClientDiagnosticsSchema {
2825                current_version: self.schema.version,
2826                upgrading: self.upgrading,
2827                required_version: self
2828                    .schema_floor
2829                    .as_ref()
2830                    .and_then(|floor| floor.required_schema_version),
2831                latest_version: self
2832                    .schema_floor
2833                    .as_ref()
2834                    .and_then(|floor| floor.latest_schema_version),
2835            },
2836            replica: ClientDiagnosticsReplica {
2837                local_revision: local_revision
2838                    .and_then(|value| value.parse::<u64>().ok())
2839                    .unwrap_or(0)
2840                    .to_string(),
2841                sync_needed: self.sync_needed,
2842                pending_outbox: self.outbox.len(),
2843            },
2844            lease,
2845            active_units: registered
2846                .iter()
2847                .filter(|unit| unit.status == UnitStatus::Active)
2848                .count(),
2849            units,
2850            replica_cursor: replica_cursor.map_or(Ok(-1), |raw| {
2851                raw.parse()
2852                    .map_err(|_| "sync.local_corrupt: persisted unit state is invalid".to_owned())
2853            })?,
2854            last_round: self.last_round.clone(),
2855            last_change: self.last_change.clone(),
2856            storage: self.diagnostics_storage(),
2857            query_failures: self.query_failures.clone(),
2858        })
2859    }
2860
2861    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
2862        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
2863            let page_count: i64 = self
2864                .conn
2865                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
2866            let page_size: i64 = self
2867                .conn
2868                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
2869            // One cached aggregate, since the FFI host recomputes diagnostics
2870            // after every command; every invocation still reads current storage.
2871            let (outbox_bytes, outcome_entries, outcome_bytes): (i64, i64, i64) = self
2872                .conn
2873                .prepare_cached(
2874                    "SELECT (SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox),
2875                            COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0)
2876                       FROM _syncular_commit_outcomes",
2877                )?
2878                .query_row([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
2879            let blob_bytes = if self.schema_has_blobs() {
2880                self.conn
2881                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
2882                    .query_row([], |row| row.get(0))?
2883            } else {
2884                0
2885            };
2886            let pressure = self
2887                .limits
2888                .blob_cache_max_bytes
2889                .is_some_and(|limit| blob_bytes > limit);
2890            Ok(ClientDiagnosticsStorage {
2891                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
2892                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
2893                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
2894                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
2895                retained_outcome_entries: Some(outcome_entries.max(0)),
2896                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
2897                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
2898            })
2899        };
2900        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
2901            status: "unreadable".to_owned(),
2902            database_bytes_approx: None,
2903            pending_outbox_bytes_approx: None,
2904            retained_outcome_bytes_approx: None,
2905            retained_outcome_entries: None,
2906            blob_cache_bytes_approx: None,
2907            pressure_reason_code: None,
2908        })
2909    }
2910
2911    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
2912        self.change_queue.drain(..).collect()
2913    }
2914
2915    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
2916        let intents = self.sync_intent_queue.drain(..).collect();
2917        if self.transport_enabled {
2918            intents
2919        } else {
2920            vec![SyncIntent::None]
2921        }
2922    }
2923
2924    fn schedule_background_retry(&mut self) -> u64 {
2925        let delay_ms = self.retry_delay_ms;
2926        self.sync_intent_queue
2927            .push_back(SyncIntent::Background { delay_ms });
2928        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
2929        self.round_retry_delay_ms = Some(delay_ms);
2930        delay_ms
2931    }
2932
2933    fn reset_background_retry(&mut self) {
2934        self.retry_delay_ms = 250;
2935    }
2936
2937    fn retryable_transport_code(code: &str) -> bool {
2938        code == "transport.failed"
2939            || code == "transport.timeout"
2940            || code == "transport.unavailable"
2941            || code == "sync.transport_failed"
2942    }
2943
2944    /// §1.8: the transport reported a refused or unreachable endpoint. The
2945    /// native transport says `connect` (DNS failure, connection refused,
2946    /// reset, or aborted); a browser fetch transport says `network`. Repeating
2947    /// the request on a backoff repairs neither, so no retry is scheduled.
2948    fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
2949        matches!(
2950            details
2951                .as_ref()
2952                .and_then(|details| details.get("causeKind"))
2953                .and_then(serde_json::Value::as_str),
2954            Some("connect" | "network")
2955        )
2956    }
2957
2958    /// §1.5: a segment fetch that fails inside an otherwise complete round is
2959    /// invisible to the server (the round response arrived whole), so its code
2960    /// rides the following requests like a socket-round failure. A code the
2961    /// frame grammar rejects is not reportable and the previous code stays.
2962    fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
2963        if ssp2::model::valid_round_failure_code(&error.code) {
2964            self.round_failure = Some(error.code.clone());
2965        }
2966        SectionError::Transport(error)
2967    }
2968
2969    /// A failed round worth a background retry: a transport failure, or a
2970    /// §10.2 code the catalog marks retryable (clients may hardcode the
2971    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
2972    /// on the server's `retryable` flag.
2973    fn retryable_failure_code(code: &str) -> bool {
2974        Self::retryable_transport_code(code)
2975            || matches!(
2976                code,
2977                "sync.auth_required"
2978                    | "sync.auth_lease_required"
2979                    | "sync.auth_lease_revoked"
2980                    | "sync.internal_error"
2981                    | "sync.idempotency_cache_miss"
2982                    | "sync.schema_not_ready"
2983                    | "sync.segment_expired"
2984                    | "sync.rate_limited"
2985                    | "sync.websocket_connection_limit"
2986            )
2987    }
2988
2989    fn diagnostic_code(code: &str) -> String {
2990        let valid = !code.is_empty()
2991            && code.len() <= 96
2992            && code.contains('.')
2993            && code
2994                .bytes()
2995                .next()
2996                .is_some_and(|byte| byte.is_ascii_lowercase())
2997            && code.bytes().all(|byte| {
2998                byte.is_ascii_lowercase()
2999                    || byte.is_ascii_digit()
3000                    || matches!(byte, b'.' | b'_' | b'-')
3001            });
3002        if valid {
3003            code.to_owned()
3004        } else {
3005            "client.unknown_failure".to_owned()
3006        }
3007    }
3008
3009    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
3010        if self.sync_needed != value {
3011            if self.begin_observation("syncular_status").is_ok() {
3012                self.sync_needed = value;
3013                let batch = ChangeAccumulator {
3014                    status: true,
3015                    ..ChangeAccumulator::default()
3016                };
3017                if self.finish_observation("syncular_status", batch).is_err() {
3018                    self.rollback_observation("syncular_status");
3019                }
3020            } else {
3021                self.sync_needed = value;
3022            }
3023        }
3024        if value && interactive {
3025            self.sync_intent_queue.push_back(SyncIntent::Interactive);
3026        }
3027    }
3028
3029    /// A unit-store result: a row that does not parse is `sync.local_corrupt`;
3030    /// every other SQLite error keeps its storage classification.
3031    fn stored<T>(&self, result: rusqlite::Result<T>) -> Result<T, String> {
3032        result.map_err(|error| match error {
3033            rusqlite::Error::FromSqlConversionFailure(..) => {
3034                "sync.local_corrupt: persisted unit state is invalid".to_owned()
3035            }
3036            error => Self::sqlite_failure(&self.storage_failure, error),
3037        })
3038    }
3039
3040    fn sqlite_failure(
3041        storage_failure: &RefCell<Option<QueryReadFailure>>,
3042        error: rusqlite::Error,
3043    ) -> String {
3044        let message = error.to_string();
3045        let failure = QueryReadFailure::from(error);
3046        let code = failure.code;
3047        if code.is_some() && storage_failure.borrow().is_none() {
3048            *storage_failure.borrow_mut() = Some(failure);
3049        }
3050        match code {
3051            Some(code) => format!("{code}: {message}"),
3052            None => message,
3053        }
3054    }
3055
3056    fn begin_observation(&self, name: &str) -> Result<(), String> {
3057        if let Some(failure) = &self.coalesced_failure {
3058            return Err(failure.clone());
3059        }
3060        // SQLite rolls a whole transaction back on some I/O and capacity
3061        // errors; later frames must not commit on their own after that.
3062        if self.coalesced.is_some() && self.conn.is_autocommit() {
3063            return Err("the coalesced apply transaction was rolled back".to_owned());
3064        }
3065        self.conn
3066            .execute_batch(&format!("SAVEPOINT {name}"))
3067            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
3068    }
3069
3070    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
3071    /// already owns one.
3072    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
3073        if self.coalesced.is_some() || !self.conn.is_autocommit() {
3074            return Ok(());
3075        }
3076        self.conn
3077            .execute_batch("BEGIN")
3078            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3079        self.coalesced = Some(CoalescedApply {
3080            rows: 0,
3081            rollback: self.capture(),
3082        });
3083        Ok(())
3084    }
3085
3086    fn capture(&self) -> Rollback {
3087        Rollback {
3088            outbox: self.outbox.clone(),
3089            failed_commits: self.failed_commits.clone(),
3090            conflicts: self.conflicts.clone(),
3091            rejections: self.rejections.clone(),
3092            schema_floor: self.schema_floor.clone(),
3093            lease_state: self.lease_state.clone(),
3094            stopped: self.stopped,
3095            upgrading: self.upgrading,
3096            sync_needed: self.sync_needed,
3097            active_round: self.active_round,
3098            overlay_dirty: self.overlay_dirty.snapshot(),
3099            change_queue: self.change_queue.len(),
3100            sync_intent_queue: self.sync_intent_queue.len(),
3101            last_change: self.last_change.clone(),
3102        }
3103    }
3104
3105    fn restore(&mut self, rollback: Rollback) {
3106        self.outbox = rollback.outbox;
3107        self.failed_commits = rollback.failed_commits;
3108        self.conflicts = rollback.conflicts;
3109        self.rejections = rollback.rejections;
3110        self.schema_floor = rollback.schema_floor;
3111        self.lease_state = rollback.lease_state;
3112        self.stopped = rollback.stopped;
3113        self.upgrading = rollback.upgrading;
3114        self.sync_needed = rollback.sync_needed;
3115        self.active_round = rollback.active_round;
3116        self.overlay_dirty.restore(rollback.overlay_dirty);
3117        self.change_queue.truncate(rollback.change_queue);
3118        self.sync_intent_queue.truncate(rollback.sync_intent_queue);
3119        self.last_change = rollback.last_change;
3120    }
3121
3122    /// Account the rows of the next frame or block. A unit that would take the
3123    /// coalesced transaction past the bound commits the earlier units first.
3124    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
3125        let Some(open) = &self.coalesced else {
3126            return Ok(());
3127        };
3128        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
3129            self.commit_coalesced_apply()?;
3130            self.begin_coalesced_apply()?;
3131        }
3132        if let Some(open) = &mut self.coalesced {
3133            open.rows += rows;
3134        }
3135        Ok(())
3136    }
3137
3138    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
3139    /// frame back, restores the in-memory state of its `BEGIN`, drops the
3140    /// change batches and sync intents queued since, and fails the rest of the
3141    /// response.
3142    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
3143        let Some(open) = self.coalesced.take() else {
3144            return Ok(());
3145        };
3146        let committed = if self.conn.is_autocommit() {
3147            Err("the coalesced apply transaction was rolled back".to_owned())
3148        } else {
3149            self.conn
3150                .execute_batch("COMMIT")
3151                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
3152        };
3153        let Err(error) = committed else {
3154            return Ok(());
3155        };
3156        if !self.conn.is_autocommit() {
3157            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3158                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
3159            }
3160        }
3161        self.restore(open.rollback);
3162        self.coalesced_failure = Some(error.clone());
3163        Err(error)
3164    }
3165
3166    /// End the response's coalescing: COMMIT what is open and clear a failure.
3167    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
3168        let committed = self.commit_coalesced_apply();
3169        match self.coalesced_failure.take() {
3170            Some(failure) => Err(failure),
3171            None => committed,
3172        }
3173    }
3174
3175    fn rollback_observation(&self, name: &str) {
3176        if let Err(error) = self
3177            .conn
3178            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
3179        {
3180            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
3181                if failure.rollback_failure.is_none() {
3182                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
3183                }
3184            }
3185        }
3186    }
3187
3188    fn finish_observation(
3189        &mut self,
3190        name: &str,
3191        mut batch: ChangeAccumulator,
3192    ) -> Result<(), String> {
3193        #[cfg(feature = "bench-internals")]
3194        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
3195        if self.failed_commits.iter().any(|(commit, _)| {
3196            commit
3197                .ops
3198                .iter()
3199                .any(|operation| batch.tables.contains_key(&operation.table))
3200        }) {
3201            batch.outcomes = true;
3202        }
3203        if !batch.touched() {
3204            self.conn
3205                .execute_batch(&format!("RELEASE {name}"))
3206                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3207            return Ok(());
3208        }
3209        let revision = self
3210            .local_revision()?
3211            .checked_add(1)
3212            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
3213        self.conn
3214            .execute(
3215                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
3216                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
3217            )
3218            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3219        let status = batch.status.then(|| self.status_snapshot());
3220        let event = ClientChangeBatch {
3221            revision: revision.to_string(),
3222            tables: batch
3223                .tables
3224                .into_iter()
3225                .map(|(table, scope_keys)| TableChange {
3226                    table,
3227                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
3228                })
3229                .collect(),
3230            windows: batch
3231                .windows
3232                .into_iter()
3233                .map(|(base_key, units)| WindowChange {
3234                    base_key,
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        // §7.6: the selected variables of the changed window bases.
3255        let mut diagnostic_windows = event
3256            .windows
3257            .iter()
3258            .map(|entry| {
3259                entry
3260                    .base_key
3261                    .split('\0')
3262                    .next()
3263                    .unwrap_or_default()
3264                    .to_owned()
3265            })
3266            .collect::<BTreeSet<_>>()
3267            .into_iter()
3268            .collect::<Vec<_>>();
3269        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
3270            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
3271        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
3272        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
3273        self.last_change = Some(DiagnosticLastChange {
3274            revision: event.revision.clone(),
3275            recorded_at_ms: self.clock_now_ms(),
3276            tables: diagnostic_tables,
3277            windows: diagnostic_windows,
3278            domains_truncated,
3279            status_changed: event.status.is_some(),
3280            conflicts_changed: event.conflicts_changed,
3281            rejections_changed: event.rejections_changed,
3282            outcomes_changed: event.outcomes_changed,
3283        });
3284        self.change_queue.push_back(event);
3285        Ok(())
3286    }
3287
3288    fn record_scope_map(
3289        &self,
3290        batch: &mut ChangeAccumulator,
3291        table_name: &str,
3292        scopes: &[(String, Vec<String>)],
3293    ) {
3294        let Some(table) = self.schema.table(table_name) else {
3295            return;
3296        };
3297        // A bulk scoped delete whose map leaves a declared variable unnamed
3298        // cannot list the deleted rows' keys for it: the change is table-wide.
3299        if table.scope_variables.iter().any(|scope| {
3300            !scopes
3301                .iter()
3302                .any(|(variable, _)| *variable == scope.variable)
3303        }) {
3304            batch.table(table_name);
3305            return;
3306        }
3307        for (variable, values) in scopes {
3308            let Some(scope) = table
3309                .scope_variables
3310                .iter()
3311                .find(|scope| &scope.variable == variable)
3312            else {
3313                continue;
3314            };
3315            for value in values {
3316                batch.scope(table_name, format!("{}:{value}", scope.prefix));
3317            }
3318        }
3319    }
3320
3321    /// Record a row's current scope keys from the base or visible table.
3322    fn record_row_scopes(
3323        &self,
3324        batch: &mut ChangeAccumulator,
3325        table_name: &str,
3326        row_id: &str,
3327        base: bool,
3328    ) -> bool {
3329        let Some(table) = self.schema.table(table_name) else {
3330            return false;
3331        };
3332        if table.scope_variables.is_empty() {
3333            return false;
3334        }
3335        let columns = table
3336            .scope_variables
3337            .iter()
3338            .map(|scope| quote_ident(&scope.column))
3339            .collect::<Vec<_>>()
3340            .join(", ");
3341        let full_table = if base {
3342            base_table(table_name)
3343        } else {
3344            visible_table(table_name)
3345        };
3346        let sql = format!(
3347            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
3348            row_id_predicate(table)
3349        );
3350        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
3351            return false;
3352        };
3353        let values = stmt.query_row(rusqlite::params![row_id], |row| {
3354            let mut values = Vec::with_capacity(table.scope_variables.len());
3355            for index in 0..table.scope_variables.len() {
3356                values.push(row.get::<_, Option<String>>(index)?);
3357            }
3358            Ok(values)
3359        });
3360        let Ok(values) = values else {
3361            return false;
3362        };
3363        let mut recorded = false;
3364        for (scope, value) in table.scope_variables.iter().zip(values) {
3365            if let Some(value) = value {
3366                batch.scope(table_name, format!("{}:{value}", scope.prefix));
3367                recorded = true;
3368            }
3369        }
3370        recorded
3371    }
3372
3373    fn record_commit_changes(
3374        &self,
3375        batch: &mut ChangeAccumulator,
3376        tables: &[String],
3377        changes: &[ssp2::model::Change],
3378    ) {
3379        #[cfg(feature = "bench-internals")]
3380        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
3381        for change in changes {
3382            let Some(table_name) = tables.get(change.table_index as usize) else {
3383                continue;
3384            };
3385            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
3386            if let Some(table) = self.schema.table(table_name) {
3387                for (variable, value) in &change.scopes {
3388                    if let Some(scope) = table
3389                        .scope_variables
3390                        .iter()
3391                        .find(|scope| &scope.variable == variable)
3392                    {
3393                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
3394                        precise = true;
3395                    }
3396                }
3397            }
3398            if !precise {
3399                batch.table(table_name);
3400            }
3401        }
3402    }
3403
3404    fn scoped_rows_exist(
3405        &self,
3406        table_name: &str,
3407        effective: &[(String, Vec<String>)],
3408        owners: &[Vec<(String, Vec<String>)>],
3409    ) -> bool {
3410        let Some(table) = self.schema.table(table_name) else {
3411            return false;
3412        };
3413        let Ok(Some((predicate, params))) = local_scope_predicate(table, effective, owners) else {
3414            return false;
3415        };
3416        let sql = format!(
3417            "SELECT 1 FROM {} WHERE {predicate} LIMIT 1",
3418            base_table(table_name)
3419        );
3420        self.conn
3421            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
3422            .is_ok()
3423    }
3424
3425    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
3426    pub fn upgrading(&self) -> bool {
3427        self.upgrading
3428    }
3429
3430    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
3431    /// keeping this client's local database (identity, outbox, tables). The
3432    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
3433    /// version increases. Recreation runs the same marker guard as opening
3434    /// a durable replica, holds the guard and every write it authorizes under
3435    /// one write transaction, and preserves the current client (compiled
3436    /// schema, outbox, tables) on refusal.
3437    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
3438        let new_schema = parse_schema_json(schema_json)?;
3439        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
3440        self.conn
3441            .execute_batch("BEGIN IMMEDIATE")
3442            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3443        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
3444        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
3445        // state (dropped outbox commits, rejections, upgrade and floor flags,
3446        // overlay dirtiness) and appends change/intent batches. A COMMIT that
3447        // fails after the reset succeeded rolls the database back, so every
3448        // one of those fields is captured here and restored with it.
3449        let rollback = self.capture();
3450        let guarded = (|| -> Result<(), String> {
3451            // §7.4.2: the marker is re-read under the write lock the caller
3452            // holds, so a replica another process upgraded meanwhile refuses
3453            // this recreation instead of having its marker and tables
3454            // downgraded.
3455            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
3456            self.create_bookkeeping_tables()?;
3457            match marker {
3458                Some(version) if version < self.schema.version => self.run_schema_reset()?,
3459                None => {
3460                    self.create_synced_tables()?;
3461                    self.persist_schema_version()?;
3462                }
3463                _ => {}
3464            }
3465            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
3466            // durable outbox the same-version path is about to replay. Only the
3467            // outbox is reloaded: the recovery below reads it, and the caller's
3468            // rollback snapshot already covers this field.
3469            self.outbox = self.load_outbox()?.into();
3470            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
3471            // this guarded boundary once the schema and marker agree. A bump went
3472            // through `run_schema_reset`, which performs the same recovery after
3473            // it creates the new physical tables and before its final replay.
3474            self.drop_unencodable_outbox(false, None)?;
3475            if marker == Some(self.schema.version)
3476                && (!self.outbox.is_empty()
3477                    || !self.failed_commits.is_empty()
3478                    || self.has_acknowledged_rows()?)
3479            {
3480                self.overlay_dirty.set(true);
3481                self.rebuild_overlay()?;
3482            }
3483            self.prune_unknown_units(false)?;
3484            self.save_unit_scope_schema()?;
3485            // The conformance recreate is the in-memory equivalent of reopening
3486            // a durable client. Apply the same startup catch-up contract even
3487            // when the schema itself did not change.
3488            self.enqueue_startup_sync_if_needed()?;
3489            Ok(())
3490        })();
3491        let outcome = match guarded {
3492            Ok(()) => self
3493                .conn
3494                .execute_batch("COMMIT")
3495                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
3496            Err(error) => Err(error),
3497        };
3498        if let Err(error) = outcome {
3499            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3500                // Keep a failed rollback's typed storage classification.
3501                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
3502            }
3503            self.schema = prior_schema;
3504            self.restore(rollback);
3505            // Prepared insert statements were derived from the reverted schema.
3506            self.insert_sql.borrow_mut().clear();
3507            return Err(error);
3508        }
3509        Ok(())
3510    }
3511
3512    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
3513        let startup_work = !self.stopped
3514            && (!self.outbox.is_empty()
3515                || self.has_acknowledged_rows()?
3516                || !self.load_pending_evictions()?.is_empty()
3517                || self
3518                    .stored(load_units(&self.conn))?
3519                    .iter()
3520                    .any(|unit| unit.status == UnitStatus::Active));
3521        if startup_work {
3522            self.sync_needed = true;
3523            self.sync_intent_queue.push_back(SyncIntent::Interactive);
3524        }
3525        Ok(())
3526    }
3527
3528    /// A native client persists schema-floor stops across process restarts.
3529    /// Once the running app already satisfies that floor, the persisted stop
3530    /// is only stale evidence from an older server round (for example, the
3531    /// server was restarted after catching up to an app that was ahead).
3532    /// Clear it and let the normal startup pull re-negotiate. If the server is
3533    /// still incompatible it will return the floor again in that first round.
3534    fn clear_satisfied_persisted_schema_floor(&mut self) -> Result<(), String> {
3535        let satisfied = self
3536            .schema_floor
3537            .as_ref()
3538            .and_then(|floor| floor.required_schema_version)
3539            .is_some_and(|required| self.schema.version >= required);
3540        if !satisfied {
3541            return Ok(());
3542        }
3543        self.delete_meta(SCHEMA_FLOOR_KEY)?;
3544        self.schema_floor = None;
3545        self.stopped = false;
3546        Ok(())
3547    }
3548
3549    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
3550    /// path already swept it; this covers a same-version or fresh-install open,
3551    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
3552    /// an aware binary can apply.
3553    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
3554        let Some(replica_path) = self.previous_version_replica_path() else {
3555            return Ok(());
3556        };
3557        let max_age_ms = self
3558            .previous_version
3559            .filter(|config| config.enabled)
3560            .map(|config| config.max_age_ms);
3561        reconcile_previous_version_at_boot(
3562            &self.conn,
3563            &replica_path,
3564            self.schema.version,
3565            max_age_ms,
3566            self.clock_now_ms(),
3567        )
3568    }
3569
3570    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
3571    /// no file — an in-memory replica returns an empty path, and a `file:` URI
3572    /// has no plain sibling path — so the feature resolves as off there.
3573    fn previous_version_replica_path(&self) -> Option<String> {
3574        let path = self.conn.path()?;
3575        if path.is_empty() || path.starts_with("file:") {
3576            return None;
3577        }
3578        Some(path.to_owned())
3579    }
3580
3581    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
3582    /// and leaseState. Drops/recreates every synced table from the new
3583    /// schema, resets unit sync state (keeping registrations), clears
3584    /// the schema-floor stop state, rewrites the marker, drops outbox commits
3585    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
3586    fn run_schema_reset(&mut self) -> Result<(), String> {
3587        self.begin_observation("syncular_schema_reset")?;
3588        let rollback = self.capture();
3589        let mut batch = ChangeAccumulator::default();
3590        let result = self
3591            .run_schema_reset_observed(&mut batch, true)
3592            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
3593        if let Err(error) = result {
3594            self.rollback_observation("syncular_schema_reset");
3595            self.restore(rollback);
3596            return Err(error);
3597        }
3598        Ok(())
3599    }
3600
3601    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
3602        if self.get_meta(LOG_EPOCH_KEY)?.is_none() {
3603            self.begin_observation("syncular_epoch_acquisition")?;
3604            let rollback = self.capture();
3605            if let Err(error) = self.conn.execute(
3606                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
3607                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
3608            ) {
3609                self.rollback_observation("syncular_epoch_acquisition");
3610                return Err(Self::sqlite_failure(&self.storage_failure, error));
3611            }
3612            self.sync_needed = true;
3613            let batch = ChangeAccumulator {
3614                status: true,
3615                ..ChangeAccumulator::default()
3616            };
3617            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
3618                self.rollback_observation("syncular_epoch_acquisition");
3619                self.restore(rollback);
3620                return Err(error);
3621            }
3622            self.sync_intent_queue.push_back(SyncIntent::Interactive);
3623            return Ok(Vec::new());
3624        }
3625        let resets = self
3626            .stored(load_units(&self.conn))?
3627            .into_iter()
3628            .map(|unit| unit.id)
3629            .collect();
3630        self.begin_observation("syncular_log_epoch_reset")?;
3631        let rollback = self.capture();
3632        let mut batch = ChangeAccumulator::default();
3633        let result = self
3634            .run_schema_reset_observed(&mut batch, false)
3635            .and_then(|()| {
3636                self.conn
3637                    .execute(
3638                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
3639                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
3640                    )
3641                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3642                self.sync_needed = true;
3643                batch.status = true;
3644                self.finish_observation("syncular_log_epoch_reset", batch)
3645            });
3646        if let Err(error) = result {
3647            self.rollback_observation("syncular_log_epoch_reset");
3648            self.restore(rollback);
3649            return Err(error);
3650        }
3651        self.sync_intent_queue.push_back(SyncIntent::Interactive);
3652        Ok(resets)
3653    }
3654
3655    fn run_schema_reset_observed(
3656        &mut self,
3657        batch: &mut ChangeAccumulator,
3658        capture: bool,
3659    ) -> Result<(), String> {
3660        // §7.4.2: the reset is the only path that wipes local tables for a
3661        // version bump, and it rewrites the marker last. Re-read the marker
3662        // under this reset's transaction: a replica another process upgraded
3663        // meanwhile refuses here instead of being reset to this client's
3664        // older schema.
3665        read_local_schema_version(&self.conn, self.schema.version)?;
3666        self.conn
3667            .execute_batch(
3668                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
3669            )
3670            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3671        self.cancel_sync_round();
3672        self.upgrading = true;
3673        batch.status = true;
3674        // RFC 0005 D6: classify the pending outbox against the NEW compiled
3675        // schema BEFORE the wipe, and record the advisory audit. This pass
3676        // drops nothing, so the audit sees the original pending intent; the
3677        // §7.4.4 send-time drop and the intrinsic value recovery later in this
3678        // reset are the paths that remove a commit. The classification is
3679        // shared with the send-time drop so the two cannot disagree about what
3680        // is incompatible.
3681        if self.previous_version.is_some_and(|config| config.enabled) {
3682            let previous_version = self
3683                .get_meta(LOCAL_SCHEMA_VERSION_KEY)?
3684                .and_then(|value| value.parse::<i32>().ok())
3685                .unwrap_or(self.schema.version);
3686            let pending = self
3687                .outbox
3688                .iter()
3689                .map(|commit| PendingCommitAudit {
3690                    commit_id: &commit.client_commit_id,
3691                    operations: commit_audit_operations(commit),
3692                })
3693                .collect::<Vec<_>>();
3694            crate::previous_version::write_previous_version_audit(
3695                &self.conn,
3696                &crate::previous_version::build_previous_version_audit(
3697                    &self.schema,
3698                    &pending,
3699                    previous_version,
3700                    self.schema.version,
3701                    self.clock_now_ms(),
3702                ),
3703            )?;
3704        }
3705        // RFC 0005 D5: the container lives in its own FILE, so the reset can
3706        // never reach it. Sweep first (unconditional — also on the log-epoch
3707        // reset, which captures nothing), then capture BEFORE the wipe drops
3708        // any row. A refused capture records its reason and leaves no file.
3709        if let Some(replica_path) = self.previous_version_replica_path() {
3710            sweep_previous_version_container(&self.conn, &replica_path)?;
3711            if capture {
3712                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
3713                    let previous_version = self
3714                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)?
3715                        .and_then(|value| value.parse::<i32>().ok())
3716                        .unwrap_or(self.schema.version);
3717                    capture_previous_version_from_replica(
3718                        &self.conn,
3719                        &replica_path,
3720                        previous_version,
3721                        self.schema.version,
3722                        &config,
3723                        self.clock_now_ms(),
3724                    )?;
3725                }
3726            }
3727        }
3728        for table in &self.schema.tables {
3729            batch.table(&table.name);
3730        }
3731        for (base_key, value) in self.load_window_registry()? {
3732            batch.window(&base_key, &value);
3733        }
3734        if capture {
3735            self.prune_unknown_units(true)?;
3736        }
3737        // The per-table insert SQL is derived from the OLD column lists.
3738        self.insert_sql.borrow_mut().clear();
3739        self.overlay_dirty.set(true);
3740        // Drop every synced table (base + visible) that currently exists —
3741        // discovered from sqlite_master so a bump that adds/removes tables is
3742        // handled. Bookkeeping tables (`_syncular_outbox/_units/_meta`
3743        // and the blob cache) are preserved; base tables are `_syncular_base_*`
3744        // so they are matched explicitly, not by the bookkeeping filter.
3745        // Drop virtual tables first so SQLite removes every FTS shadow table
3746        // atomically instead of the generic discovery tearing it apart.
3747        let virtual_tables: Vec<String> = {
3748            let mut stmt = self
3749                .conn
3750                .prepare(
3751                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
3752                )
3753                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3754            let rows = stmt
3755                .query_map([], |row| row.get::<_, String>(0))
3756                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3757            rows.filter_map(Result::ok)
3758                .filter(|name| is_synced_table_name(name))
3759                .collect()
3760        };
3761        for name in virtual_tables {
3762            self.conn
3763                .execute_batch(&format!(
3764                    "DROP TABLE IF EXISTS {}",
3765                    quote_ident(&format!("_syncular_fts_{name}"))
3766                ))
3767                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3768            self.conn
3769                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3770                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3771        }
3772        let existing: Vec<String> = {
3773            let mut stmt = self
3774                .conn
3775                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
3776                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3777            let rows = stmt
3778                .query_map([], |row| row.get::<_, String>(0))
3779                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3780            rows.filter_map(Result::ok)
3781                .filter(|name| is_synced_table_name(name))
3782                .collect()
3783        };
3784        for name in &existing {
3785            if let Some(table) = name.strip_prefix("_syncular_base_") {
3786                batch.table(table);
3787            } else if !name.starts_with("_syncular_") {
3788                batch.table(name);
3789            }
3790        }
3791        for name in existing {
3792            self.conn
3793                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3794                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3795        }
3796        // Recreate the synced tables from the NEW schema.
3797        self.create_synced_tables()?;
3798        // Reset every unit's sync state, keeping the registration.
3799        reset_units(&self.conn)
3800            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3801        // The stop state is over: this client now ships a servable schema.
3802        self.stopped = false;
3803        self.schema_floor = None;
3804        self.delete_meta(SCHEMA_FLOOR_KEY)?;
3805        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
3806        self.persist_schema_version()?;
3807        self.save_unit_scope_schema()?;
3808        // §7.1/§7.4.4: the previous-version capture and audit above saw the
3809        // original pending intent; the synced tables now carry the new schema, so
3810        // recover intrinsically codec-invalid persisted values here, before the
3811        // final replay. A pending upsert that names a column the new schema
3812        // removed stays pending for the send-time `drop_incompatible_outbox`
3813        // prepass. The reset wrapper snapshots and restores the outbox,
3814        // rejections, and change queues, so a failed commit rolls this recovery
3815        // back with the rest of the reset.
3816        // §7.4.4: the enclosing reset observation owns the publication, so the
3817        // recovery contributes its rejection, outcome, and table domains to it.
3818        self.drop_unencodable_outbox(false, Some(batch))?;
3819        // Re-apply the surviving outbox optimistically over the empty tables.
3820        self.rebuild_overlay()?;
3821        Ok(())
3822    }
3823
3824    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
3825    /// column the current schema lacks (or a removed table) cannot be encoded
3826    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
3827    /// encodable and is validated by the server), nor can an insert-intent
3828    /// upsert that lacks a non-nullable column (§7.1, the audit's
3829    /// `missing-required-column`). Drop the commit, undo
3830    /// its optimistic projection, and raise a client-local
3831    /// `sync.outbox_incompatible` rejection.
3832    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
3833        let incompatible = self
3834            .outbox
3835            .iter()
3836            .filter(|commit| {
3837                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
3838            })
3839            .cloned()
3840            .collect::<Vec<_>>();
3841        if incompatible.is_empty() {
3842            return Ok(false);
3843        }
3844        self.drop_outbox_commits(
3845            &incompatible,
3846            OUTBOX_INCOMPATIBLE_CODE,
3847            "the persisted commit cannot encode under the current schema",
3848        )?;
3849        Ok(true)
3850    }
3851
3852    /// §7.4.4 incompatible-commit path, also taken by a refused blob upload
3853    /// (§5.9.7) with the server's code: reject every operation of `commits`,
3854    /// remove them from the outbox, and undo their optimistic rows.
3855    fn drop_outbox_commits(
3856        &mut self,
3857        incompatible: &[Arc<OutboxCommit>],
3858        code: &str,
3859        message: &str,
3860    ) -> Result<(), String> {
3861        // §7.4.4: one observation covers the durable outcome writes, the outbox
3862        // removal, the in-memory drop, and the overlay rebuild, so a storage
3863        // fault leaves the replica and the client unchanged and publishes no
3864        // change; a successful drop publishes exactly one batch carrying the
3865        // rejections, outcomes, status, and the tables whose projection changed.
3866        let rollback = self.capture();
3867        let owns_transaction = self.conn.is_autocommit();
3868        self.begin_observation("syncular_outbox_drop")?;
3869        let mut batch = ChangeAccumulator::default();
3870        let dropped = (|| -> Result<(), String> {
3871            let mut rejections = Vec::new();
3872            for commit in incompatible {
3873                let results = commit
3874                    .ops
3875                    .iter()
3876                    .enumerate()
3877                    .map(|(op_index, operation)| {
3878                        let rejection = RejectionRecord {
3879                            client_commit_id: commit.client_commit_id.clone(),
3880                            op_index: op_index as i32,
3881                            code: code.to_owned(),
3882                            message: message.to_owned(),
3883                            retryable: false,
3884                            details: None,
3885                            operation: Some(CommitOperation::from(operation)),
3886                        };
3887                        rejections.push(rejection.clone());
3888                        CommitOperationOutcome::Error { rejection }
3889                    })
3890                    .collect::<Vec<_>>();
3891                self.persist_commit_outcome(
3892                    &commit.client_commit_id,
3893                    CommitOutcomeStatus::Rejected,
3894                    &results,
3895                    Some(&commit.ops),
3896                )?;
3897                self.delete_outbox_persisted(&commit.client_commit_id)?;
3898                // A removed table has no local projection to invalidate; every
3899                // surviving table the dropped commit touched does.
3900                for operation in &commit.ops {
3901                    if self.schema.table(&operation.table).is_some() {
3902                        batch.table(&operation.table);
3903                    }
3904                }
3905            }
3906            self.prune_commit_outcomes()?;
3907            let incompatible_ids = incompatible
3908                .iter()
3909                .map(|commit| commit.client_commit_id.as_str())
3910                .collect::<BTreeSet<_>>();
3911            self.outbox
3912                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
3913            self.rejections.extend(rejections);
3914            // The dropped commits' purely-optimistic rows are undone: the
3915            // visible projection is re-derived from the surviving base + outbox.
3916            self.overlay_dirty.set(true);
3917            self.rebuild_overlay()?;
3918            batch.status = true;
3919            batch.rejections = true;
3920            batch.outcomes = true;
3921            Ok(())
3922        })();
3923        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
3924        if let Err(error) = result {
3925            // An outer RELEASE is the COMMIT. Roll back that transaction
3926            // directly: retrying RELEASE after ROLLBACK TO still needs the
3927            // reader's lock. A nested observation only rolls back its savepoint.
3928            if owns_transaction {
3929                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3930                    let mut failure = self.storage_failure.borrow_mut();
3931                    let failure =
3932                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
3933                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
3934                }
3935            } else {
3936                self.rollback_observation("syncular_outbox_drop");
3937            }
3938            self.restore(rollback);
3939            return Err(error);
3940        }
3941        Ok(())
3942    }
3943
3944    /// §7.4.3: (re)create the base + visible table pair for every synced
3945    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
3946    fn create_synced_tables(&self) -> Result<(), String> {
3947        for table in &self.schema.tables {
3948            // The base half + the visible half form the synced-table pair. An
3949            // index name is global in SQLite, so the base half's indexes are
3950            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
3951            for (full, index_prefix) in [
3952                (base_table(&table.name), "_syncular_base_"),
3953                (visible_table(&table.name), ""),
3954            ] {
3955                let mut cols: Vec<String> =
3956                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
3957                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
3958                let sql = format!(
3959                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
3960                    cols.join(", "),
3961                    quote_ident(&table.primary_key)
3962                );
3963                self.conn
3964                    .execute(&sql, [])
3965                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3966                // Local secondary indexes (CREATE INDEX subset). Created on
3967                // both halves so mirror reads hit an index on either. Runs on
3968                // both the initial create and the §7.4.3 reset recreate path.
3969                for index in &table.indexes {
3970                    let unique = if index.unique { "UNIQUE " } else { "" };
3971                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
3972                    let cols_sql = index
3973                        .columns
3974                        .iter()
3975                        .map(|c| quote_ident(c))
3976                        .collect::<Vec<_>>()
3977                        .join(", ");
3978                    let index_sql = format!(
3979                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
3980                    );
3981                    self.conn
3982                        .execute(&index_sql, [])
3983                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3984                }
3985            }
3986        }
3987        // FTS exists only on the visible half. It is a local search
3988        // projection, never a synced/base table or a wire-schema member.
3989        for table in &self.schema.tables {
3990            for index in &table.fts_indexes {
3991                self.create_fts_projection(table, index)?;
3992            }
3993        }
3994        Ok(())
3995    }
3996
3997    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
3998        let count: i64 = self
3999            .conn
4000            .query_row(
4001                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
4002                rusqlite::params![index.name],
4003                |row| row.get(0),
4004            )
4005            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4006        Ok(count > 0)
4007    }
4008
4009    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
4010        for suffix in ["ai", "ad", "au"] {
4011            self.conn
4012                .execute(
4013                    &format!(
4014                        "DROP TRIGGER IF EXISTS {}",
4015                        quote_ident(&format!("{}_{suffix}", index.name))
4016                    ),
4017                    [],
4018                )
4019                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4020        }
4021        Ok(())
4022    }
4023
4024    fn create_fts_triggers(
4025        &self,
4026        table: &TableSchema,
4027        index: &FtsIndexSchema,
4028    ) -> Result<(), String> {
4029        let fts = quote_ident(&index.name);
4030        let source = visible_table(&table.name);
4031        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
4032        let pk = quote_ident(&table.primary_key);
4033        let projection_columns = std::iter::once(source_id.clone())
4034            .chain(index.columns.iter().map(|column| quote_ident(column)))
4035            .collect::<Vec<_>>()
4036            .join(", ");
4037        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
4038            .chain(
4039                index
4040                    .columns
4041                    .iter()
4042                    .map(|column| format!("new.{}", quote_ident(column))),
4043            )
4044            .collect::<Vec<_>>()
4045            .join(", ");
4046        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4047        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)");
4048        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)");
4049        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})");
4050        let bi = quote_ident(&format!("{}_bi", index.name));
4051        let ai = quote_ident(&format!("{}_ai", index.name));
4052        let ad = quote_ident(&format!("{}_ad", index.name));
4053        let au = quote_ident(&format!("{}_au", index.name));
4054        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
4055        let sql = format!(
4056            "DROP TRIGGER IF EXISTS {bi};
4057             DROP TRIGGER IF EXISTS {ai};
4058             DROP TRIGGER IF EXISTS {ad};
4059             DROP TRIGGER IF EXISTS {au};
4060             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
4061             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
4062             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
4063             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
4064        );
4065        self.conn
4066            .execute_batch(&sql)
4067            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4068    }
4069
4070    fn rebuild_fts_projection(
4071        &self,
4072        table: &TableSchema,
4073        index: &FtsIndexSchema,
4074    ) -> Result<(), String> {
4075        let fts = quote_ident(&index.name);
4076        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
4077        let pk = quote_ident(&table.primary_key);
4078        let indexed_columns = index
4079            .columns
4080            .iter()
4081            .map(|column| quote_ident(column))
4082            .collect::<Vec<_>>();
4083        let projection_columns = std::iter::once(source_id.clone())
4084            .chain(indexed_columns.iter().cloned())
4085            .collect::<Vec<_>>()
4086            .join(", ");
4087        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4088        let sql = format!(
4089            "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};",
4090            columns = indexed_columns.join(", "),
4091            source = visible_table(&table.name),
4092        );
4093        self.conn
4094            .execute_batch(&sql)
4095            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4096    }
4097
4098    fn create_fts_projection(
4099        &self,
4100        table: &TableSchema,
4101        index: &FtsIndexSchema,
4102    ) -> Result<(), String> {
4103        self.begin_observation("syncular_fts_schema")?;
4104        let result = (|| {
4105            let existed = self.fts_projection_exists(index)?;
4106            let tokenizer = index.tokenize.replace('\'', "''");
4107            let columns = index
4108                .columns
4109                .iter()
4110                .map(|column| quote_ident(column))
4111                .collect::<Vec<_>>()
4112                .join(", ");
4113            let sql = format!(
4114            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
4115            fts = quote_ident(&index.name),
4116            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
4117        );
4118            self.conn.execute(&sql, []).map_err(|error| {
4119                format!(
4120                    "cannot create local FTS5 projection {:?}: {error}",
4121                    index.name
4122                )
4123            })?;
4124            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4125            let mapped: bool = self
4126                .conn
4127                .query_row(
4128                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
4129                    [format!("_syncular_fts_{}", index.name)],
4130                    |row| row.get(0),
4131                )
4132                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4133            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))?;
4134            if !mapped && existed {
4135                self.conn
4136                    .execute_batch(&format!(
4137                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
4138                        quote_ident(FTS_SOURCE_ID_COLUMN),
4139                        quote_ident(&index.name)
4140                    ))
4141                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4142            }
4143            self.create_fts_triggers(table, index)?;
4144            if !existed {
4145                self.rebuild_fts_projection(table, index)?;
4146            }
4147            Ok(())
4148        })();
4149        if let Err(error) = result {
4150            self.rollback_observation("syncular_fts_schema");
4151            return Err(error);
4152        }
4153        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
4154            self.rollback_observation("syncular_fts_schema");
4155            return Err(error.to_string());
4156        }
4157        Ok(())
4158    }
4159
4160    // -- persistence write-through --------------------------------------------
4161
4162    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
4163        let ops: Vec<Value> = commit
4164            .ops
4165            .iter()
4166            .map(|op| {
4167                let mut entry = serde_json::json!({
4168                    "op": if op.upsert { "upsert" } else { "delete" },
4169                    "table": op.table,
4170                    "rowId": op.row_id,
4171                    "baseVersion": op.base_version,
4172                    "values": op.values.clone().map(Value::Object),
4173                });
4174                if op.insert {
4175                    entry["insert"] = Value::Bool(true);
4176                }
4177                entry
4178            })
4179            .collect();
4180        self.conn
4181            .execute(
4182                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
4183                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
4184            )
4185            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4186        for operation in &commit.ops {
4187            let Some(values) = &operation.values else {
4188                continue;
4189            };
4190            let Some(table) = self.schema.table(&operation.table) else {
4191                continue;
4192            };
4193            for column in table
4194                .columns
4195                .iter()
4196                .filter(|column| column.ty == ColumnType::BlobRef)
4197            {
4198                let Some(Value::String(raw)) = values.get(&column.name) else {
4199                    continue;
4200                };
4201                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
4202                    continue;
4203                };
4204                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
4205                    continue;
4206                };
4207                self.conn
4208                    .execute(
4209                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
4210                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
4211                        rusqlite::params![commit.client_commit_id, blob_id],
4212                    )
4213                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4214            }
4215        }
4216        Ok(())
4217    }
4218
4219    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
4220        self.conn
4221            .execute(
4222                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
4223                rusqlite::params![client_commit_id],
4224            )
4225            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4226        self.conn
4227            .execute(
4228                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
4229                rusqlite::params![client_commit_id],
4230            )
4231            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4232        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)", [])
4233            .map(|_| ())
4234            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4235    }
4236
4237    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
4238        match status {
4239            CommitOutcomeStatus::Applied => "applied",
4240            CommitOutcomeStatus::Cached => "cached",
4241            CommitOutcomeStatus::Conflict => "conflict",
4242            CommitOutcomeStatus::Rejected => "rejected",
4243        }
4244    }
4245
4246    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
4247        match resolution {
4248            CommitOutcomeResolution::Active => "active",
4249            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
4250            CommitOutcomeResolution::Superseded => "superseded",
4251            CommitOutcomeResolution::Dismissed => "dismissed",
4252        }
4253    }
4254
4255    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
4256        match value {
4257            "applied" => Ok(CommitOutcomeStatus::Applied),
4258            "cached" => Ok(CommitOutcomeStatus::Cached),
4259            "conflict" => Ok(CommitOutcomeStatus::Conflict),
4260            "rejected" => Ok(CommitOutcomeStatus::Rejected),
4261            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
4262        }
4263    }
4264
4265    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
4266        match value {
4267            "active" => Ok(CommitOutcomeResolution::Active),
4268            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
4269            "superseded" => Ok(CommitOutcomeResolution::Superseded),
4270            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
4271            _ => Err(format!(
4272                "invalid persisted commit outcome resolution {value:?}"
4273            )),
4274        }
4275    }
4276
4277    fn persist_commit_outcome(
4278        &self,
4279        client_commit_id: &str,
4280        status: CommitOutcomeStatus,
4281        results: &[CommitOperationOutcome],
4282        operations: Option<&[OutboxOp]>,
4283    ) -> Result<(), String> {
4284        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
4285        let operations_json = operations
4286            .map(|items| {
4287                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
4288            })
4289            .transpose()
4290            .map_err(|error| error.to_string())?;
4291        self.conn
4292            .execute(
4293                "INSERT INTO _syncular_commit_outcomes (
4294                   client_commit_id, status, recorded_at_ms, results_json,
4295                   operations_json, resolution
4296                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
4297                rusqlite::params![
4298                    client_commit_id,
4299                    Self::outcome_status_name(status),
4300                    self.clock_now_ms(),
4301                    results_json,
4302                    operations_json
4303                ],
4304            )
4305            .map(|_| ())
4306            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4307    }
4308
4309    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
4310    /// the byte-envelope rule refuses cannot be journaled verbatim.
4311    fn stored_cell_is_representable(cell: &Value) -> bool {
4312        cell.is_null()
4313            || cell.is_string()
4314            || cell.is_boolean()
4315            || cell.is_number()
4316            || is_canonical_bytes_envelope(cell)
4317    }
4318
4319    fn stored_row_is_representable(value: &Value) -> bool {
4320        value
4321            .as_object()
4322            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
4323    }
4324
4325    fn stored_operation_is_representable(value: &Value) -> bool {
4326        value.get("table").is_some_and(Value::is_string)
4327            && value.get("rowId").is_some_and(Value::is_string)
4328            && value.get("baseVersion").is_none_or(|v| {
4329                v.as_i64()
4330                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
4331            })
4332            && match value.get("op").and_then(Value::as_str) {
4333                Some("upsert") => {
4334                    value
4335                        .get("values")
4336                        .is_some_and(Self::stored_row_is_representable)
4337                        && value
4338                            .get("insert")
4339                            .is_none_or(|insert| *insert == Value::Bool(true))
4340                }
4341                Some("delete") => value.get("values").is_none() && value.get("insert").is_none(),
4342                _ => false,
4343            }
4344    }
4345
4346    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
4347    /// only when the journal reader accepts the serialized operation, which is the
4348    /// rule `outcome_from_row` applies to this same row later.
4349    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
4350        serde_json::to_value(CommitOperation::from(operation))
4351            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
4352    }
4353
4354    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
4355        let StoredCommitOutcomeRow {
4356            sequence,
4357            client_commit_id,
4358            status,
4359            recorded_at_ms,
4360            results_json,
4361            operations_json,
4362            resolution,
4363            resolved_at_ms,
4364            replacement_client_commit_id,
4365        } = row;
4366        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
4367            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
4368            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
4369        {
4370            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
4371        }
4372        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
4373        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
4374        for result in &stored {
4375            let nested = match result.get("status").and_then(Value::as_str) {
4376                Some("conflict") => result.get("conflict"),
4377                Some("error") => result.get("rejection"),
4378                _ => None,
4379            };
4380            if nested
4381                .and_then(|record| record.get("operation"))
4382                .is_some_and(|op| !Self::stored_operation_is_representable(op))
4383                || (result.get("status").and_then(Value::as_str) == Some("conflict")
4384                    && nested
4385                        .and_then(|record| record.get("serverRow"))
4386                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
4387            {
4388                return Err(invalid());
4389            }
4390        }
4391        let results: Vec<CommitOperationOutcome> =
4392            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
4393        let has_conflict = results
4394            .iter()
4395            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
4396        let has_error = results
4397            .iter()
4398            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
4399        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
4400        let redacted = results.is_empty() && operations_json.is_none();
4401        if match status {
4402            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
4403            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
4404            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
4405        } {
4406            return Err(invalid());
4407        }
4408        let operations: Option<Vec<CommitOperation>> = operations_json
4409            .map(|raw| {
4410                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
4411                if !stored.iter().all(Self::stored_operation_is_representable) {
4412                    return Err(invalid());
4413                }
4414                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
4415            })
4416            .transpose()?;
4417        for result in &results {
4418            match result {
4419                CommitOperationOutcome::Conflict { conflict }
4420                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
4421                {
4422                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
4423                }
4424                CommitOperationOutcome::Error { rejection } => {
4425                    if let Some(details) = &rejection.details {
4426                        let encoded = serde_json::to_string(details).map_err(|_| {
4427                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
4428                        })?;
4429                        RejectionDetails::parse(&encoded).map_err(|_| {
4430                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
4431                        })?;
4432                    }
4433                }
4434                _ => {}
4435            }
4436        }
4437        Ok(CommitOutcome {
4438            sequence,
4439            client_commit_id,
4440            status,
4441            recorded_at_ms,
4442            results,
4443            operations,
4444            retained_rows: None,
4445            resolution: Self::parse_outcome_resolution(&resolution)?,
4446            resolved_at_ms,
4447            replacement_client_commit_id,
4448        })
4449    }
4450
4451    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
4452    fn unique_conflicts(
4453        &self,
4454        table: &TableSchema,
4455        values: &Map<String, Value>,
4456        base: bool,
4457    ) -> Result<Vec<RetainedUniqueConflict>, String> {
4458        let source = if base {
4459            base_table(&table.name)
4460        } else {
4461            visible_table(&table.name)
4462        };
4463        let mut conflicts = Vec::new();
4464        for index in table.indexes.iter().filter(|index| index.unique) {
4465            let mut params = vec![json_param_to_sql(
4466                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
4467            )?];
4468            for name in &index.columns {
4469                let column = table
4470                    .columns
4471                    .iter()
4472                    .find(|column| column.name == *name)
4473                    .ok_or("sync.local_corrupt")?;
4474                let cell = json_to_column_value(column, values.get(name))?;
4475                params.push(owned_sql_value(RowParam::Cell(&cell))?);
4476            }
4477            let sql = format!(
4478                "SELECT * FROM {source} WHERE {} != ? AND {}",
4479                quote_ident(&table.primary_key),
4480                index
4481                    .columns
4482                    .iter()
4483                    .map(|column| format!("{} = ?", quote_ident(column)))
4484                    .collect::<Vec<_>>()
4485                    .join(" AND ")
4486            );
4487            let mut statement = self
4488                .conn
4489                .prepare_cached(&sql)
4490                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4491            let records = statement
4492                .query_map(rusqlite::params_from_iter(params), |record| {
4493                    let values = table
4494                        .columns
4495                        .iter()
4496                        .enumerate()
4497                        .map(|(index, column)| {
4498                            let value = sql_ref_to_json(column, record.get_ref(index)?);
4499                            json_to_column_value(column, Some(&value)).map_err(|message| {
4500                                rusqlite::Error::ToSqlConversionFailure(message.into())
4501                            })
4502                        })
4503                        .collect::<Result<Row, _>>()?;
4504                    Ok((values, record.get::<_, i64>(table.columns.len())?))
4505                })
4506                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4507            for record in records {
4508                let (row, version) =
4509                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4510                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
4511                conflicts.push(RetainedUniqueConflict {
4512                    index: index.name.clone(),
4513                    columns: index.columns.clone(),
4514                    row_id,
4515                    server_row: table
4516                        .columns
4517                        .iter()
4518                        .zip(&row)
4519                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
4520                        .collect(),
4521                    server_version: version,
4522                });
4523            }
4524        }
4525        Ok(conflicts)
4526    }
4527
4528    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
4529        if let Some((commit, initial)) = self
4530            .failed_commits
4531            .iter()
4532            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
4533        {
4534            let mut rows = Vec::new();
4535            for operation in &commit.ops {
4536                // A table the current schema removed has no rows to retain.
4537                let Some(table) = self.schema.table(&operation.table) else {
4538                    continue;
4539                };
4540                let key =
4541                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
4542                        .map_err(|_| "sync.local_corrupt".to_owned())?;
4543                let base = self.stored_row(table, &operation.row_id, true)?;
4544                rows.push(RetainedCommitRow {
4545                    table: operation.table.clone(),
4546                    row_id: operation.row_id.clone(),
4547                    unique_conflicts: if operation.upsert {
4548                        self.unique_conflicts(
4549                            table,
4550                            initial
4551                                .get(&key)
4552                                .and_then(Value::as_object)
4553                                .or(operation.values.as_ref())
4554                                .ok_or("sync.local_corrupt")?,
4555                            true,
4556                        )?
4557                    } else {
4558                        Vec::new()
4559                    },
4560                    local_row: if operation.upsert {
4561                        initial
4562                            .get(&key)
4563                            .and_then(Value::as_object)
4564                            .cloned()
4565                            .or_else(|| operation.values.clone())
4566                    } else {
4567                        None
4568                    },
4569                    server_row: base.as_ref().map(|(values, _)| {
4570                        table
4571                            .columns
4572                            .iter()
4573                            .zip(values)
4574                            .map(|(column, value)| {
4575                                (column.name.clone(), column_value_to_json(value))
4576                            })
4577                            .collect()
4578                    }),
4579                    server_version: base.map(|(_, version)| version),
4580                });
4581            }
4582            outcome.retained_rows = Some(rows);
4583        }
4584        Ok(outcome)
4585    }
4586
4587    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
4588        let row = self
4589            .conn
4590            .query_row(
4591                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
4592                        resolution, resolved_at_ms, replacement_client_commit_id
4593                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
4594                rusqlite::params![client_commit_id],
4595                |row| {
4596                    Ok(StoredCommitOutcomeRow {
4597                        sequence: row.get(0)?,
4598                        client_commit_id: row.get(1)?,
4599                        status: row.get(2)?,
4600                        recorded_at_ms: row.get(3)?,
4601                        results_json: row.get(4)?,
4602                        operations_json: row.get(5)?,
4603                        resolution: row.get(6)?,
4604                        resolved_at_ms: row.get(7)?,
4605                        replacement_client_commit_id: row.get(8)?,
4606                    })
4607                },
4608            )
4609            .optional()
4610            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4611        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
4612            .transpose()
4613    }
4614
4615    /// Internal host step. The host must run its idle drive before finishing.
4616    #[doc(hidden)]
4617    pub fn prepare_current_base(
4618        &self,
4619        client_commit_id: &str,
4620        sequence: i64,
4621    ) -> Result<CurrentBasePreparation, CurrentBaseProof> {
4622        let outcome = self
4623            .commit_outcome(client_commit_id)
4624            .map_err(|_| CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed))?;
4625        let valid = outcome.as_ref().is_some_and(|outcome| {
4626            outcome.sequence == sequence
4627                && outcome.resolution == CommitOutcomeResolution::Active
4628                && matches!(
4629                    outcome.status,
4630                    CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
4631                )
4632                && outcome
4633                    .operations
4634                    .as_ref()
4635                    .zip(outcome.retained_rows.as_ref())
4636                    .is_some_and(|(operations, rows)| {
4637                        !rows.is_empty() && operations.len() == rows.len()
4638                    })
4639        });
4640        if !valid {
4641            return Err(CurrentBaseProof::failure(
4642                CurrentBaseProofReason::OutcomeUnavailable,
4643            ));
4644        }
4645        if !self.transport_enabled {
4646            return Err(CurrentBaseProof::failure(CurrentBaseProofReason::Offline));
4647        }
4648        Ok(CurrentBasePreparation {
4649            client_commit_id: client_commit_id.to_owned(),
4650            sequence,
4651            units: load_units(&self.conn)
4652                .map_err(|_| CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed))?,
4653            transport_epoch: self.transport_epoch,
4654            registration_epoch: self.registration_epoch,
4655            security_epoch: self.security_epoch,
4656        })
4657    }
4658
4659    /// Internal host step after the completed post-rejection idle drive.
4660    #[doc(hidden)]
4661    pub fn finish_current_base(
4662        &mut self,
4663        prepared: CurrentBasePreparation,
4664        drive: SyncOutcome,
4665    ) -> CurrentBaseProof {
4666        if !self.transport_enabled || self.transport_epoch != prepared.transport_epoch {
4667            return CurrentBaseProof::failure(CurrentBaseProofReason::Offline);
4668        }
4669        if self.registration_epoch != prepared.registration_epoch {
4670            return CurrentBaseProof::failure(CurrentBaseProofReason::MissingCoverage);
4671        }
4672        if self.security_preflight || self.security_epoch != prepared.security_epoch {
4673            return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed);
4674        }
4675        let report = match drive {
4676            SyncOutcome::Ok(report) => report,
4677            SyncOutcome::BudgetExhausted(_) => {
4678                return CurrentBaseProof::failure(CurrentBaseProofReason::BudgetExhausted)
4679            }
4680            _ => return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed),
4681        };
4682        if report.schema_floor.is_some() {
4683            return CurrentBaseProof::failure(CurrentBaseProofReason::SchemaFloor);
4684        }
4685        if self
4686            .conn
4687            .execute_batch("SAVEPOINT syncular_base_proof")
4688            .is_err()
4689        {
4690            return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
4691        }
4692        let proof = (|| {
4693            let snapshot = snapshot_read_connection(
4694                &self.conn,
4695                &SnapshotReadRequest {
4696                    units: prepared.units.iter().map(|unit| unit.id.clone()).collect(),
4697                    commit_ids: vec![prepared.client_commit_id.clone()],
4698                    ..Default::default()
4699                },
4700            )
4701            .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
4702            let current = self
4703                .commit_outcome(&prepared.client_commit_id)
4704                .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
4705            let unavailable = if report.revoked.is_empty() {
4706                CurrentBaseProofReason::OutcomeUnavailable
4707            } else {
4708                CurrentBaseProofReason::Revoked
4709            };
4710            let Some(outcome) = current else {
4711                return Err(unavailable);
4712            };
4713            if !matches!(
4714                snapshot.deliveries.first(),
4715                Some(CommitDelivery::Known { .. })
4716            ) || outcome.sequence != prepared.sequence
4717                || outcome.resolution != CommitOutcomeResolution::Active
4718                || !matches!(
4719                    outcome.status,
4720                    CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
4721                )
4722            {
4723                return Err(unavailable);
4724            }
4725            let (Some(operations), Some(rows)) = (outcome.operations, outcome.retained_rows) else {
4726                return Err(unavailable);
4727            };
4728            if rows.is_empty() || operations.len() != rows.len() {
4729                return Err(unavailable);
4730            }
4731            let after =
4732                load_units(&self.conn).map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
4733            let complete: HashSet<&str> = snapshot
4734                .units
4735                .iter()
4736                .filter_map(|unit| match unit {
4737                    UnitCatchup::Known {
4738                        id,
4739                        bootstrap_complete: true,
4740                        ..
4741                    } => Some(id.as_str()),
4742                    _ => None,
4743                })
4744                .collect();
4745            // A unit id names one scope map (§4.1), so the same id before and
4746            // after the drive is the same registration.
4747            let candidates: Vec<&UnitRecord> = after
4748                .iter()
4749                .filter(|unit| {
4750                    complete.contains(unit.id.as_str())
4751                        && prepared.units.iter().any(|before| before.id == unit.id)
4752                })
4753                .collect();
4754            let covered = |table_name: &str, row: &Map<String, Value>| {
4755                let Some(table) = self.schema.table(table_name) else {
4756                    return false;
4757                };
4758                candidates.iter().any(|unit| {
4759                    covers(table, &unit.scopes)
4760                        && pair_scopes(&unit.scopes, table_name, unit.echo.as_deref()).is_some_and(
4761                            |scopes| {
4762                                scopes.iter().all(|(variable, allowed)| {
4763                                    table
4764                                        .scope_column(variable)
4765                                        .and_then(|column| match row.get(column) {
4766                                            Some(Value::String(value)) => Some(value.clone()),
4767                                            Some(Value::Bool(value)) => Some(value.to_string()),
4768                                            Some(Value::Number(value)) => {
4769                                                value.as_f64().map(|number| {
4770                                                    ryu_js::Buffer::new()
4771                                                        .format_finite(number)
4772                                                        .to_owned()
4773                                                })
4774                                            }
4775                                            _ => None,
4776                                        })
4777                                        .is_some_and(|value| {
4778                                            !value.is_empty() && allowed.contains(&value)
4779                                        })
4780                                })
4781                            },
4782                        )
4783                })
4784            };
4785            for row in &rows {
4786                let Some(base) = row.server_row.as_ref().or(row.local_row.as_ref()) else {
4787                    return Err(if report.revoked.is_empty() {
4788                        CurrentBaseProofReason::MissingCoverage
4789                    } else {
4790                        CurrentBaseProofReason::Revoked
4791                    });
4792                };
4793                if !covered(&row.table, base)
4794                    || row
4795                        .unique_conflicts
4796                        .iter()
4797                        .any(|conflict| !covered(&row.table, &conflict.server_row))
4798                {
4799                    return Err(if report.revoked.is_empty() {
4800                        CurrentBaseProofReason::MissingCoverage
4801                    } else {
4802                        CurrentBaseProofReason::Revoked
4803                    });
4804                }
4805            }
4806            Ok(rows)
4807        })();
4808        if self
4809            .conn
4810            .execute_batch("RELEASE syncular_base_proof")
4811            .is_err()
4812        {
4813            return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
4814        }
4815        match proof {
4816            Ok(rows) => CurrentBaseProof {
4817                proven: true,
4818                rows: Some(rows),
4819                reason: None,
4820            },
4821            Err(reason) => CurrentBaseProof::failure(reason),
4822        }
4823    }
4824
4825    pub fn prove_current_base(
4826        &mut self,
4827        transport: &mut dyn Transport,
4828        client_commit_id: &str,
4829        sequence: i64,
4830        max_rounds: Option<u32>,
4831    ) -> CurrentBaseProof {
4832        let prepared = match self.prepare_current_base(client_commit_id, sequence) {
4833            Ok(prepared) => prepared,
4834            Err(proof) => return proof,
4835        };
4836        let drive = self.sync_until_idle(transport, max_rounds);
4837        self.finish_current_base(prepared, drive)
4838    }
4839
4840    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
4841        if query.limit == Some(0) {
4842            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
4843        }
4844        let mut sql = String::from(
4845            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
4846                    resolution, resolved_at_ms, replacement_client_commit_id
4847               FROM _syncular_commit_outcomes",
4848        );
4849        if query.active_only {
4850            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
4851        }
4852        sql.push_str(" ORDER BY seq DESC");
4853        if let Some(limit) = query.limit {
4854            sql.push_str(&format!(" LIMIT {limit}"));
4855        }
4856        let mut stmt = self
4857            .conn
4858            .prepare(&sql)
4859            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4860        let rows = stmt
4861            .query_map([], |row| {
4862                Ok(StoredCommitOutcomeRow {
4863                    sequence: row.get(0)?,
4864                    client_commit_id: row.get(1)?,
4865                    status: row.get(2)?,
4866                    recorded_at_ms: row.get(3)?,
4867                    results_json: row.get(4)?,
4868                    operations_json: row.get(5)?,
4869                    resolution: row.get(6)?,
4870                    resolved_at_ms: row.get(7)?,
4871                    replacement_client_commit_id: row.get(8)?,
4872                })
4873            })
4874            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4875        let mut outcomes = Vec::new();
4876        for row in rows {
4877            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
4878                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
4879            )?)?);
4880        }
4881        Ok(outcomes)
4882    }
4883
4884    fn prune_commit_outcomes(&self) -> Result<(), String> {
4885        #[cfg(test)]
4886        self.outcome_prune_count
4887            .set(self.outcome_prune_count.get() + 1);
4888        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
4889        let count = self
4890            .conn
4891            .query_row(
4892                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
4893                [],
4894                |row| row.get::<_, i64>(0),
4895            )
4896            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
4897            as usize;
4898        let excess = count.saturating_sub(max_entries);
4899        if excess == 0 {
4900            return Ok(());
4901        }
4902        let mut stmt = self
4903            .conn
4904            .prepare(
4905                "SELECT seq FROM _syncular_commit_outcomes
4906                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
4907                  ORDER BY seq ASC LIMIT ?1",
4908            )
4909            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4910        let rows = stmt
4911            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
4912            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4913        let sequences = rows
4914            .collect::<Result<Vec<_>, _>>()
4915            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4916        drop(stmt);
4917        for sequence in sequences {
4918            self.conn
4919                .execute(
4920                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
4921                    rusqlite::params![sequence],
4922                )
4923                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4924        }
4925        Ok(())
4926    }
4927
4928    // -- units (§4.1) ------------------------------------------------------------
4929
4930    /// Every registered unit in registration order (§4.1).
4931    pub fn units(&self) -> Result<Vec<UnitRecord>, String> {
4932        self.stored(load_units(&self.conn))
4933    }
4934
4935    pub fn unit(&self, id: &str) -> Result<Option<UnitRecord>, String> {
4936        self.stored(get_unit(&self.conn, id))
4937    }
4938
4939    /// Register one unit (§4.1) and return its id.
4940    pub fn subscribe(&mut self, scopes: UnitScopes) -> Result<String, String> {
4941        Ok(self.subscribe_many(vec![scopes])?.remove(0))
4942    }
4943
4944    /// Register several units in one local transaction and return their ids:
4945    /// one commit for all of them (on OPFS a commit costs milliseconds, so a
4946    /// cold start with hundreds of units pays it once). An error rolls back
4947    /// every registration of the call. Registering an active unit again
4948    /// changes nothing; a revoked unit re-arms (§3.3). A call that registers or
4949    /// re-arms a unit raises sync-needed with an interactive intent.
4950    pub fn subscribe_many(&mut self, units: Vec<UnitScopes>) -> Result<Vec<String>, String> {
4951        self.begin_observation("syncular_subscribe")?;
4952        let mut changed = false;
4953        let registered = (|| {
4954            let mut ids = Vec::with_capacity(units.len());
4955            for scopes in units {
4956                let (id, held) = self.hold_unit(scopes, true)?;
4957                changed |= held;
4958                ids.push(id);
4959            }
4960            self.conn
4961                .execute_batch("RELEASE syncular_subscribe")
4962                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4963            Ok(ids)
4964        })();
4965        let ids = match registered {
4966            Ok(ids) => ids,
4967            Err(error) => {
4968                self.rollback_observation("syncular_subscribe");
4969                return Err(error);
4970            }
4971        };
4972        if changed {
4973            self.registration_epoch = self.registration_epoch.wrapping_add(1);
4974            self.set_sync_needed(true, true);
4975        }
4976        Ok(ids)
4977    }
4978
4979    /// Release the explicit reference to a unit. The unit leaves the
4980    /// registration list when no window base holds it (§4.1); its rows stay.
4981    pub fn unsubscribe(&mut self, id: &str) -> Result<(), String> {
4982        let Some(unit) = self.stored(get_unit(&self.conn, id))? else {
4983            return Ok(());
4984        };
4985        if !unit.explicit {
4986            return Ok(());
4987        }
4988        if !self.stored(window_refs(&self.conn, id))?.is_empty() {
4989            return self.stored(save_unit(
4990                &self.conn,
4991                &UnitRecord {
4992                    explicit: false,
4993                    ..unit
4994                },
4995            ));
4996        }
4997        self.conn
4998            .execute("DELETE FROM _syncular_units WHERE id = ?1", [id])
4999            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5000        self.registration_epoch = self.registration_epoch.wrapping_add(1);
5001        if unit.status == UnitStatus::Active {
5002            self.set_sync_needed(true, true);
5003        }
5004        Ok(())
5005    }
5006
5007    /// Take one reference on a unit (§4.1): validate the scope map, register a
5008    /// new unit catching up with cursor `-1`, re-arm a revoked one (§3.3).
5009    /// Returns the id and whether the registration list changed.
5010    fn hold_unit(&mut self, scopes: UnitScopes, explicit: bool) -> Result<(String, bool), String> {
5011        if scopes.is_empty()
5012            || scopes.values().any(|value| value == "*")
5013            || !self
5014                .schema
5015                .tables
5016                .iter()
5017                .any(|table| covers(table, &scopes))
5018        {
5019            return Err("sync.invalid_subscription: a unit names one non-wildcard value per variable and covers a table".to_owned());
5020        }
5021        let id = ssp2::registration::unit_id(&scope_pairs(&scopes));
5022        let existing = self.stored(get_unit(&self.conn, &id))?;
5023        if let Some(existing) = existing
5024            .as_ref()
5025            .filter(|unit| unit.status != UnitStatus::Revoked)
5026        {
5027            if explicit && !existing.explicit {
5028                self.stored(save_unit(
5029                    &self.conn,
5030                    &UnitRecord {
5031                        explicit,
5032                        ..existing.clone()
5033                    },
5034                ))?;
5035            }
5036            return Ok((id, false));
5037        }
5038        self.delete_pending_evict(&id)?;
5039        self.stored(save_unit(
5040            &self.conn,
5041            &UnitRecord {
5042                id: id.clone(),
5043                scopes,
5044                explicit: explicit || existing.is_some_and(|unit| unit.explicit),
5045                status: UnitStatus::Active,
5046                reason_code: None,
5047                cursor: Some(-1),
5048                bootstrap_state: None,
5049                seeded: Vec::new(),
5050                echo: None,
5051            },
5052        ))?;
5053        Ok((id, true))
5054    }
5055
5056    // -- windows (§4.8) -----------------------------------------------------------
5057
5058    /// §4.8: set the windowed-in values of a base, one unit per value. Added
5059    /// values take a reference on their unit (a new unit bootstraps on the
5060    /// next sync); removed values release theirs, and a unit no reference
5061    /// holds leaves the registration list fused with its first eviction chunk
5062    /// (E1–E4). Idempotent; re-entry cancels a deferred eviction.
5063    pub fn set_window(
5064        &mut self,
5065        base: &WindowBase,
5066        units: &[String],
5067    ) -> Result<CommandEffects, String> {
5068        let base_key = window_base_key(base);
5069        let live = self.load_window_units(&base_key)?;
5070        let mut changed = false;
5071        for value in units {
5072            if live.iter().any(|(held, _)| held == value) {
5073                continue;
5074            }
5075            let rollback = self.capture();
5076            self.begin_observation("syncular_window")?;
5077            let widened = (|| {
5078                let (id, registered) = self.hold_unit(window_unit_scopes(base, value), false)?;
5079                self.sync_needed |= registered;
5080                self.conn
5081                    .execute(
5082                        "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
5083                        rusqlite::params![base_key, value, id],
5084                    )
5085                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5086                let mut batch = ChangeAccumulator::default();
5087                batch.window(&base_key, value);
5088                batch.status = true;
5089                self.finish_observation("syncular_window", batch)
5090            })();
5091            if let Err(error) = widened {
5092                self.rollback_observation("syncular_window");
5093                self.restore(rollback);
5094                return Err(error);
5095            }
5096            changed = true;
5097            self.registration_epoch = self.registration_epoch.wrapping_add(1);
5098        }
5099        for (value, id) in live {
5100            if units.contains(&value) {
5101                continue;
5102            }
5103            self.rebuild_overlay_if_dirty()?;
5104            let rollback = self.capture();
5105            self.begin_observation("syncular_window")?;
5106            let remaining = self.release_window_unit(&base_key, &value, &id);
5107            let remaining = match remaining {
5108                Ok(remaining) => remaining,
5109                Err(error) => {
5110                    self.rollback_observation("syncular_window");
5111                    self.restore(rollback);
5112                    return Err(error);
5113                }
5114            };
5115            changed = true;
5116            self.registration_epoch = self.registration_epoch.wrapping_add(1);
5117            if remaining {
5118                self.drain_pending_evictions()?;
5119            }
5120        }
5121        Ok(if changed && self.transport_enabled {
5122            CommandEffects::interactive()
5123        } else {
5124            CommandEffects::none()
5125        })
5126    }
5127
5128    /// §4.8 completeness oracle: the windowed-in values of a base and those
5129    /// whose unit is not complete. A value is complete once its unit is active
5130    /// and no longer bootstrapping; registration alone is not completeness.
5131    pub fn window_state(&self, base: &WindowBase) -> Result<WindowState, String> {
5132        persisted_window_state(&self.conn, base).map_err(|failure| failure.message)
5133    }
5134
5135    /// Registry rows for a base, ordered by value: (value, unit id).
5136    fn load_window_units(&self, base_key: &str) -> Result<Vec<(String, String)>, String> {
5137        let mut statement = self
5138            .conn
5139            .prepare_cached(
5140                "SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC",
5141            )
5142            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5143        let rows = statement
5144            .query_map([base_key], |row| Ok((row.get(0)?, row.get(1)?)))
5145            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5146        rows.collect::<Result<_, _>>()
5147            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5148    }
5149
5150    /// Every window registry row: (base key, value).
5151    fn load_window_registry(&self) -> Result<Vec<(String, String)>, String> {
5152        let mut statement = self
5153            .conn
5154            .prepare_cached("SELECT base, unit FROM _syncular_windows ORDER BY base, unit")
5155            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5156        let rows = statement
5157            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
5158            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5159        rows.collect::<Result<_, _>>()
5160            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5161    }
5162
5163    /// §4.8: release a window value's reference inside the caller's
5164    /// observation. When no reference holds the unit, unregister it fused with
5165    /// its first eviction chunk (E3): delete the rows of every covered table
5166    /// matching the persisted echo, except rows pinned by the outbox (E1) or
5167    /// owned by another unit (§3.3), and park the per-table scopes of what
5168    /// remains in the pending-eviction registry. Fails closed without a local
5169    /// scope-column mapping. Returns whether unpinned rows remain.
5170    fn release_window_unit(
5171        &mut self,
5172        base_key: &str,
5173        value: &str,
5174        id: &str,
5175    ) -> Result<bool, String> {
5176        self.conn
5177            .execute(
5178                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
5179                rusqlite::params![base_key, value],
5180            )
5181            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5182        let mut batch = ChangeAccumulator::default();
5183        batch.window(base_key, value);
5184        batch.status = true;
5185        let unit = self.stored(get_unit(&self.conn, id))?;
5186        let mut remaining = false;
5187        if let Some(unit) = unit.filter(|unit| !unit.explicit) {
5188            if self.stored(window_refs(&self.conn, id))?.is_empty() {
5189                let units = self.stored(load_units(&self.conn))?;
5190                self.conn
5191                    .execute("DELETE FROM _syncular_units WHERE id = ?1", [id])
5192                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5193                self.sync_needed = true;
5194                for table in self.schema.tables.clone() {
5195                    let Some(effective) = unit
5196                        .echo
5197                        .as_deref()
5198                        .filter(|_| covers(&table, &unit.scopes))
5199                        .and_then(|echo| pair_scopes(&unit.scopes, &table.name, Some(echo)))
5200                    else {
5201                        continue;
5202                    };
5203                    let pinned = self.pinned_row_ids(&table.name);
5204                    let owners = unit_owners(&units, &table, id);
5205                    let (more, deferred) =
5206                        self.evict_scope_rows(&table.name, &effective, &owners, &pinned)?;
5207                    remaining |= more;
5208                    if deferred {
5209                        self.save_pending_evict(id, &table.name, &effective)?;
5210                    }
5211                    self.record_scope_map(&mut batch, &table.name, &effective);
5212                }
5213            }
5214        }
5215        self.finish_observation("syncular_window", batch)?;
5216        Ok(remaining)
5217    }
5218
5219    /// §4.8 E1: delete base rows owned by the effective scopes
5220    /// ([`local_scope_predicate`]) EXCEPT pinned primary keys. Returns (more
5221    /// unpinned work, any scoped rows remain). A missing scope-column mapping
5222    /// fails without deleting rows.
5223    fn evict_scope_rows(
5224        &self,
5225        table_name: &str,
5226        effective: &[(String, Vec<String>)],
5227        owners: &[Vec<(String, Vec<String>)>],
5228        pinned: &std::collections::HashSet<String>,
5229    ) -> Result<(bool, bool), String> {
5230        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
5231        let Some((scope, mut params)) = local_scope_predicate(table, effective, owners)
5232            .map_err(|()| "sync.scope_revoked".to_owned())?
5233        else {
5234            return Ok((false, false));
5235        };
5236        let scope_params = params.len();
5237        let pk = quote_ident(&table.primary_key);
5238        // Pinned ids are rowId text; the text form matches every key type.
5239        let predicate = if pinned.is_empty() {
5240            scope.clone()
5241        } else {
5242            params.extend(pinned.iter().cloned().map(SqlValue::Text));
5243            let holes = vec!["?"; pinned.len()].join(", ");
5244            format!("{scope} AND CAST({pk} AS TEXT) NOT IN ({holes})")
5245        };
5246        let base = base_table(table_name);
5247        let visible = visible_table(table_name);
5248        let mut ids: Vec<SqlValue> = Vec::new();
5249        for source in [&base, &visible] {
5250            let mut stmt = self
5251                .conn
5252                .prepare_cached(&format!(
5253                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
5254                ))
5255                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5256            ids = stmt
5257                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
5258                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
5259                .collect::<Result<_, _>>()
5260                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5261            if !ids.is_empty() {
5262                break;
5263            }
5264        }
5265        if !ids.is_empty() {
5266            for id in &ids {
5267                self.conn
5268                    .execute(
5269                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
5270                        rusqlite::params![table_name, id],
5271                    )
5272                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5273            }
5274            let holes = vec!["?"; ids.len()].join(", ");
5275            for source in [&base, &visible] {
5276                self.conn
5277                    .execute(
5278                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
5279                        rusqlite::params_from_iter(&ids),
5280                    )
5281                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5282            }
5283        }
5284        let mut remaining = false;
5285        let mut deferred = false;
5286        for source in [&base, &visible] {
5287            remaining |= self
5288                .conn
5289                .query_row(
5290                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
5291                    rusqlite::params_from_iter(&params),
5292                    |row| row.get::<_, bool>(0),
5293                )
5294                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5295            deferred |= self
5296                .conn
5297                .query_row(
5298                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
5299                    rusqlite::params_from_iter(&params[..scope_params]),
5300                    |row| row.get::<_, bool>(0),
5301                )
5302                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5303        }
5304        Ok((remaining, deferred))
5305    }
5306
5307    /// §4.8 E1: continue deferred evictions after the outbox drains or between
5308    /// chunks, retaining outbox pins and their durable marker. A unit that
5309    /// re-entered the window has no pending entry left.
5310    fn drain_pending_evictions(&mut self) -> Result<(), String> {
5311        let pending = self.load_pending_evictions()?;
5312        if pending.is_empty() {
5313            return Ok(());
5314        }
5315        self.rebuild_overlay_if_dirty()?;
5316        for (unit_id, table_name, effective) in pending {
5317            let Some(table) = self.schema.table(&table_name).cloned() else {
5318                self.delete_pending_evict_table(&unit_id, &table_name)?;
5319                continue;
5320            };
5321            loop {
5322                std::thread::yield_now();
5323                self.begin_observation("syncular_evict")?;
5324                let chunk = (|| {
5325                    let pinned = self.pinned_row_ids(&table_name);
5326                    let owners =
5327                        unit_owners(&self.stored(load_units(&self.conn))?, &table, &unit_id);
5328                    let (remaining, deferred) =
5329                        self.evict_scope_rows(&table_name, &effective, &owners, &pinned)?;
5330                    if !deferred {
5331                        self.delete_pending_evict_table(&unit_id, &table_name)?;
5332                    }
5333                    let mut batch = ChangeAccumulator::default();
5334                    self.record_scope_map(&mut batch, &table_name, &effective);
5335                    self.finish_observation("syncular_evict", batch)?;
5336                    Ok(remaining)
5337                })();
5338                match chunk {
5339                    Ok(true) => {}
5340                    Ok(false) => break,
5341                    Err(error) => {
5342                        self.rollback_observation("syncular_evict");
5343                        return Err(error);
5344                    }
5345                }
5346            }
5347        }
5348        Ok(())
5349    }
5350
5351    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
5352    /// commit — rows that MUST NOT be evicted until the commit drains.
5353    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
5354        let mut pinned = std::collections::HashSet::new();
5355        for commit in self
5356            .outbox
5357            .iter()
5358            .map(|commit| &**commit)
5359            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
5360        {
5361            for op in &commit.ops {
5362                if op.table == table {
5363                    pinned.insert(op.row_id.clone());
5364                }
5365            }
5366        }
5367        pinned
5368    }
5369
5370    fn save_pending_evict(
5371        &self,
5372        unit_id: &str,
5373        table: &str,
5374        effective: &[(String, Vec<String>)],
5375    ) -> Result<(), String> {
5376        self.conn.execute(
5377            "INSERT OR REPLACE INTO _syncular_pending_evictions(unit_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
5378            rusqlite::params![unit_id, table, scope_map_to_json(effective).to_string()],
5379        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5380    }
5381
5382    /// Re-entry cancels every pending eviction of the unit.
5383    fn delete_pending_evict(&self, unit_id: &str) -> Result<(), String> {
5384        self.conn
5385            .execute(
5386                "DELETE FROM _syncular_pending_evictions WHERE unit_id = ?1",
5387                [unit_id],
5388            )
5389            .map(|_| ())
5390            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5391    }
5392
5393    fn delete_pending_evict_table(&self, unit_id: &str, table: &str) -> Result<(), String> {
5394        self.conn
5395            .execute(
5396                "DELETE FROM _syncular_pending_evictions WHERE unit_id = ?1 AND tbl = ?2",
5397                [unit_id, table],
5398            )
5399            .map(|_| ())
5400            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5401    }
5402
5403    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
5404        let mut stmt = self
5405            .conn
5406            .prepare("SELECT unit_id, tbl, effective_scopes FROM _syncular_pending_evictions")
5407            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5408        let rows = stmt
5409            .query_map([], |row| {
5410                Ok((
5411                    row.get::<_, String>(0)?,
5412                    row.get::<_, String>(1)?,
5413                    row.get::<_, String>(2)?,
5414                ))
5415            })
5416            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5417        rows.map(|row| {
5418            let (unit_id, table, json) =
5419                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5420            let value: Value = serde_json::from_str(&json)
5421                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
5422            let effective = json_to_scope_map(&value)
5423                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
5424            Ok((unit_id, table, effective))
5425        })
5426        .collect()
5427    }
5428
5429    /// Select failed-intent retention; reload durable retained aggregates.
5430    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
5431        self.retain_failed_commits = enabled;
5432        for (commit, _) in self.failed_commits.iter() {
5433            for operation in &commit.ops {
5434                self.overlay_dirty.table(&operation.table);
5435            }
5436        }
5437        self.failed_commits = self.load_failed_commits()?.into();
5438        for (commit, _) in self.failed_commits.iter() {
5439            for operation in &commit.ops {
5440                self.overlay_dirty.table(&operation.table);
5441            }
5442        }
5443        Ok(())
5444    }
5445
5446    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
5447        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
5448            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5449        let rows = statement
5450            .query_map([], |row| {
5451                Ok((
5452                    row.get::<_, String>(0)?,
5453                    row.get::<_, String>(1)?,
5454                    row.get::<_, String>(2)?,
5455                ))
5456            })
5457            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5458        rows.map(|row| {
5459            let (id, operations, initial) =
5460                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5461            let operations: Vec<CommitOperation> =
5462                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
5463            let initial =
5464                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
5465            Ok((
5466                OutboxCommit {
5467                    client_commit_id: id,
5468                    ops: operations
5469                        .into_iter()
5470                        .map(|op| OutboxOp {
5471                            upsert: op.op == "upsert",
5472                            table: op.table,
5473                            row_id: op.row_id,
5474                            base_version: op.base_version,
5475                            values: op.values,
5476                            insert: op.insert,
5477                        })
5478                        .collect(),
5479                },
5480                initial,
5481            ))
5482        })
5483        .collect()
5484    }
5485
5486    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
5487        let mut initial = Map::new();
5488        for operation in operations {
5489            // A table the current schema removed has no rows to retain.
5490            let Some(table) = self.schema.table(&operation.table) else {
5491                continue;
5492            };
5493            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
5494                Some(row) => Some(row),
5495                None => self.visible_row(table, &operation.row_id)?,
5496            };
5497            if let Some((values, _)) = initial_row {
5498                let mut intended: Map<String, Value> = table
5499                    .columns
5500                    .iter()
5501                    .zip(values.iter())
5502                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
5503                    .collect();
5504                if let Some(patch) = &operation.values {
5505                    intended.extend(patch.clone());
5506                }
5507                initial.insert(
5508                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
5509                        .map_err(|_| "sync.local_corrupt".to_owned())?,
5510                    Value::Object(intended),
5511                );
5512            }
5513        }
5514        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
5515            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
5516            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
5517            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5518        self.failed_commits.push((
5519            OutboxCommit {
5520                client_commit_id: id.to_owned(),
5521                ops: operations.to_vec(),
5522            },
5523            initial,
5524        ));
5525        Ok(())
5526    }
5527
5528    fn drop_failed_commits(
5529        &mut self,
5530        matches: impl Fn(&str, &Map<String, Value>) -> bool,
5531    ) -> Result<Vec<OutboxCommit>, String> {
5532        let mut doomed = self.drop_acknowledged_rows(&matches)?;
5533        for (commit, initial) in self.failed_commits.iter() {
5534            for operation in &commit.ops {
5535                let key =
5536                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
5537                        .map_err(|_| "sync.local_corrupt".to_owned())?;
5538                if initial
5539                    .get(&key)
5540                    .and_then(Value::as_object)
5541                    .is_some_and(|values| matches(&operation.table, values))
5542                {
5543                    doomed.push(commit.clone());
5544                    break;
5545                }
5546            }
5547        }
5548        for commit in &doomed {
5549            self.conn
5550                .execute(
5551                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
5552                    rusqlite::params![commit.client_commit_id],
5553                )
5554                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5555        }
5556        for commit in &doomed {
5557            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))?;
5558        }
5559        self.conflicts.retain(|record| {
5560            !doomed
5561                .iter()
5562                .any(|commit| commit.client_commit_id == record.client_commit_id)
5563        });
5564        self.rejections.retain(|record| {
5565            !doomed
5566                .iter()
5567                .any(|commit| commit.client_commit_id == record.client_commit_id)
5568        });
5569        self.failed_commits.retain(|(commit, _)| {
5570            !doomed
5571                .iter()
5572                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
5573        });
5574        for commit in &doomed {
5575            for operation in &commit.ops {
5576                self.overlay_dirty.table(&operation.table);
5577            }
5578        }
5579        Ok(doomed)
5580    }
5581
5582    /// Record one commit of local mutations. The returned commit ID is the
5583    /// §2.3 idempotency key. Failures are structured so callers read
5584    /// `code`/`message`/`details` instead of parsing a string prefix.
5585    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
5586        let prior = self.storage_failure.borrow_mut().take();
5587        let outcome = (|| -> Result<String, String> {
5588            if mutations.is_empty() {
5589                return Err(
5590                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
5591                        .to_owned(),
5592                );
5593            }
5594            let mut ops = Vec::with_capacity(mutations.len());
5595            for mutation in mutations {
5596                match mutation {
5597                    Mutation::Patch {
5598                        table,
5599                        values,
5600                        base_version,
5601                    } => {
5602                        let schema_table = self
5603                            .schema
5604                            .table(&table)
5605                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
5606                        let values = normalize_values_casing(schema_table, values)?;
5607                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
5608                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
5609                    }
5610                    Mutation::Upsert {
5611                        table,
5612                        values,
5613                        base_version,
5614                    } => {
5615                        let schema_table = self.schema.table(&table).ok_or_else(|| {
5616                            format!("sync.unknown_table: unknown table {table:?}")
5617                        })?;
5618                        // §5: value keys are accepted in snake_case AND the
5619                        // generated row types' camelCase; normalize to SQL truth
5620                        // before the pk lookup / codec see them.
5621                        let values = normalize_values_casing(schema_table, values)?;
5622                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
5623                        // §6.7 `mutate` marks every column present: fill missing
5624                        // nullable columns with NULL before the payload is built.
5625                        let values = full_row_values(schema_table, values)?;
5626                        // §5.11: validate the payload encodes with the current
5627                        // codec. A key-selection or unknown-key failure is a
5628                        // durable rejection at the push seam (§10.3), never an
5629                        // author-time error. Full-row `mutate` presents every
5630                        // column, so no stored fallback is needed.
5631                        self.validate_author_encode(schema_table, &row_id, &values)?;
5632                        // §7.1 insert intent: the row is absent from the
5633                        // visible replica at call time, before this batch
5634                        // applies anything.
5635                        let insert = !self.visible_row_exists(schema_table, &row_id)?;
5636                        ops.push(OutboxOp {
5637                            upsert: true,
5638                            table,
5639                            row_id,
5640                            base_version,
5641                            values: Some(values),
5642                            insert,
5643                        });
5644                    }
5645                    Mutation::Delete {
5646                        table,
5647                        row_id,
5648                        base_version,
5649                    } => {
5650                        if self.schema.table(&table).is_none() {
5651                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
5652                        }
5653                        // §2.4: the server rejects a rowId holding U+0000.
5654                        if row_id.contains('\0') {
5655                            return Err(
5656                                "sync.invalid_request: rowId holds U+0000 (§2.4)".to_owned()
5657                            );
5658                        }
5659                        ops.push(OutboxOp {
5660                            upsert: false,
5661                            table,
5662                            row_id,
5663                            base_version,
5664                            values: None,
5665                            insert: false,
5666                        });
5667                    }
5668                }
5669            }
5670            self.record_outbox_commit(ops)
5671        })();
5672        self.finish_authoring(prior, outcome)
5673    }
5674
5675    /// Scope the retained `storage_failure` diagnostic to this call so an
5676    /// earlier operation's failure cannot classify a new one, and surface the
5677    /// call's own classified failure with its details. A fresh classified
5678    /// failure replaces the retained diagnostic; otherwise the earlier one is
5679    /// kept for the next sync.
5680    fn finish_authoring(
5681        &self,
5682        prior: Option<QueryReadFailure>,
5683        outcome: Result<String, String>,
5684    ) -> Result<String, ClientError> {
5685        let fresh = self.storage_failure.borrow_mut().take();
5686        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
5687        match outcome {
5688            Ok(id) => Ok(id),
5689            Err(message) => Err(fresh
5690                .map(ClientError::from)
5691                .unwrap_or_else(|| ClientError::from(message))),
5692        }
5693    }
5694
5695    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
5696        let commit = OutboxCommit {
5697            client_commit_id: uuid::Uuid::new_v4().to_string(),
5698            ops,
5699        };
5700        self.begin_observation("syncular_mutation")?;
5701        let mut batch = ChangeAccumulator::default();
5702        for op in &commit.ops {
5703            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
5704            if let Some(values) = &op.values {
5705                if let Some(table) = self.schema.table(&op.table) {
5706                    for scope in &table.scope_variables {
5707                        if let Some(Value::String(value)) = values.get(&scope.column) {
5708                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
5709                            precise = true;
5710                        }
5711                    }
5712                }
5713            }
5714            if !precise {
5715                batch.table(&op.table);
5716            }
5717        }
5718        if let Err(error) = self.persist_outbox_insert(&commit) {
5719            self.rollback_observation("syncular_mutation");
5720            return Err(error);
5721        }
5722        // A clean overlay contains the FIFO fold of every previous commit, so
5723        // the new commit applies over it. §7.1: an operation that does not
5724        // apply (a secondary unique collision) fails the whole commit; the
5725        // savepoint rollback removes the outbox entry and every visible write.
5726        // The append undoes itself: a rollback capture would copy the outbox.
5727        let was_dirty = self.overlay_dirty.snapshot();
5728        let id = commit.client_commit_id.clone();
5729        let appended = self.rebuild_overlay_if_dirty().and_then(|()| {
5730            commit
5731                .ops
5732                .iter()
5733                .try_for_each(|op| self.apply_outbox_op(op))?;
5734            self.outbox.push(Arc::new(commit));
5735            batch.status = true;
5736            self.finish_observation("syncular_mutation", batch)
5737                .inspect_err(|_| drop(self.outbox.pop()))
5738        });
5739        if let Err(error) = appended {
5740            self.rollback_observation("syncular_mutation");
5741            self.overlay_dirty.restore(was_dirty);
5742            return Err(error);
5743        }
5744        Ok(id)
5745    }
5746
5747    /// Record one §6.1 sparse upsert: the primary key plus the supplied
5748    /// non-scope columns are present; every other column is absent and stays
5749    /// untouched on the server and in the local overlay. An absent local base
5750    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
5751    /// Failures are structured so callers read `code`/`message`/`details`.
5752    pub fn patch(
5753        &mut self,
5754        table: &str,
5755        row_id: &str,
5756        partial: Map<String, Value>,
5757        base_version: Option<i64>,
5758    ) -> Result<String, ClientError> {
5759        let prior = self.storage_failure.borrow_mut().take();
5760        let outcome = self.perform_patch(table, row_id, partial, base_version);
5761        self.finish_authoring(prior, outcome)
5762    }
5763
5764    /// The unscoped patch body. Public authoring entry points scope
5765    /// `storage_failure` around the whole operation, including any pre-read.
5766    fn perform_patch(
5767        &mut self,
5768        table: &str,
5769        row_id: &str,
5770        partial: Map<String, Value>,
5771        base_version: Option<i64>,
5772    ) -> Result<String, String> {
5773        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
5774        self.record_outbox_commit(vec![operation])
5775    }
5776
5777    fn prepare_patch(
5778        &self,
5779        table: &str,
5780        row_id: &str,
5781        partial: Map<String, Value>,
5782        base_version: Option<i64>,
5783    ) -> Result<OutboxOp, String> {
5784        let schema_table = self
5785            .schema
5786            .table(table)
5787            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
5788        let partial = normalize_values_casing(schema_table, partial)?;
5789        if let Some(pk) = partial.get(&schema_table.primary_key) {
5790            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
5791                return Err(format!(
5792                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
5793                ));
5794            }
5795        }
5796        if !self.visible_row_exists(schema_table, row_id)? {
5797            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
5798        }
5799        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
5800        // server accepts a present scope column whose value equals the
5801        // stored row and applies it as a no-op, so a decoded envelope
5802        // round-trips. The local row is the only value the client can prove
5803        // equality against; when the row is absent locally the client cannot
5804        // prove equality against the server's stored row and fails closed.
5805        // A primary key that is also a scope column needs no stored-row
5806        // read: the primary key in a sparse payload is by construction the
5807        // row id being patched (§6.1), so its value is proven equal already.
5808        for scope in &schema_table.scope_variables {
5809            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
5810                continue;
5811            }
5812            let Some(column) = schema_table
5813                .columns
5814                .iter()
5815                .find(|column| column.name == scope.column)
5816            else {
5817                continue;
5818            };
5819            let stored = self
5820                .conn
5821                .query_row(
5822                    &format!(
5823                        "SELECT {} FROM {} WHERE {}",
5824                        quote_ident(&scope.column),
5825                        visible_table(&schema_table.name),
5826                        row_id_predicate(schema_table)
5827                    ),
5828                    rusqlite::params![row_id],
5829                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
5830                )
5831                .optional()
5832                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5833            // Decode the supplied value through the same column-type path the
5834            // rest of the patch encoding uses, so a value that only equals
5835            // the stored one in another number representation still compares
5836            // equal.
5837            let incoming = json_to_column_value(column, partial.get(&scope.column))
5838                .ok()
5839                .map(|value| column_value_to_json(&value));
5840            // A stored blob never equals a patch value, matching the server's
5841            // scope-column comparison (§3.4): bytes are unstorable as scope
5842            // values.
5843            let equal = stored.as_ref().is_some_and(|stored| {
5844                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
5845            });
5846            if !equal {
5847                return Err(format!(
5848                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
5849                    scope.column
5850                ));
5851            }
5852        }
5853        let mut values = partial;
5854        // Presence-set semantics: a proven-equal scope column is dropped, so
5855        // the patch leaves the stored value untouched (§6.2).
5856        for scope in &schema_table.scope_variables {
5857            values.remove(&scope.column);
5858        }
5859        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
5860            // §2.4: the rowId of an integer key is its canonical decimal form.
5861            ColumnType::Integer => match row_id.parse::<i64>() {
5862                Ok(v)
5863                    if v.to_string() == row_id
5864                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
5865                            .contains(&v) =>
5866                {
5867                    Value::from(v)
5868                }
5869                _ => {
5870                    return Err(format!(
5871                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
5872                ))
5873                }
5874            },
5875            ColumnType::Boolean => match row_id {
5876                "true" => Value::Bool(true),
5877                "false" => Value::Bool(false),
5878                _ => {
5879                    return Err(format!(
5880                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
5881                ))
5882                }
5883            },
5884            _ => Value::from(row_id.to_owned()),
5885        };
5886        values.insert(schema_table.primary_key.clone(), pk_value);
5887        // §5.11: structural validation only; an absent key-id selector is
5888        // resolved at the push seam from the stored local row, and a
5889        // key-selection or unknown-key failure becomes a durable
5890        // `client.encrypt_failed` rejection there (§10.3), never an
5891        // author-time error.
5892        self.validate_author_encode(schema_table, row_id, &values)?;
5893        Ok(OutboxOp {
5894            upsert: true,
5895            table: table.to_owned(),
5896            row_id: row_id.to_owned(),
5897            base_version,
5898            values: Some(values),
5899            insert: false,
5900        })
5901    }
5902
5903    /// §5.11 key-id fallback: the stored local row's selector value as a
5904    /// positional row (only the selector slot filled). Returns None when no
5905    /// selector is configured, the row is locally absent, or the stored
5906    /// value is not a usable key id. A failed storage read is an error.
5907    fn stored_key_fallback(
5908        &self,
5909        table: &TableSchema,
5910        row_id: &str,
5911    ) -> Result<Option<Row>, String> {
5912        let Some(selector) = self.encryption.key_id_columns.get(&table.name) else {
5913            return Ok(None);
5914        };
5915        let Some(index) = table
5916            .columns
5917            .iter()
5918            .position(|column| &column.name == selector)
5919        else {
5920            return Ok(None);
5921        };
5922        let sql = format!(
5923            "SELECT {} FROM {} WHERE {}",
5924            quote_ident(selector),
5925            visible_table(&table.name),
5926            row_id_predicate(table)
5927        );
5928        let key = self
5929            .conn
5930            .query_row(&sql, rusqlite::params![row_id], |row| {
5931                row.get::<_, Option<String>>(0)
5932            })
5933            .optional()
5934            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
5935            .flatten()
5936            .filter(|key| !key.is_empty());
5937        let Some(key) = key else {
5938            return Ok(None);
5939        };
5940        let mut fallback: Row = vec![None; table.columns.len()];
5941        fallback[index] = Some(ColumnValue::String(key));
5942        Ok(Some(fallback))
5943    }
5944
5945    /// §5.11/§10.3 author-time encode check: a structural failure (a
5946    /// non-nullable NULL, a value the codec rejects) is an author-time
5947    /// error; a key-selection or unknown-key failure is deferred to the push
5948    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
5949    fn validate_author_encode(
5950        &self,
5951        table: &TableSchema,
5952        row_id: &str,
5953        values: &Map<String, Value>,
5954    ) -> Result<(), String> {
5955        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
5956            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
5957            result => result.map(|_| ()),
5958        }
5959    }
5960
5961    /// §5.11/§10.3: drop every pending commit whose push encode fails with
5962    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
5963    /// raise one durable rejection per commit. `sync()` never aborts for an
5964    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
5965    /// undoing the dropped commit's purely-optimistic rows.
5966    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
5967    /// (an envelope or a type an earlier version accepted). `with_encryption`
5968    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
5969    /// the startup boundary leaves those to the send seam, where hosts configure
5970    /// keys after open.
5971    /// Recovery runs as one observation. An enclosing observation (the guarded
5972    /// schema reset) passes its `batch`, so the rejection, outcome, and table
5973    /// domains join that change event instead of publishing a second one.
5974    fn drop_unencodable_outbox(
5975        &mut self,
5976        with_encryption: bool,
5977        batch: Option<&mut ChangeAccumulator>,
5978    ) -> Result<bool, String> {
5979        let mut failures = Vec::new();
5980        for commit in self.outbox.iter() {
5981            for op in &commit.ops {
5982                let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
5983                    continue;
5984                };
5985                // An insert lacking a non-nullable column is left to
5986                // `drop_incompatible_outbox` (§7.4.4).
5987                let Some(values) = op.presented_values(table) else {
5988                    continue;
5989                };
5990                let fallback = self.stored_key_fallback(table, &op.row_id)?;
5991                let failure = match encode_sparse_row_json(
5992                    table,
5993                    &op.row_id,
5994                    &values,
5995                    &self.encryption,
5996                    fallback.as_ref(),
5997                ) {
5998                    Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
5999                        with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
6000                    }
6001                    // §7.1/§7.4.4: a value the current codec refuses was
6002                    // persisted by a version that accepted it; it leaves
6003                    // through the incompatible-outbox path, so the FIFO
6004                    // queue never wedges.
6005                    Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
6006                        LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
6007                        OUTBOX_INCOMPATIBLE_CODE,
6008                    )),
6009                    _ => None,
6010                };
6011                if let Some((error, code)) = failure {
6012                    failures.push((commit.clone(), error, code));
6013                    break;
6014                }
6015            }
6016        }
6017        if failures.is_empty() {
6018            return Ok(false);
6019        }
6020        // §7.4.4: one observation covers the durable outcome writes, the outbox
6021        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
6022        // leaves the replica and the client unchanged, a successful recovery
6023        // publishes one batch with the rejection, outcome, and table domains, and
6024        // an ordinary open with nothing to recover publishes nothing.
6025        let rollback = self.capture();
6026        let owns_observation = batch.is_none();
6027        let owns_transaction = self.conn.is_autocommit();
6028        let mut domains = ChangeAccumulator::default();
6029        if owns_observation {
6030            self.begin_observation("syncular_value_recovery")?;
6031        }
6032        let dropped = (|| -> Result<(), String> {
6033            for (commit, message, code) in failures {
6034                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
6035                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
6036                }
6037                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
6038                let rejection = RejectionRecord {
6039                    client_commit_id: commit.client_commit_id.clone(),
6040                    op_index: 0,
6041                    code: code.to_owned(),
6042                    message,
6043                    retryable: false,
6044                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
6045                        field_paths: None,
6046                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
6047                        required_action: None,
6048                        references: None,
6049                    }),
6050                    operation: representable
6051                        .then(|| commit.ops.first())
6052                        .flatten()
6053                        .map(CommitOperation::from),
6054                };
6055                self.persist_commit_outcome(
6056                    &commit.client_commit_id,
6057                    CommitOutcomeStatus::Rejected,
6058                    &[CommitOperationOutcome::Error {
6059                        rejection: rejection.clone(),
6060                    }],
6061                    representable.then_some(commit.ops.as_slice()),
6062                )?;
6063                self.delete_outbox_persisted(&commit.client_commit_id)?;
6064                self.outbox
6065                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
6066                self.rejections.push(rejection);
6067                for operation in &commit.ops {
6068                    domains.table(operation.table.as_str());
6069                }
6070            }
6071            domains.status = true;
6072            domains.rejections = true;
6073            domains.outcomes = true;
6074            self.prune_commit_outcomes()?;
6075            // The visible projection is re-derived from the surviving base + outbox.
6076            self.overlay_dirty.set(true);
6077            self.rebuild_overlay()?;
6078            Ok(())
6079        })();
6080        let result = match batch {
6081            Some(batch) => {
6082                if dropped.is_ok() {
6083                    for table in domains.tables.keys() {
6084                        batch.table(table.as_str());
6085                    }
6086                    batch.status |= domains.status;
6087                    batch.rejections |= domains.rejections;
6088                    batch.outcomes |= domains.outcomes;
6089                }
6090                dropped
6091            }
6092            None => {
6093                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
6094            }
6095        };
6096        if let Err(error) = result {
6097            if owns_observation {
6098                if owns_transaction {
6099                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
6100                        let mut failure = self.storage_failure.borrow_mut();
6101                        let failure =
6102                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
6103                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
6104                    }
6105                } else {
6106                    self.rollback_observation("syncular_value_recovery");
6107                }
6108            }
6109            self.restore(rollback);
6110            return Err(error);
6111        }
6112        Ok(true)
6113    }
6114
6115    pub fn pending_commit_ids(&self) -> Vec<String> {
6116        self.outbox
6117            .iter()
6118            .map(|c| c.client_commit_id.clone())
6119            .collect()
6120    }
6121
6122    /// §6.1: the sparse push payload bytes of every pending upsert operation,
6123    /// FIFO — exactly what the next push round would put on the wire. Used by
6124    /// conformance B.21(h) to pin cross-core byte identity.
6125    pub fn pending_payloads(&self) -> Result<Vec<Vec<u8>>, String> {
6126        let mut payloads = Vec::new();
6127        for commit in self.outbox.iter() {
6128            for op in &commit.ops {
6129                let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
6130                    continue;
6131                };
6132                let Some(values) = op.presented_values(table) else {
6133                    continue;
6134                };
6135                let fallback = self.stored_key_fallback(table, &op.row_id)?;
6136                if let Ok(payload) = encode_sparse_row_json(
6137                    table,
6138                    &op.row_id,
6139                    &values,
6140                    &self.encryption,
6141                    fallback.as_ref(),
6142                ) {
6143                    payloads.push(payload);
6144                }
6145            }
6146        }
6147        Ok(payloads)
6148    }
6149
6150    pub fn conflicts(&self) -> &[ConflictRecord] {
6151        &self.conflicts
6152    }
6153
6154    pub fn rejections(&self) -> &[RejectionRecord] {
6155        &self.rejections
6156    }
6157
6158    pub fn resolve_commit_outcome(
6159        &mut self,
6160        input: ResolveCommitOutcomeInput,
6161    ) -> Result<CommitOutcome, String> {
6162        let current = self
6163            .commit_outcome(&input.client_commit_id)?
6164            .ok_or_else(|| {
6165                format!(
6166                    "sync.outcome_not_found: no durable outcome exists for {:?}",
6167                    input.client_commit_id
6168                )
6169            })?;
6170        if current.resolution != CommitOutcomeResolution::Active {
6171            return Ok(current);
6172        }
6173        if input.resolution == CommitOutcomeResolution::Active {
6174            return Err("sync.invalid_request: resolution must leave active state".to_owned());
6175        }
6176        match input.resolution {
6177            CommitOutcomeResolution::Superseded => {
6178                let replacement = input
6179                    .replacement_client_commit_id
6180                    .as_deref()
6181                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
6182                    .ok_or_else(|| {
6183                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
6184                            .to_owned()
6185                    })?;
6186                let _ = replacement;
6187            }
6188            _ if input.replacement_client_commit_id.is_some() => {
6189                return Err(
6190                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
6191                        .to_owned(),
6192                );
6193            }
6194            _ => {}
6195        }
6196        let allowed = match current.status {
6197            CommitOutcomeStatus::Conflict => matches!(
6198                input.resolution,
6199                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
6200            ),
6201            CommitOutcomeStatus::Rejected => {
6202                matches!(
6203                    input.resolution,
6204                    CommitOutcomeResolution::Superseded
6205                        | CommitOutcomeResolution::ResolvedKeepServer
6206                )
6207            }
6208            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
6209                input.resolution == CommitOutcomeResolution::Dismissed
6210            }
6211        };
6212        if !allowed {
6213            return Err(format!(
6214                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
6215                input.resolution, current.status
6216            ));
6217        }
6218
6219        let rollback = self.capture();
6220        self.begin_observation("syncular_outcome_resolution")?;
6221        let result = (|| {
6222            self.conn
6223                .execute(
6224                    "UPDATE _syncular_commit_outcomes
6225                        SET resolution = ?1, resolved_at_ms = ?2,
6226                            replacement_client_commit_id = ?3
6227                      WHERE client_commit_id = ?4 AND resolution = 'active'",
6228                    rusqlite::params![
6229                        Self::outcome_resolution_name(input.resolution),
6230                        self.clock_now_ms(),
6231                        input.replacement_client_commit_id,
6232                        input.client_commit_id
6233                    ],
6234                )
6235                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6236            let resolved = self
6237                .commit_outcome(&current.client_commit_id)?
6238                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
6239            self.conn
6240                .execute(
6241                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
6242                    rusqlite::params![input.client_commit_id],
6243                )
6244                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6245            for (commit, _) in self.failed_commits.iter() {
6246                if commit.client_commit_id == input.client_commit_id {
6247                    for operation in &commit.ops {
6248                        self.overlay_dirty.table(&operation.table);
6249                    }
6250                }
6251            }
6252            self.failed_commits
6253                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
6254            self.prune_commit_outcomes()?;
6255            Ok(resolved)
6256        })();
6257        let resolved = match result {
6258            Ok(outcome) => outcome,
6259            Err(error) => {
6260                self.rollback_observation("syncular_outcome_resolution");
6261                self.restore(rollback);
6262                return Err(error);
6263            }
6264        };
6265        self.conflicts
6266            .retain(|record| record.client_commit_id != current.client_commit_id);
6267        self.rejections
6268            .retain(|record| record.client_commit_id != current.client_commit_id);
6269        let mut batch = ChangeAccumulator {
6270            conflicts: current.status == CommitOutcomeStatus::Conflict,
6271            rejections: current.status == CommitOutcomeStatus::Rejected,
6272            outcomes: true,
6273            ..ChangeAccumulator::default()
6274        };
6275        if let Some(operations) = &current.operations {
6276            for operation in operations {
6277                batch.table(&operation.table);
6278            }
6279        }
6280        if let Err(error) = self
6281            .rebuild_overlay_if_dirty()
6282            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
6283        {
6284            self.rollback_observation("syncular_outcome_resolution");
6285            self.restore(rollback);
6286            return Err(error);
6287        }
6288        Ok(resolved)
6289    }
6290
6291    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
6292        self.schema_floor.as_ref()
6293    }
6294
6295    /// §7.3.5: the client's opaque auth-lease state, if any.
6296    pub fn lease_state(&self) -> Option<&LeaseState> {
6297        self.lease_state.as_ref()
6298    }
6299
6300    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
6301    /// the two lease codes set it; other errors leave leaseState untouched.
6302    fn record_lease_error(&mut self, code: &str) -> Result<(), String> {
6303        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
6304            return Ok(());
6305        }
6306        let mut next = self.lease_state.clone().unwrap_or_default();
6307        next.error_code = Some(code.to_owned());
6308        self.set_lease_state(Some(next))
6309    }
6310
6311    fn set_lease_state(&mut self, next: Option<LeaseState>) -> Result<(), String> {
6312        if self.lease_state == next {
6313            return Ok(());
6314        }
6315        self.begin_observation("syncular_lease")?;
6316        let rollback = self.capture();
6317        self.lease_state = next;
6318        let persisted = match &self.lease_state {
6319            Some(lease) => serde_json::to_string(lease)
6320                .map_err(|error| error.to_string())
6321                .and_then(|json| self.set_meta(LEASE_STATE_KEY, &json)),
6322            None => self.delete_meta(LEASE_STATE_KEY),
6323        };
6324        let batch = ChangeAccumulator {
6325            status: true,
6326            ..ChangeAccumulator::default()
6327        };
6328        if let Err(error) =
6329            persisted.and_then(|()| self.finish_observation("syncular_lease", batch))
6330        {
6331            self.rollback_observation("syncular_lease");
6332            self.restore(rollback);
6333            return Err(error);
6334        }
6335        Ok(())
6336    }
6337
6338    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) -> Result<(), String> {
6339        if self.schema_floor == next {
6340            return Ok(());
6341        }
6342        self.begin_observation("syncular_schema_floor")?;
6343        let rollback = self.capture();
6344        self.stopped = next.is_some();
6345        self.schema_floor = next;
6346        let persisted = match &self.schema_floor {
6347            Some(floor) => serde_json::to_string(floor)
6348                .map_err(|error| error.to_string())
6349                .and_then(|json| self.set_meta(SCHEMA_FLOOR_KEY, &json)),
6350            None => self.delete_meta(SCHEMA_FLOOR_KEY),
6351        };
6352        let batch = ChangeAccumulator {
6353            status: true,
6354            ..ChangeAccumulator::default()
6355        };
6356        if let Err(error) =
6357            persisted.and_then(|()| self.finish_observation("syncular_schema_floor", batch))
6358        {
6359            self.rollback_observation("syncular_schema_floor");
6360            self.restore(rollback);
6361            return Err(error);
6362        }
6363        Ok(())
6364    }
6365
6366    fn set_upgrading(&mut self, value: bool) {
6367        if self.upgrading == value {
6368            return;
6369        }
6370        if self.begin_observation("syncular_upgrading").is_err() {
6371            return;
6372        }
6373        self.upgrading = value;
6374        let batch = ChangeAccumulator {
6375            status: true,
6376            ..ChangeAccumulator::default()
6377        };
6378        if self
6379            .finish_observation("syncular_upgrading", batch)
6380            .is_err()
6381        {
6382            self.rollback_observation("syncular_upgrading");
6383        }
6384    }
6385
6386    pub fn sync_needed(&self) -> bool {
6387        self.sync_needed
6388    }
6389
6390    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
6391        let schema_table = self
6392            .schema
6393            .table(table)
6394            .ok_or_else(|| format!("unknown table {table:?}"))?;
6395        let sql = format!(
6396            "SELECT * FROM {} ORDER BY {} ASC",
6397            visible_table(table),
6398            quote_ident(&schema_table.primary_key)
6399        );
6400        let mut stmt = self
6401            .conn
6402            .prepare(&sql)
6403            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6404        let mut rows = stmt
6405            .query([])
6406            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6407        let mut out = Vec::new();
6408        while let Some(row) = rows
6409            .next()
6410            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6411        {
6412            let mut values = Map::new();
6413            for (i, column) in schema_table.columns.iter().enumerate() {
6414                let value = row
6415                    .get_ref(i)
6416                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6417                values.insert(column.name.clone(), sql_ref_to_json(column, value));
6418            }
6419            let version: i64 = row
6420                .get(schema_table.columns.len())
6421                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6422            let row_id = match values.get(&schema_table.primary_key) {
6423                Some(Value::String(s)) => s.clone(),
6424                Some(Value::Number(n)) => n.to_string(),
6425                Some(Value::Bool(b)) => b.to_string(),
6426                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
6427            };
6428            out.push(RowState {
6429                row_id,
6430                version,
6431                values,
6432            });
6433        }
6434        Ok(out)
6435    }
6436
6437    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
6438    //
6439    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
6440    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
6441    // the op with `yrs`, re-encodes the whole doc state, and records a
6442    // baseVersion-less sparse upsert carrying only the primary key and the
6443    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
6444    // the overlay re-materializes the edit immediately (optimistic apply,
6445    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
6446    // Byte-compatible with `@syncular/crdt-yjs`.
6447
6448    /// The current stored value of a `crdt` column for one row — the visible
6449    /// (optimistic) bytes, or `None` when the row is absent or the column is
6450    /// NULL (the empty document, §5.10.1). Errors if the column is not a
6451    /// `crdt` column (guards the app against a typo'd column name).
6452    #[cfg(feature = "crdt-yjs")]
6453    fn crdt_column_bytes(
6454        &self,
6455        table: &str,
6456        row_id: &str,
6457        column: &str,
6458    ) -> Result<Option<Vec<u8>>, String> {
6459        let schema_table = self
6460            .schema
6461            .table(table)
6462            .ok_or_else(|| format!("unknown table {table:?}"))?;
6463        let col = schema_table
6464            .columns
6465            .iter()
6466            .find(|c| c.name == column)
6467            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
6468        if col.ty != ColumnType::Crdt {
6469            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
6470        }
6471        let sql = format!(
6472            "SELECT {} FROM {} WHERE {}",
6473            quote_ident(column),
6474            visible_table(table),
6475            row_id_predicate(schema_table)
6476        );
6477        let bytes: Option<Vec<u8>> = self
6478            .conn
6479            .prepare_cached(&sql)
6480            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6481            .query_row(rusqlite::params![row_id], |row| {
6482                row.get::<_, Option<Vec<u8>>>(0)
6483            })
6484            .map_err(|e| match e {
6485                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
6486                other => Self::sqlite_failure(&self.storage_failure, other),
6487            })?;
6488        Ok(bytes)
6489    }
6490
6491    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
6492    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
6493    /// An absent row / NULL column is the empty document (empty string).
6494    #[cfg(feature = "crdt-yjs")]
6495    pub fn crdt_text(
6496        &self,
6497        table: &str,
6498        row_id: &str,
6499        column: &str,
6500        name: &str,
6501    ) -> Result<String, String> {
6502        let bytes = self
6503            .crdt_column_bytes(table, row_id, column)?
6504            .unwrap_or_default();
6505        crate::crdt::text(&bytes, name)
6506    }
6507
6508    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
6509    /// record the resulting crdt-only sparse operation. Returns the enqueued
6510    /// `clientCommitId`.
6511    #[cfg(feature = "crdt-yjs")]
6512    pub fn crdt_insert_text(
6513        &mut self,
6514        table: &str,
6515        row_id: &str,
6516        column: &str,
6517        name: &str,
6518        index: u32,
6519        value: &str,
6520    ) -> Result<String, ClientError> {
6521        let prior = self.storage_failure.borrow_mut().take();
6522        let outcome = (|| -> Result<String, String> {
6523            let current = self
6524                .crdt_column_bytes(table, row_id, column)?
6525                .unwrap_or_default();
6526            let update = crate::crdt::insert_text(&current, name, index, value)?;
6527            self.perform_crdt_push_update(table, row_id, column, &update)
6528        })();
6529        self.finish_authoring(prior, outcome)
6530    }
6531
6532    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
6533    /// record the resulting crdt-only sparse operation. Returns the enqueued
6534    /// `clientCommitId`.
6535    #[cfg(feature = "crdt-yjs")]
6536    pub fn crdt_delete_text(
6537        &mut self,
6538        table: &str,
6539        row_id: &str,
6540        column: &str,
6541        name: &str,
6542        index: u32,
6543        len: u32,
6544    ) -> Result<String, ClientError> {
6545        let prior = self.storage_failure.borrow_mut().take();
6546        let outcome = (|| -> Result<String, String> {
6547            let current = self
6548                .crdt_column_bytes(table, row_id, column)?
6549                .unwrap_or_default();
6550            let update = crate::crdt::delete_text(&current, name, index, len)?;
6551            self.perform_crdt_push_update(table, row_id, column, &update)
6552        })();
6553        self.finish_authoring(prior, outcome)
6554    }
6555
6556    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
6557    /// `crdt` column's current state and record the resulting crdt-only sparse
6558    /// operation. The app authored the update with its own `yrs` model.
6559    /// Returns the enqueued `clientCommitId`.
6560    #[cfg(feature = "crdt-yjs")]
6561    pub fn crdt_apply_update(
6562        &mut self,
6563        table: &str,
6564        row_id: &str,
6565        column: &str,
6566        update: &[u8],
6567    ) -> Result<String, ClientError> {
6568        let prior = self.storage_failure.borrow_mut().take();
6569        let outcome = (|| -> Result<String, String> {
6570            let current = self
6571                .crdt_column_bytes(table, row_id, column)?
6572                .unwrap_or_default();
6573            let next = crate::crdt::apply_update(&current, update)?;
6574            self.perform_crdt_push_update(table, row_id, column, &next)
6575        })();
6576        self.finish_authoring(prior, outcome)
6577    }
6578
6579    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
6580    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
6581    /// enqueue it. The server merges the crdt column and leaves every other
6582    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
6583    /// clobber a concurrent edit to another column. A locally absent row
6584    /// records the same partial operation; the server answers per §6.2.
6585    #[cfg(feature = "crdt-yjs")]
6586    fn perform_crdt_push_update(
6587        &mut self,
6588        table: &str,
6589        row_id: &str,
6590        column: &str,
6591        crdt_bytes: &[u8],
6592    ) -> Result<String, String> {
6593        let mut bytes_obj = Map::new();
6594        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
6595        let mut partial = Map::new();
6596        partial.insert(column.to_owned(), Value::Object(bytes_obj));
6597        self.perform_patch(table, row_id, partial, None)
6598    }
6599
6600    /// Run an arbitrary read-only SQL query against the local database and
6601    /// return each row as a `column-name → JSON value` map. This is the seam
6602    /// the React `useSyncQuery` live-query API needs (it takes app-authored
6603    /// SQL over the visible tables/views, not a fixed table read like
6604    /// [`read_rows`]).
6605    ///
6606    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
6607    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
6608    /// same envelope the command surface uses everywhere else. Output BLOB
6609    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
6610    ///
6611    /// The result column typing is dynamic (SQLite's stored affinity), because
6612    /// arbitrary SQL can alias, join, and compute — there is no schema column
6613    /// to consult per output cell, unlike [`read_rows`].
6614    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
6615        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
6616    }
6617
6618    /// Repository benchmark and conformance access, present only with `bench-internals`.
6619    #[cfg(feature = "bench-internals")]
6620    #[doc(hidden)]
6621    pub fn benchmark_connection(&mut self) -> &mut Connection {
6622        &mut self.conn
6623    }
6624
6625    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
6626    #[cfg(feature = "bench-internals")]
6627    #[doc(hidden)]
6628    pub fn benchmark_phases(
6629        &self,
6630        enabled: Option<bool>,
6631        reset: bool,
6632    ) -> Result<Option<Value>, String> {
6633        self.benchmark_phases.configure(enabled, reset)?;
6634        Ok(self.benchmark_phases.snapshot())
6635    }
6636
6637    /// One serialized, read-only authority observation, including durable coverage.
6638    pub fn authority_snapshot(&self) -> Result<Value, String> {
6639        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
6640        if self.limits.authority_reads.is_empty() {
6641            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
6642        }
6643        self.conn
6644            .execute_batch("SAVEPOINT authority_snapshot")
6645            .map_err(|e| e.to_string())?;
6646        let result = (|| {
6647            let revision = self
6648                .get_meta(LOCAL_REVISION_KEY)?
6649                .ok_or("sync.local_corrupt: authority revision is missing")?;
6650            let parsed_revision = revision
6651                .parse::<u64>()
6652                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
6653            if parsed_revision.to_string() != revision {
6654                return Err("sync.local_corrupt: authority revision is invalid".into());
6655            }
6656            let mut tables = Vec::new();
6657            for read in &self.limits.authority_reads {
6658                let table = self
6659                    .schema
6660                    .table(&read.table)
6661                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
6662                let scalar = |column: &str, value: &Value| -> Result<String, String> {
6663                    let ty = table
6664                        .columns
6665                        .iter()
6666                        .find(|c| c.name == column)
6667                        .expect("validated authority column")
6668                        .ty;
6669                    if ty == ColumnType::Boolean {
6670                        if let Some(value) = value.as_bool() {
6671                            return Ok(value.to_string());
6672                        }
6673                        return match value.as_i64() {
6674                            Some(0) => Ok("false".into()),
6675                            Some(1) => Ok("true".into()),
6676                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
6677                        };
6678                    }
6679                    match value {
6680                        Value::String(s) => Ok(s.clone()),
6681                        Value::Number(n) => Ok(n.to_string()),
6682                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
6683                            .get("$bigint")
6684                            .and_then(Value::as_str)
6685                            .map(str::to_owned)
6686                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
6687                        _ => {
6688                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
6689                        }
6690                    }
6691                };
6692                let matches = |row: &QueryRow| -> Result<bool, String> {
6693                    for (variable, values) in &read.scopes {
6694                        let column = table
6695                            .scope_column(variable)
6696                            .expect("validated authority scope");
6697                        let value = row
6698                            .get(column)
6699                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
6700                        if value.is_null() || !values.contains(&scalar(column, value)?) {
6701                            return Ok(false);
6702                        }
6703                    }
6704                    Ok(true)
6705                };
6706                let selected = read
6707                    .columns
6708                    .iter()
6709                    .map(|c| quote_ident(c))
6710                    .collect::<Vec<_>>()
6711                    .join(",");
6712                let mut intent = BTreeSet::new();
6713                let mut relevant = BTreeSet::new();
6714                for row in query_connection(&self.conn,
6715                    "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=?",
6716                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
6717                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
6718                    intent.insert(id.clone());
6719                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
6720                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
6721                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
6722                    }
6723                }
6724                let mut rows = BTreeMap::new();
6725                for full in [base_table(&read.table), visible_table(&read.table)] {
6726                    for mut values in query_connection(
6727                        &self.conn,
6728                        &format!(
6729                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
6730                        ),
6731                        &[],
6732                    )
6733                    .map_err(|e| e.to_string())?
6734                    {
6735                        if !matches(&values)? {
6736                            continue;
6737                        }
6738                        let id = scalar(
6739                            &table.primary_key,
6740                            values
6741                                .get(&table.primary_key)
6742                                .expect("selected primary key"),
6743                        )?;
6744                        relevant.insert(id.clone());
6745                        if full == base_table(&read.table) {
6746                            let version = values
6747                                .remove("authority_version")
6748                                .expect("selected version");
6749                            let version = version
6750                                .as_i64()
6751                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
6752                            if version >= 0 {
6753                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
6754                            }
6755                        }
6756                    }
6757                }
6758                intent.retain(|id| relevant.contains(id));
6759                let rows = rows.into_values().collect::<Vec<_>>();
6760                // Every unit covering the table, as its (unit, table) pair (§4.4).
6761                let replica = self.stored(replica_cursor(&self.conn))?;
6762                let persisted = self
6763                    .stored(load_units(&self.conn))?
6764                    .into_iter()
6765                    .filter(|unit| covers(table, &unit.scopes))
6766                    .map(|unit| {
6767                        let effective = unit.echo.as_deref().and_then(|echo| {
6768                            pair_scopes(&unit.scopes, &read.table, Some(echo))
6769                        });
6770                        let requested = read
6771                            .scopes
6772                            .keys()
6773                            .map(|v| (v.clone(), json!(unit.scopes.get(v).into_iter().collect::<Vec<_>>())))
6774                            .collect::<Map<_, _>>();
6775                        let complete = effective
6776                            .as_ref()
6777                            .is_some_and(|scopes| scopes.len() == read.scopes.len())
6778                            && unit.status == UnitStatus::Active
6779                            && !unit.bootstrapping();
6780                        let exposed = effective.map(|scopes| {
6781                            read.scopes
6782                                .keys()
6783                                .map(|v| {
6784                                    let held = scopes.iter().find(|(variable, _)| variable == v);
6785                                    (v.clone(), json!(held.map_or(&Vec::new(), |(_, values)| values)))
6786                                })
6787                                .collect::<Map<_, _>>()
6788                        });
6789                        json!({"requestedScopes": requested, "status": unit.status, "cursor": unit.cursor.unwrap_or(replica), "effectiveScopes": exposed, "complete": complete})
6790                    })
6791                    .collect::<Vec<_>>();
6792                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
6793                    match selectors.split_first() {
6794                        None => !candidates.is_empty(),
6795                        Some(((variable, values), rest)) => values.iter().all(|value| {
6796                            covered(
6797                                rest,
6798                                &candidates
6799                                    .iter()
6800                                    .copied()
6801                                    .filter(|p| {
6802                                        p["effectiveScopes"][variable.as_str()]
6803                                            .as_array()
6804                                            .is_some_and(|held| {
6805                                                held.contains(&Value::String(value.clone()))
6806                                                    || held.contains(&Value::String("*".into()))
6807                                            })
6808                                    })
6809                                    .collect::<Vec<_>>(),
6810                            )
6811                        }),
6812                    }
6813                }
6814                let coverage = if covered(
6815                    &read.scopes.iter().collect::<Vec<_>>(),
6816                    &persisted
6817                        .iter()
6818                        .filter(|p| p["complete"] == true)
6819                        .collect::<Vec<_>>(),
6820                ) {
6821                    "complete"
6822                } else if persisted.iter().any(|p| {
6823                    p["status"] == "active"
6824                        && read.scopes.iter().all(|(v, values)| {
6825                            values.iter().any(|value| {
6826                                p["requestedScopes"][v].as_array().is_some_and(|held| {
6827                                    held.contains(&Value::String(value.clone()))
6828                                })
6829                            })
6830                        })
6831                }) {
6832                    "pending"
6833                } else {
6834                    "missing"
6835                };
6836                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
6837            }
6838            Ok(
6839                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
6840            )
6841        })();
6842        match result {
6843            Ok(snapshot) => {
6844                self.conn
6845                    .execute_batch("RELEASE authority_snapshot")
6846                    .map_err(|e| e.to_string())?;
6847                Ok(snapshot)
6848            }
6849            Err(error) => {
6850                self.conn
6851                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
6852                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
6853                Err(error)
6854            }
6855        }
6856    }
6857
6858    /// Rows, coverage, and local revision from one SQLite read snapshot.
6859    /// §7.5 atomic snapshot read. An `owner` records a failure in
6860    /// `diagnostics_snapshot().query_failures` until its next successful read.
6861    pub fn query_snapshot(
6862        &mut self,
6863        sql: &str,
6864        params: &[QueryValue],
6865        coverage: &[WindowCoverage],
6866        owner: Option<QueryOwner<'_>>,
6867    ) -> Result<QuerySnapshot, String> {
6868        let result = snapshot_connection(&self.conn, sql, params, coverage);
6869        if let Some(owner) = owner {
6870            self.record_query_read(owner, result.as_ref().err());
6871        }
6872        result.map_err(|failure| failure.to_string())
6873    }
6874
6875    /// §7.6: record the outcome of one owned snapshot read, including reads a
6876    /// host ran on a separate connection. A success removes the owner's entry;
6877    /// an identical failure leaves it unchanged; a changed failure moves last.
6878    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
6879        let position = self
6880            .query_failures
6881            .iter()
6882            .position(|entry| entry.id == owner.id);
6883        let Some(failure) = failure else {
6884            if let Some(index) = position {
6885                self.query_failures.remove(index);
6886            }
6887            return;
6888        };
6889        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
6890            .into_iter()
6891            .map(str::to_owned)
6892            .collect();
6893        let code = failure.code.unwrap_or("client.query_failed").to_owned();
6894        if position.is_some_and(|index| {
6895            let entry = &self.query_failures[index];
6896            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
6897        }) {
6898            return;
6899        }
6900        if let Some(index) = position {
6901            self.query_failures.remove(index);
6902        }
6903        self.query_failures.push(DiagnosticQueryFailure {
6904            id: owner.id.to_owned(),
6905            tables,
6906            code,
6907            sqlite_code: failure.sqlite_code,
6908            at_ms: self.clock_now_ms(),
6909        });
6910        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
6911            self.query_failures.remove(0);
6912        }
6913    }
6914
6915    // -- request building ---------------------------------------------------------
6916
6917    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
6918        #[cfg(feature = "bench-internals")]
6919        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
6920        let log_epoch = self.get_meta(LOG_EPOCH_KEY).map_err(|message| {
6921            Box::new(SyncOutcome::Failed {
6922                error_code: "storage.failed".into(),
6923                message,
6924                details: None,
6925                recommended_action: None,
6926            })
6927        })?;
6928        let max_commits = self.limits.max_push_commits_per_request;
6929        let max_ops = self
6930            .limits
6931            .max_push_operations_per_request
6932            .unwrap_or(PUSH_OPS_PER_REQUEST);
6933        let byte_cap = self.limits.max_push_request_bytes;
6934        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
6935        // bare URL — capability negotiation, decided per transport.
6936        let accept = self.limits.accept.unwrap_or(if url_capable {
6937            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
6938        } else {
6939            DEFAULT_ACCEPT
6940        });
6941        let storage_failed = |message: String| {
6942            Box::new(SyncOutcome::Failed {
6943                error_code: "storage.failed".into(),
6944                message,
6945                details: None,
6946                recommended_action: None,
6947            })
6948        };
6949        let header = Frame::ReqHeader {
6950            client_id: self.client_id.clone(),
6951            schema_version: self.schema.version,
6952            log_epoch: log_epoch.clone(),
6953            outbox_id: self.get_meta(OUTBOX_ID_KEY).map_err(storage_failed)?,
6954        };
6955        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
6956        // which stays ahead of every push, pull, unit and progress frame; the
6957        // byte-capacity probes below measure this exact sequence.
6958        let mut lead = vec![header];
6959        lead.extend(
6960            self.round_failure
6961                .iter()
6962                .map(|code| Frame::RoundFailure { code: code.clone() }),
6963        );
6964        let registered: Vec<UnitRecord> = self
6965            .stored(load_units(&self.conn))
6966            .map_err(storage_failed)?
6967            .into_iter()
6968            .filter(|unit| unit.status == UnitStatus::Active)
6969            .collect();
6970        let registration_digest = ssp2::registration::registration_digest(
6971            registered
6972                .iter()
6973                .map(|unit| scope_pairs(&unit.scopes))
6974                .collect::<Vec<_>>()
6975                .iter()
6976                .map(Vec::as_slice),
6977        );
6978        let echo_digest = self
6979            .get_meta(ECHO_DIGEST_KEY)
6980            .map_err(storage_failed)?
6981            .filter(|hex| !hex.is_empty())
6982            .map(|hex| {
6983                crate::values::hex_to_bytes(&hex)
6984                    .ok()
6985                    .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
6986                    .ok_or_else(|| {
6987                        "sync.local_corrupt: persisted echo digest is invalid".to_owned()
6988                    })
6989            })
6990            .transpose()
6991            .map_err(storage_failed)?;
6992        let pull = Frame::PullHeader {
6993            limit_commits: self.limits.limit_commits.unwrap_or(0),
6994            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
6995            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
6996            accept,
6997            cursor: self
6998                .stored(replica_cursor(&self.conn))
6999                .map_err(storage_failed)?,
7000            registration_digest,
7001            echo_digest,
7002        };
7003        let registration_digest = bytes_to_hex(&registration_digest);
7004        // §4.1: the list travels when it changed since the server stored it,
7005        // after `unregistered` and after a reset (both clear the stored digest).
7006        let travels = self
7007            .get_meta(REGISTERED_DIGEST_KEY)
7008            .map_err(storage_failed)?
7009            .as_deref()
7010            != Some(registration_digest.as_str());
7011        let mut sub_frames: Vec<Frame> = Vec::new();
7012        if travels {
7013            sub_frames.extend(registered.iter().map(|unit| Frame::Unit {
7014                scopes: scope_pairs(&unit.scopes),
7015            }));
7016        }
7017        // §4.3: one PROGRESS per catching-up unit, in registration order.
7018        sub_frames.extend(registered.iter().filter_map(|unit| {
7019            Some(Frame::Progress {
7020                id: unit.id.clone(),
7021                cursor: unit.cursor?,
7022                bootstrap_state: unit.bootstrap_state.clone().map(RawJson),
7023            })
7024        }));
7025        // The fixed frames (order-independent size) and the header+pull probe
7026        // both encode canonically, so the byte budget is the exact wire size.
7027        let fixed_bytes = match byte_cap {
7028            Some(_) => {
7029                let mut frames = lead.clone();
7030                frames.push(pull.clone());
7031                frames.extend(sub_frames.iter().cloned());
7032                encode_message(&Message {
7033                    wire_version: WIRE_VERSION,
7034                    msg_kind: MsgKind::Request,
7035                    frames,
7036                })
7037                .len()
7038            }
7039            None => 0,
7040        };
7041        if let Some(cap) = byte_cap {
7042            if fixed_bytes > cap {
7043                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
7044            }
7045        }
7046        let base_bytes = match byte_cap {
7047            Some(_) => {
7048                let mut frames = lead.clone();
7049                frames.push(pull.clone());
7050                encode_message(&Message {
7051                    wire_version: WIRE_VERSION,
7052                    msg_kind: MsgKind::Request,
7053                    frames,
7054                })
7055                .len()
7056            }
7057            None => 0,
7058        };
7059        let total_outbox = if log_epoch.is_some() {
7060            self.outbox.len()
7061        } else {
7062            0
7063        };
7064        let mut push_frames: Vec<Frame> = Vec::new();
7065        let mut pushed_ids = Vec::new();
7066        let mut ops_in_request = 0usize;
7067        let mut bytes_in_request = 0usize;
7068        let mut deferred_commits = 0usize;
7069        #[cfg(feature = "bench-internals")]
7070        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
7071        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
7072            // §6.1/§7.1: the commit-count and operation caps are hard. A
7073            // first commit over the operation cap is a typed capacity error
7074            // and stays queued; a later one defers with the complete suffix.
7075            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
7076                deferred_commits = total_outbox - index;
7077                break;
7078            }
7079            if ops_in_request + commit.ops.len() > max_ops {
7080                if push_frames.is_empty() {
7081                    return Err(Self::push_capacity_outcome(
7082                        "operations",
7083                        max_ops,
7084                        commit.ops.len(),
7085                        Some(commit.client_commit_id.as_str()),
7086                    ));
7087                }
7088                deferred_commits = total_outbox - index;
7089                break;
7090            }
7091            let operations = commit
7092                .ops
7093                .iter()
7094                .map(|op| {
7095                    let payload = match self
7096                        .schema
7097                        .table(&op.table)
7098                        .and_then(|table| Some((table, op.presented_values(table)?)))
7099                    {
7100                        Some((table, values)) => {
7101                            // §0: outbox entries encode at send time with the
7102                            // current codec (validated at mutate()). §6.1: the
7103                            // values map's keys are the sparse presence set; an
7104                            // insert-intent operation presents every column (§7.1).
7105                            // §5.11: encrypted columns are encrypted here, with
7106                            // an absent key-id selector read from the stored row.
7107                            // `sync_inner` runs `drop_unencodable_outbox` first,
7108                            // so an encrypt failure cannot reach this encode; the
7109                            // author seam validated every other failure.
7110                            let fallback =
7111                                self.stored_key_fallback(table, &op.row_id)
7112                                    .map_err(|message| {
7113                                        Box::new(SyncOutcome::Failed {
7114                                            error_code: "storage.failed".into(),
7115                                            message,
7116                                            details: None,
7117                                            recommended_action: None,
7118                                        })
7119                                    })?;
7120                            encode_sparse_row_json(
7121                                table,
7122                                &op.row_id,
7123                                &values,
7124                                &self.encryption,
7125                                fallback.as_ref(),
7126                            )
7127                            .ok()
7128                        }
7129                        None => None,
7130                    };
7131                    Ok(ssp2::model::Operation {
7132                        table: op.table.clone(),
7133                        row_id: op.row_id.clone(),
7134                        op: if op.upsert { Op::Upsert } else { Op::Delete },
7135                        base_version: op.base_version,
7136                        payload,
7137                    })
7138                })
7139                .collect::<Result<Vec<_>, Box<SyncOutcome>>>()?;
7140            // §6.3.2, §7.1: the commit's outbox sequence.
7141            let client_seq = self
7142                .conn
7143                .prepare_cached("SELECT seq FROM _syncular_outbox WHERE commit_id = ?1")
7144                .and_then(|mut statement| {
7145                    statement.query_row([&commit.client_commit_id], |row| row.get(0))
7146                })
7147                .map_err(|error| {
7148                    storage_failed(Self::sqlite_failure(&self.storage_failure, error))
7149                })?;
7150            let frame = Frame::PushCommit {
7151                client_commit_id: commit.client_commit_id.clone(),
7152                client_seq,
7153                operations,
7154            };
7155            let frame_bytes = match byte_cap {
7156                Some(_) => {
7157                    let mut frames = lead.clone();
7158                    frames.push(frame.clone());
7159                    frames.push(pull.clone());
7160                    encode_message(&Message {
7161                        wire_version: WIRE_VERSION,
7162                        msg_kind: MsgKind::Request,
7163                        frames,
7164                    })
7165                    .len()
7166                        - base_bytes
7167                }
7168                None => 0,
7169            };
7170            // The head is always encoded so the caller can report the full
7171            // projected request size; a later commit defers once it no longer
7172            // fits.
7173            if let Some(cap) = byte_cap {
7174                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
7175                    deferred_commits = total_outbox - index;
7176                    break;
7177                }
7178            }
7179            push_frames.push(frame);
7180            pushed_ids.push(commit.client_commit_id.clone());
7181            ops_in_request += commit.ops.len();
7182            bytes_in_request += frame_bytes;
7183        }
7184        #[cfg(feature = "bench-internals")]
7185        drop(outbox_phase);
7186        if let Some(cap) = byte_cap {
7187            if fixed_bytes + bytes_in_request > cap {
7188                // The head alone does not fit this budget; the commit stays
7189                // queued and no transport round is attempted.
7190                let blocked = if push_frames.is_empty() {
7191                    None
7192                } else {
7193                    self.outbox.first().map(|c| c.client_commit_id.as_str())
7194                };
7195                return Err(Self::push_capacity_outcome(
7196                    "bytes",
7197                    cap,
7198                    fixed_bytes + bytes_in_request,
7199                    blocked,
7200                ));
7201            }
7202        }
7203        let mut frames = lead;
7204        frames.extend(push_frames);
7205        frames.push(pull);
7206        frames.extend(sub_frames);
7207        let message = Message {
7208            wire_version: WIRE_VERSION,
7209            msg_kind: MsgKind::Request,
7210            frames,
7211        };
7212        Ok((
7213            message,
7214            RequestMeta {
7215                pushed_ids,
7216                registration_digest,
7217                accept,
7218                deferred_commits,
7219            },
7220        ))
7221    }
7222
7223    /// §7.1: a configured push capacity limit cannot carry this request.
7224    fn push_capacity_outcome(
7225        kind: &str,
7226        limit: usize,
7227        size: usize,
7228        client_commit_id: Option<&str>,
7229    ) -> Box<SyncOutcome> {
7230        let mut details = Map::new();
7231        details.insert("kind".to_owned(), Value::from(kind));
7232        details.insert("limit".to_owned(), Value::from(limit as u64));
7233        details.insert("size".to_owned(), Value::from(size as u64));
7234        if let Some(id) = client_commit_id {
7235            details.insert("clientCommitId".to_owned(), Value::from(id));
7236        }
7237        Box::new(SyncOutcome::Failed {
7238            error_code: "client.push_request_too_large".into(),
7239            message: "the push request exceeds a configured capacity limit".into(),
7240            details: Some(Value::Object(details)),
7241            recommended_action: None,
7242        })
7243    }
7244
7245    // -- sync -------------------------------------------------------------------
7246
7247    /// Cloneable observation handle; no client lock is needed to read it.
7248    pub fn progress(&self) -> ProgressObserver {
7249        self.progress.clone()
7250    }
7251
7252    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7253        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
7254            Ok(prepared) => prepared,
7255            Err(outcome) => return *outcome,
7256        };
7257        // The round's network deadline rides `PreparedSyncRound` and is scoped
7258        // to each exchange, so uploads, continuations, the main request, and
7259        // every segment fetch share one monotonic anchor without leaving
7260        // deadline state on a reused transport.
7261        let mut next = prepared;
7262        loop {
7263            match self.apply_sync_round(next.exchange(transport)) {
7264                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
7265                crate::AppliedSyncRound::Complete {
7266                    outcome, controls, ..
7267                } => {
7268                    if self.transport_enabled {
7269                        for text in controls {
7270                            let _ = transport.realtime_send(&text);
7271                        }
7272                    } else {
7273                        self.disconnect_realtime(transport);
7274                    }
7275                    return outcome;
7276                }
7277            }
7278        }
7279    }
7280
7281    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
7282        if let SyncOutcome::Failed {
7283            error_code,
7284            message,
7285            details,
7286            ..
7287        } = &mut outcome
7288        {
7289            if let Some((failure, code)) = self
7290                .storage_failure
7291                .borrow()
7292                .as_ref()
7293                .and_then(|failure| failure.code.map(|code| (failure, code)))
7294            {
7295                *error_code = code.to_owned();
7296                *message = match code {
7297                    "client.storage_busy" => "local SQLite storage is busy",
7298                    "client.storage_full" => "local SQLite storage is full",
7299                    "client.storage_io" => "local SQLite storage I/O failed",
7300                    "client.storage_corrupt" => "local SQLite storage is corrupt",
7301                    _ => unreachable!("unrecognized classified storage failure"),
7302                }
7303                .to_owned();
7304                *details = failure.details();
7305            }
7306        }
7307        let details = match &outcome {
7308            SyncOutcome::Failed { details, .. } => details.as_ref(),
7309            _ => None,
7310        };
7311        let cause_kind = details
7312            .and_then(|details| details.get("causeKind"))
7313            .and_then(Value::as_str)
7314            .and_then(|cause| match cause {
7315                "network" => Some(crate::TransportCauseKind::Network),
7316                "connect" => Some(crate::TransportCauseKind::Connect),
7317                "tls" => Some(crate::TransportCauseKind::Tls),
7318                "io" => Some(crate::TransportCauseKind::Io),
7319                "request" => Some(crate::TransportCauseKind::Request),
7320                "protocol" => Some(crate::TransportCauseKind::Protocol),
7321                "timeout" => Some(crate::TransportCauseKind::Timeout),
7322                "round_deadline" => Some(crate::TransportCauseKind::RoundDeadline),
7323                "aborted" => Some(crate::TransportCauseKind::Aborted),
7324                "body" => Some(crate::TransportCauseKind::Body),
7325                "status" => Some(crate::TransportCauseKind::Status),
7326                "redirect" => Some(crate::TransportCauseKind::Redirect),
7327                "unknown" => Some(crate::TransportCauseKind::Unknown),
7328                _ => None,
7329            });
7330        let http_status = if matches!(
7331            cause_kind,
7332            Some(
7333                crate::TransportCauseKind::Status
7334                    | crate::TransportCauseKind::Body
7335                    | crate::TransportCauseKind::Redirect
7336            )
7337        ) {
7338            details
7339                .and_then(|details| details.get("httpStatus"))
7340                .and_then(Value::as_f64)
7341                .filter(|status| status.fract() == 0.0 && (100.0..=599.0).contains(status))
7342                .map(|status| status as u16)
7343        } else {
7344            None
7345        };
7346        let retry_delay_ms = self.round_retry_delay_ms;
7347        self.progress.update(|p| match &outcome {
7348            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
7349                if report.failed.is_empty() =>
7350            {
7351                p.state = ProgressState::Complete
7352            }
7353            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
7354                p.state = ProgressState::Failed;
7355                p.error_code = Some("sync.scope_revoked".into());
7356            }
7357            SyncOutcome::Failed { error_code, .. } => {
7358                p.state = ProgressState::Failed;
7359                p.error_code = Some(Self::diagnostic_code(error_code));
7360                p.cause_kind = cause_kind;
7361                p.http_status = http_status;
7362                p.retry_delay_ms = retry_delay_ms;
7363            }
7364            SyncOutcome::RealtimeUnavailable { .. } => {
7365                p.state = ProgressState::Failed;
7366                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
7367                p.retry_delay_ms = retry_delay_ms;
7368            }
7369        });
7370        let completed_at_ms = self.clock_now_ms();
7371        self.last_round = Some(match &outcome {
7372            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
7373                status: "succeeded".to_owned(),
7374                started_at_ms,
7375                completed_at_ms,
7376                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7377                counters: Some(DiagnosticRoundCounters {
7378                    pushed: report.pushed,
7379                    applied: report.applied.len(),
7380                    rejected: report.rejected.len(),
7381                    retryable: report.retryable.len(),
7382                    conflicts: report.conflicts,
7383                    commits_applied: report.commits_applied,
7384                    segment_rows_applied: report.segment_rows_applied,
7385                    bootstrapping: report.bootstrapping.len(),
7386                    resets: report.resets.len(),
7387                    revoked: report.revoked.len(),
7388                    failed: report.failed.len(),
7389                    deferred_commits: report.deferred_commits,
7390                }),
7391                error_code: None,
7392                cause_kind: None,
7393                http_status: None,
7394            },
7395            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
7396                status: "failed".to_owned(),
7397                started_at_ms,
7398                completed_at_ms,
7399                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7400                counters: None,
7401                error_code: Some(Self::diagnostic_code(error_code)),
7402                cause_kind,
7403                http_status,
7404            },
7405            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
7406                status: "failed".to_owned(),
7407                started_at_ms,
7408                completed_at_ms,
7409                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7410                counters: None,
7411                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
7412                cause_kind: None,
7413                http_status: None,
7414            },
7415        });
7416        outcome
7417    }
7418
7419    /// Capture one request on the client owner. Mutations made after this
7420    /// call remain in the outbox for a later round; no network I/O occurs here.
7421    pub fn prepare_sync_round(
7422        &mut self,
7423        url_capable: bool,
7424    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
7425        if !self.transport_enabled {
7426            return Err(Box::new(SyncOutcome::Failed {
7427                error_code: "sync.offline".into(),
7428                message: "transport gate is closed".into(),
7429                details: None,
7430                recommended_action: None,
7431            }));
7432        }
7433        if self.active_round.is_some() {
7434            return Err(Box::new(SyncOutcome::Failed {
7435                error_code: "client.round_in_progress".into(),
7436                message: "a sync round is already in flight".into(),
7437                details: None,
7438                recommended_action: None,
7439            }));
7440        }
7441        self.storage_failure.borrow_mut().take();
7442        self.progress.start();
7443        self.round_retry_delay_ms = None;
7444        let started_at_ms = self.clock_now_ms();
7445        let prepared = (|| {
7446            if self.stopped {
7447                return Err(Box::new(SyncOutcome::Ok(SyncReport {
7448                    schema_floor: self.schema_floor.clone(),
7449                    ..SyncReport::default()
7450                })));
7451            }
7452            if self.security_preflight {
7453                return Err(Box::new(SyncOutcome::Failed {
7454                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
7455                    message: "security preflight must complete before syncing".into(),
7456                    details: None,
7457                    recommended_action: None,
7458                }));
7459            }
7460            self.set_sync_needed(false, false);
7461            self.drain_pending_evictions()
7462                .and_then(|_| self.drop_incompatible_outbox())
7463                .and_then(|_| self.drop_unencodable_outbox(true, None))
7464                .map_err(|message| {
7465                    Box::new(SyncOutcome::Failed {
7466                        error_code: "storage.failed".into(),
7467                        message,
7468                        details: None,
7469                        recommended_action: None,
7470                    })
7471                })?;
7472            let has_log_epoch = self.get_meta(LOG_EPOCH_KEY).map_err(|message| {
7473                Box::new(SyncOutcome::Failed {
7474                    error_code: "storage.failed".into(),
7475                    message,
7476                    details: None,
7477                    recommended_action: None,
7478                })
7479            })?;
7480            let uploads = if has_log_epoch.is_some() && self.schema_has_blobs() {
7481                self.pending_blob_uploads().map_err(|e| {
7482                    Box::new(SyncOutcome::Failed {
7483                        error_code: e.code,
7484                        message: e.message,
7485                        details: e.details,
7486                        recommended_action: None,
7487                    })
7488                })?
7489            } else {
7490                Vec::new()
7491            };
7492            if self.realtime_state != RealtimeState::Connected
7493                && self.realtime_policy == RealtimePolicy::Required
7494            {
7495                let retry_delay_ms = self.schedule_background_retry();
7496                self.realtime_retry_delay_ms = Some(retry_delay_ms);
7497                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
7498                    state: self.realtime_state,
7499                    reason_code: self.realtime_reason_code.clone(),
7500                    retry_delay_ms,
7501                }));
7502            }
7503            let (message, meta) = self.build_request(url_capable)?;
7504            let id = uuid::Uuid::new_v4();
7505            self.active_round = Some(id);
7506            let phase = if uploads.is_empty() {
7507                crate::round::RoundPhase::Sync
7508            } else {
7509                crate::round::RoundPhase::UploadGrant
7510            };
7511            Ok(crate::PreparedSyncRound {
7512                id,
7513                started_at_ms,
7514                message,
7515                meta,
7516                uploads: uploads.into(),
7517                phase,
7518                encoded: None,
7519                continuation: None,
7520                completed: None,
7521                realtime: self.realtime_state == RealtimeState::Connected,
7522                // §5.5: the owning unit's scope map, each value a one-element list.
7523                scopes: self
7524                    .stored(load_units(&self.conn))
7525                    .map_err(|message| {
7526                        Box::new(SyncOutcome::Failed {
7527                            error_code: "storage.failed".into(),
7528                            message,
7529                            details: None,
7530                            recommended_action: None,
7531                        })
7532                    })?
7533                    .into_iter()
7534                    .map(|unit| {
7535                        (
7536                            unit.id,
7537                            canonical_scope_json(&unit_scope_lists(&unit.scopes)),
7538                        )
7539                    })
7540                    .collect(),
7541                #[cfg(feature = "bench-internals")]
7542                benchmark_phases: self.benchmark_phases.clone(),
7543                progress: self.progress.clone(),
7544                fixed_now: self.now_ms,
7545                round_deadline_at: None,
7546            })
7547        })();
7548        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
7549    }
7550
7551    /// Discard the reply of an old authorization or registration context. Its
7552    /// submitted commits stay durable and can be retried with the same ids.
7553    pub fn cancel_sync_round(&mut self) {
7554        self.active_round = None;
7555    }
7556
7557    /// Apply captured network results on the owner, preserving all normal
7558    /// version, schema, content-address and cursor checks. No network I/O.
7559    pub fn apply_sync_round(
7560        &mut self,
7561        completed: crate::CompletedSyncRound,
7562    ) -> crate::AppliedSyncRound {
7563        let mut prepared = completed.prepared;
7564        if self.active_round != Some(prepared.id) || self.security_preflight {
7565            return crate::AppliedSyncRound::Complete {
7566                outcome: SyncOutcome::Failed {
7567                    error_code: "client.round_cancelled".into(),
7568                    message: "sync round belongs to a released client context".into(),
7569                    details: None,
7570                    recommended_action: None,
7571                },
7572                controls: Vec::new(),
7573                more: false,
7574                bootstrap_advanced: false,
7575            };
7576        }
7577        self.storage_failure.borrow_mut().take();
7578        let (response, mut downloads, transport_failed) = match completed.exchange {
7579            crate::round::ExchangeResult::Upload { id, result } => {
7580                let result = match result {
7581                    // §5.9.7: a non-retryable server refusal (`blob.too_large`)
7582                    // is the body's final outcome. The commits naming it take
7583                    // the incompatible-commit path with the server's code and
7584                    // leave this round's request; the round continues.
7585                    Err(error)
7586                        if error
7587                            .details
7588                            .as_ref()
7589                            .and_then(|details| details.get("retryable"))
7590                            == Some(&Value::Bool(false)) =>
7591                    {
7592                        self.conn
7593                            .prepare(
7594                                "SELECT commit_id FROM _syncular_blob_commit_refs WHERE blob_id = ?",
7595                            )
7596                            .and_then(|mut statement| {
7597                                statement
7598                                    .query_map(rusqlite::params![id], |row| row.get::<_, String>(0))?
7599                                    .collect::<Result<HashSet<_>, _>>()
7600                            })
7601                            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7602                            .and_then(|refused| {
7603                                let commits = self
7604                                    .outbox
7605                                    .iter()
7606                                    .filter(|commit| refused.contains(&commit.client_commit_id))
7607                                    .cloned()
7608                                    .collect::<Vec<_>>();
7609                                if !commits.is_empty() {
7610                                    self.drop_outbox_commits(&commits, &error.code, &error.message)?;
7611                                }
7612                                prepared.message.frames.retain(|frame| {
7613                                    !matches!(frame, Frame::PushCommit { client_commit_id, .. }
7614                                        if refused.contains(client_commit_id))
7615                                });
7616                                prepared.meta.pushed_ids.retain(|id| !refused.contains(id));
7617                                prepared.continuation = Some(crate::round::RoundPhase::UploadGrant);
7618                                Ok(())
7619                            })
7620                            .map_err(|message| TransportError::new("client.failed", message))
7621                    }
7622                    result => result,
7623                };
7624                let result = result.and_then(|()| {
7625                    self.conn
7626                        .execute(
7627                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
7628                            rusqlite::params![id],
7629                        )
7630                        .map(|_| ())
7631                        .map_err(|error| {
7632                            TransportError::new(
7633                                "client.failed",
7634                                Self::sqlite_failure(&self.storage_failure, error),
7635                            )
7636                        })
7637                });
7638                match result {
7639                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
7640                    Err(TransportError {
7641                        code,
7642                        message,
7643                        details,
7644                    }) => {
7645                        self.active_round = None;
7646                        if Self::unreachable_transport(&details) {
7647                            // The round's work is still owed; the host's
7648                            // connectivity resume runs it.
7649                            self.set_sync_needed(true, false);
7650                        } else if Self::retryable_failure_code(&code) {
7651                            self.schedule_background_retry();
7652                        }
7653                        let recommended_action = recommended_action_of(&details);
7654                        let outcome = self.finish_sync_round(
7655                            prepared.started_at_ms,
7656                            SyncOutcome::Failed {
7657                                error_code: code,
7658                                message,
7659                                details,
7660                                recommended_action,
7661                            },
7662                        );
7663                        return crate::AppliedSyncRound::Complete {
7664                            outcome,
7665                            controls: Vec::new(),
7666                            more: false,
7667                            bootstrap_advanced: false,
7668                        };
7669                    }
7670                }
7671            }
7672            crate::round::ExchangeResult::Reply {
7673                response,
7674                downloads,
7675                transport_failed,
7676            } => (response, downloads, transport_failed),
7677        };
7678        self.active_round = None;
7679        let before = self.resume_tokens();
7680        let outcome = (|| {
7681            let response = match response {
7682                Ok(response) => {
7683                    // §1.5: a complete, decodable response ends the report, an
7684                    // in-band ERROR included (the server already recorded it).
7685                    self.round_failure = None;
7686                    response
7687                }
7688                Err(TransportError {
7689                    code,
7690                    message,
7691                    details,
7692                }) => {
7693                    // §1.5: the server cannot see a round whose transport
7694                    // failed or whose response never decoded, so its code rides
7695                    // the following requests. A code the frame grammar rejects
7696                    // is not reportable, and the previous code stays.
7697                    if ssp2::model::valid_round_failure_code(&code) {
7698                        self.round_failure = Some(code.clone());
7699                    }
7700                    if transport_failed {
7701                        if prepared.realtime {
7702                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
7703                        }
7704                        if let Err(message) = self.record_lease_error(&code) {
7705                            return SyncOutcome::Failed {
7706                                error_code: "storage.failed".to_owned(),
7707                                message,
7708                                details: None,
7709                                recommended_action: None,
7710                            };
7711                        }
7712                    }
7713                    if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
7714                    {
7715                        let delay = self.schedule_background_retry();
7716                        if prepared.realtime && transport_failed {
7717                            self.realtime_retry_delay_ms = Some(delay);
7718                        }
7719                    }
7720                    let recommended_action = recommended_action_of(&details);
7721                    return SyncOutcome::Failed {
7722                        error_code: code,
7723                        message,
7724                        details,
7725                        recommended_action,
7726                    };
7727                }
7728            };
7729            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
7730            if let SyncOutcome::Ok(report) = &mut outcome {
7731                report.deferred_commits = prepared.meta.deferred_commits;
7732            }
7733            match &outcome {
7734                SyncOutcome::Ok(_) => self.reset_background_retry(),
7735                SyncOutcome::Failed {
7736                    error_code,
7737                    details,
7738                    ..
7739                } if Self::retryable_failure_code(error_code)
7740                    && !Self::unreachable_transport(details) =>
7741                {
7742                    self.schedule_background_retry();
7743                }
7744                _ => {}
7745            }
7746            if prepared.meta.deferred_commits > 0 {
7747                self.set_sync_needed(true, true);
7748            }
7749            self.previous_version_lifetime_check();
7750            outcome
7751        })();
7752        match &outcome {
7753            // §7.1: a unit still catching up needs another round, also when the
7754            // drive that started it ends early.
7755            SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() => {
7756                self.set_sync_needed(true, false)
7757            }
7758            // The round's work is still owed; the host's connectivity resume
7759            // runs it.
7760            SyncOutcome::Failed { details, .. } if Self::unreachable_transport(details) => {
7761                self.set_sync_needed(true, false)
7762            }
7763            _ => {}
7764        }
7765        let more = self.transport_enabled
7766            && matches!(&outcome, SyncOutcome::Ok(report)
7767                if report.schema_floor.is_none()
7768                    && (!report.bootstrapping.is_empty()
7769                        || report.commits_applied > 0
7770                        || report.segment_rows_applied > 0
7771                        || !report.resets.is_empty()
7772                        || report.deferred_commits > 0
7773                        || self.sync_needed));
7774        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
7775            && before != self.resume_tokens();
7776        crate::AppliedSyncRound::Complete {
7777            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
7778            controls: if self.transport_enabled {
7779                downloads.controls
7780            } else {
7781                Vec::new()
7782            },
7783            more,
7784            bootstrap_advanced,
7785        }
7786    }
7787
7788    /// Every unit's resume token: a bootstrap that advanced changes one (§4.7).
7789    fn resume_tokens(&self) -> Option<Vec<(String, Option<String>)>> {
7790        load_units(&self.conn).ok().map(|units| {
7791            units
7792                .into_iter()
7793                .map(|unit| (unit.id, unit.bootstrap_state))
7794                .collect()
7795        })
7796    }
7797
7798    /// §7.8: one idle drive's default total deadline, the TS client's value.
7799    pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
7800
7801    pub fn sync_until_idle(
7802        &mut self,
7803        transport: &mut dyn Transport,
7804        max_rounds: Option<u32>,
7805    ) -> SyncOutcome {
7806        self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
7807    }
7808
7809    /// As [`SyncClient::sync_until_idle`], with an explicit total deadline in
7810    /// milliseconds (`None` disables it). The deadline is checked between
7811    /// rounds; one blocking transport call is bounded by the transport's own
7812    /// whole-round deadline (§1.8).
7813    pub fn sync_until_idle_with_deadline(
7814        &mut self,
7815        transport: &mut dyn Transport,
7816        max_rounds: Option<u32>,
7817        timeout_ms: Option<u64>,
7818    ) -> SyncOutcome {
7819        // §7.8: an explicit budget is a strict positive round count. Zero is a
7820        // caller error, not an exhaustion.
7821        if max_rounds == Some(0) {
7822            return SyncOutcome::Failed {
7823                details: None,
7824                error_code: "sync.invalid_request".into(),
7825                message: "maxRounds must be a positive integer".into(),
7826                recommended_action: None,
7827            };
7828        }
7829        if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
7830            return SyncOutcome::Failed {
7831                details: None,
7832                error_code: "sync.invalid_request".into(),
7833                message: "timeoutMs must be a positive integer".into(),
7834                recommended_action: None,
7835            };
7836        }
7837        let started_at_ms = self.clock_now_ms();
7838        let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
7839        self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
7840    }
7841
7842    fn sync_until_idle_bounded(
7843        &mut self,
7844        transport: &mut dyn Transport,
7845        max_rounds: Option<u32>,
7846        started_at_ms: i64,
7847        deadline_ms: Option<i64>,
7848    ) -> SyncOutcome {
7849        let rounds = max_rounds.unwrap_or(20);
7850        let mut aggregate = SyncReport::default();
7851        let mut spent = 0;
7852        while spent < rounds {
7853            if let Some(deadline_ms) = deadline_ms {
7854                if self.clock_now_ms() >= deadline_ms {
7855                    return SyncOutcome::Failed {
7856                        error_code: "sync.idle_timeout".into(),
7857                        message: "sync_until_idle exceeded its deadline".into(),
7858                        details: Some(serde_json::json!({
7859                            "timeoutMs": deadline_ms
7860                                .saturating_sub(started_at_ms) as u64,
7861                            "rounds": spent,
7862                        })),
7863                        recommended_action: Some("retryLater".into()),
7864                    };
7865                }
7866            }
7867            spent += 1;
7868            let before = self.resume_tokens();
7869            match self.sync(transport) {
7870                SyncOutcome::Failed {
7871                    error_code,
7872                    message,
7873                    details,
7874                    recommended_action,
7875                } => {
7876                    return SyncOutcome::Failed {
7877                        details,
7878                        error_code,
7879                        message,
7880                        recommended_action,
7881                    };
7882                }
7883                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
7884                SyncOutcome::BudgetExhausted(_) => {
7885                    unreachable!("sync() never exhausts a round budget")
7886                }
7887                SyncOutcome::Ok(report) => {
7888                    if max_rounds.is_none()
7889                        && report.segment_rows_applied > 0
7890                        && !report.bootstrapping.is_empty()
7891                        && before != self.resume_tokens()
7892                    {
7893                        spent = 0;
7894                    }
7895                    // TS parity: a disabled transport or a schema floor ends
7896                    // the loop. The latest round still merges first.
7897                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
7898                    aggregate.merge(&report);
7899                    if stop {
7900                        return SyncOutcome::Ok(aggregate);
7901                    }
7902                    // §4.5: pull again whenever the response contained
7903                    // commits or segments; resets re-bootstrap; a pending
7904                    // resume token continues paging (§4.7); deferred outbox
7905                    // commits and a raised sync-needed signal push on the next
7906                    // round.
7907                    let more = !report.bootstrapping.is_empty()
7908                        || report.commits_applied > 0
7909                        || report.segment_rows_applied > 0
7910                        || !report.resets.is_empty()
7911                        || report.deferred_commits > 0
7912                        || self.sync_needed;
7913                    if !more {
7914                        return SyncOutcome::Ok(aggregate);
7915                    }
7916                }
7917            }
7918        }
7919        // §7.8: partial success. The aggregate report is retained and the run
7920        // is explicitly not idle.
7921        SyncOutcome::BudgetExhausted(aggregate)
7922    }
7923
7924    fn process_response(
7925        &mut self,
7926        transport: &mut dyn Transport,
7927        response: Message,
7928        meta: &RequestMeta,
7929    ) -> SyncOutcome {
7930        #[cfg(feature = "bench-internals")]
7931        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
7932        let mut report = SyncReport {
7933            pushed: meta.pushed_ids.len() as u32,
7934            ..SyncReport::default()
7935        };
7936        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
7937            HashMap::new();
7938        let pushed_ids = meta
7939            .pushed_ids
7940            .iter()
7941            .map(String::as_str)
7942            .collect::<HashSet<_>>();
7943        let mut last_final_push_result_id: Option<String> = None;
7944        for frame in &response.frames {
7945            match frame {
7946                Frame::PushResultDetails {
7947                    client_commit_id,
7948                    entries,
7949                } => {
7950                    let details = rejection_details_by_commit
7951                        .entry(client_commit_id.clone())
7952                        .or_default();
7953                    for entry in entries {
7954                        let parsed = match RejectionDetails::parse(&entry.details.0) {
7955                            Ok(value) => value,
7956                            Err(message) => {
7957                                return SyncOutcome::Failed {
7958                                    details: None,
7959                                    error_code: "sync.invalid_request".to_owned(),
7960                                    message,
7961                                    recommended_action: None,
7962                                };
7963                            }
7964                        };
7965                        details.insert(entry.op_index, parsed);
7966                    }
7967                }
7968                Frame::PushResult {
7969                    client_commit_id,
7970                    status,
7971                    results,
7972                    ..
7973                } if pushed_ids.contains(client_commit_id.as_str())
7974                    && Self::push_result_is_final(*status, results) =>
7975                {
7976                    last_final_push_result_id = Some(client_commit_id.clone());
7977                }
7978                _ => {}
7979            }
7980        }
7981        let mut frames = response.frames.into_iter().peekable();
7982        match frames.next() {
7983            Some(Frame::RespHeader {
7984                required_schema_version,
7985                latest_schema_version,
7986                log_epoch,
7987                reset_required,
7988            }) => {
7989                if let Some(required) = required_schema_version {
7990                    // §1.6 schema-floor response: nothing else is processed;
7991                    // stop syncing and surface the upgrade requirement.
7992                    let floor = SchemaFloor {
7993                        required_schema_version: Some(required),
7994                        latest_schema_version,
7995                    };
7996                    if let Err(message) = self.set_schema_floor(Some(floor.clone())) {
7997                        return SyncOutcome::Failed {
7998                            details: None,
7999                            error_code: "storage.failed".to_owned(),
8000                            message,
8001                            recommended_action: None,
8002                        };
8003                    }
8004                    report.schema_floor = Some(floor);
8005                    return SyncOutcome::Ok(report);
8006                }
8007                if reset_required {
8008                    if frames.next().is_some() {
8009                        return SyncOutcome::Failed {
8010                            details: None,
8011                            error_code: "client.invalid_host_response".to_owned(),
8012                            message: "log-epoch reset response contains body frames".to_owned(),
8013                            recommended_action: None,
8014                        };
8015                    }
8016                    match self.run_log_epoch_reset(&log_epoch) {
8017                        Ok(resets) => {
8018                            report.resets = resets;
8019                            return SyncOutcome::Ok(report);
8020                        }
8021                        Err(message) => {
8022                            return SyncOutcome::Failed {
8023                                details: None,
8024                                error_code: "sync.local_corrupt".to_owned(),
8025                                message,
8026                                recommended_action: None,
8027                            };
8028                        }
8029                    }
8030                }
8031                let stored_epoch = match self.get_meta(LOG_EPOCH_KEY) {
8032                    Ok(stored) => stored,
8033                    Err(message) => {
8034                        return SyncOutcome::Failed {
8035                            details: None,
8036                            error_code: "storage.failed".to_owned(),
8037                            message,
8038                            recommended_action: None,
8039                        };
8040                    }
8041                };
8042                if stored_epoch.as_deref() != Some(log_epoch.as_str()) {
8043                    return SyncOutcome::Failed {
8044                        details: None,
8045                        error_code: "client.invalid_host_response".to_owned(),
8046                        message: "server changed logEpoch without requiring a reset".to_owned(),
8047                        recommended_action: None,
8048                    };
8049                }
8050            }
8051            _ => {
8052                return SyncOutcome::Failed {
8053                    details: None,
8054                    error_code: "sync.invalid_request".to_owned(),
8055                    message: "response does not start with RESP_HEADER".to_owned(),
8056                    recommended_action: None,
8057                };
8058            }
8059        }
8060
8061        if let Err(message) = self.begin_coalesced_apply() {
8062            return SyncOutcome::Failed {
8063                details: None,
8064                error_code: "storage.failed".into(),
8065                message,
8066                recommended_action: None,
8067            };
8068        }
8069        let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
8070        // §4.5 client application: the units whose persisted echo a live
8071        // change must match, fixed at LIVE_START after every SUB_END persisted.
8072        let mut live: Option<(LiveStatus, String, Vec<UnitRecord>)> = None;
8073        let mut live_ended = false;
8074        while let Some(frame) = frames.next() {
8075            match frame {
8076                frame @ Frame::PushResult { .. } => {
8077                    let successful = matches!(
8078                        &frame,
8079                        Frame::PushResult {
8080                            status: PushStatus::Applied | PushStatus::Cached,
8081                            ..
8082                        }
8083                    );
8084                    let mut results = vec![frame];
8085                    if successful {
8086                        while matches!(
8087                            frames.peek(),
8088                            Some(Frame::PushResult {
8089                                status: PushStatus::Applied | PushStatus::Cached,
8090                                ..
8091                            })
8092                        ) {
8093                            if let Some(next) = frames.next() {
8094                                results.push(next);
8095                            }
8096                        }
8097                    }
8098                    if self
8099                        .handle_push_results(
8100                            &results,
8101                            &pushed_ids,
8102                            &rejection_details_by_commit,
8103                            &mut report,
8104                            last_final_push_result_id.as_deref(),
8105                        )
8106                        .is_err()
8107                    {
8108                        failure = Some((
8109                            "client.outcome_persistence_failed".to_owned(),
8110                            "local commit outcome could not be persisted".to_owned(),
8111                            None,
8112                            None,
8113                        ));
8114                        break;
8115                    }
8116                }
8117                Frame::PushResultDetails { .. } => {}
8118                Frame::SubStart {
8119                    id,
8120                    status,
8121                    reason_code,
8122                    bootstrap,
8123                    fenced,
8124                } => {
8125                    // §1.4 rule 5: an ERROR closes the section; its body still
8126                    // applied, its SUB_END values are never persisted.
8127                    let mut body = Vec::new();
8128                    let mut sub_end: Option<(i64, Option<String>)> = None;
8129                    let mut error: Option<Frame> = None;
8130                    for inner in frames.by_ref() {
8131                        match inner {
8132                            Frame::SubEnd {
8133                                next_cursor,
8134                                bootstrap_state,
8135                            } => {
8136                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
8137                                break;
8138                            }
8139                            inner @ Frame::Error { .. } => {
8140                                error = Some(inner);
8141                                break;
8142                            }
8143                            Frame::Unknown { .. } => {}
8144                            other => body.push(other),
8145                        }
8146                    }
8147                    let ended = sub_end.is_some();
8148                    let section = SectionStart {
8149                        id,
8150                        status,
8151                        reason_code,
8152                        bootstrap,
8153                        fenced,
8154                    };
8155                    match self.process_section(
8156                        transport,
8157                        &section,
8158                        body,
8159                        sub_end,
8160                        meta,
8161                        &mut report,
8162                    ) {
8163                        Err(SectionError::Abort(code, message)) => {
8164                            failure = Some((code, message, None, None));
8165                            break;
8166                        }
8167                        Err(SectionError::Transport(error)) => {
8168                            failure = Some((error.code, error.message, None, error.details));
8169                            break;
8170                        }
8171                        _ => {}
8172                    }
8173                    match error {
8174                        Some(Frame::Error {
8175                            code,
8176                            message,
8177                            recommended_action,
8178                            details,
8179                            ..
8180                        }) => {
8181                            failure = Some(Self::error_frame_failure(
8182                                code,
8183                                message,
8184                                recommended_action,
8185                                details,
8186                            ));
8187                            break;
8188                        }
8189                        Some(_) => unreachable!("only an ERROR closes a section early"),
8190                        None if !ended => {
8191                            failure = Some((
8192                                "sync.invalid_request".to_owned(),
8193                                "unit section without SUB_END".to_owned(),
8194                                None,
8195                                None,
8196                            ));
8197                            break;
8198                        }
8199                        None => {}
8200                    }
8201                }
8202                Frame::LiveStart {
8203                    status,
8204                    reason_code,
8205                } => {
8206                    let receivers = match self.stored(load_units(&self.conn)) {
8207                        Ok(units) => units
8208                            .into_iter()
8209                            .filter(|unit| {
8210                                unit.status == UnitStatus::Active
8211                                    && unit.echo.is_some()
8212                                    && !unit.bootstrapping()
8213                            })
8214                            .collect(),
8215                        Err(message) => {
8216                            failure = Some(("storage.failed".to_owned(), message, None, None));
8217                            break;
8218                        }
8219                    };
8220                    live = Some((status, reason_code, receivers));
8221                }
8222                Frame::Commit {
8223                    commit_seq,
8224                    tables,
8225                    changes,
8226                    ..
8227                } => {
8228                    let Some((LiveStatus::Active, _, receivers)) = &live else {
8229                        continue;
8230                    };
8231                    let receivers = receivers.clone();
8232                    match self.apply_live_commit(
8233                        &receivers,
8234                        &tables,
8235                        changes,
8236                        commit_seq,
8237                        AppliedServerCommitSource::Pull,
8238                    ) {
8239                        Ok(applied) => report.commits_applied += u32::from(applied),
8240                        Err((code, message)) => {
8241                            failure = Some((code, message, None, None));
8242                            break;
8243                        }
8244                    }
8245                }
8246                Frame::LiveEnd {
8247                    through_seq,
8248                    echo_digest,
8249                } => {
8250                    let Some((status, reason_code, _)) = live.take() else {
8251                        continue;
8252                    };
8253                    live_ended = true;
8254                    live = Some((status, String::new(), Vec::new()));
8255                    if let Err(message) = self.finish_live_section(
8256                        status,
8257                        &reason_code,
8258                        through_seq,
8259                        echo_digest,
8260                        &meta.registration_digest,
8261                        &mut report,
8262                    ) {
8263                        failure = Some(("storage.failed".to_owned(), message, None, None));
8264                        break;
8265                    }
8266                }
8267                Frame::Lease {
8268                    lease_id,
8269                    expires_at_ms,
8270                } => {
8271                    // §7.3.5: persist the opaque lease; a fresh lease clears
8272                    // any prior lease error (the outage/revocation is over).
8273                    if let Err(message) = self.set_lease_state(Some(LeaseState {
8274                        lease_id: Some(lease_id),
8275                        expires_at_ms: Some(expires_at_ms),
8276                        error_code: None,
8277                    })) {
8278                        failure = Some(("storage.failed".to_owned(), message, None, None));
8279                        break;
8280                    }
8281                }
8282                Frame::Error {
8283                    code,
8284                    message,
8285                    recommended_action,
8286                    details,
8287                    ..
8288                } => {
8289                    // §1.6: the whole request failed; already-completed
8290                    // units keep their applied data and cursors.
8291                    failure = Some(Self::error_frame_failure(
8292                        code,
8293                        message,
8294                        recommended_action,
8295                        details,
8296                    ));
8297                    break;
8298                }
8299                Frame::Unknown { .. } => {}
8300                _ => {
8301                    failure = Some((
8302                        "sync.invalid_request".to_owned(),
8303                        "unexpected frame in response".to_owned(),
8304                        None,
8305                        None,
8306                    ));
8307                    break;
8308                }
8309            }
8310        }
8311
8312        let applied = match failure {
8313            Some(failure) => Err(failure),
8314            // Local apply boundaries already reconciled their affected overlay.
8315            // Refuse round success if any remaining dirty work fails to
8316            // reconcile. §4.8 E1: the push half may have drained commits that
8317            // pinned rows of a shrunk window unit, so retry deferred evictions.
8318            None => self
8319                .rebuild_overlay_if_dirty()
8320                .and_then(|()| self.drain_pending_evictions())
8321                .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
8322        };
8323        // §1.4 rule 4: frames completed before a failure stay committed; a
8324        // failed COMMIT of the coalesced transaction fails the round.
8325        if let Err(message) = self.finish_coalesced_apply() {
8326            return SyncOutcome::Failed {
8327                details: None,
8328                error_code: "storage.failed".into(),
8329                message,
8330                recommended_action: None,
8331            };
8332        }
8333        if let Err((error_code, message, recommended_action, details)) = applied {
8334            return SyncOutcome::Failed {
8335                details,
8336                error_code,
8337                message,
8338                recommended_action,
8339            };
8340        }
8341        let catching_up = match self.stored(load_units(&self.conn)) {
8342            Ok(units) => units
8343                .into_iter()
8344                .filter(|unit| unit.status == UnitStatus::Active && unit.cursor.is_some())
8345                .map(|unit| unit.id)
8346                .collect::<Vec<_>>(),
8347            Err(message) => {
8348                return SyncOutcome::Failed {
8349                    details: None,
8350                    error_code: "storage.failed".into(),
8351                    message,
8352                    recommended_action: None,
8353                }
8354            }
8355        };
8356        // §7.1: a response without LIVE_END, one answered `unregistered` or
8357        // `reset`, or a unit still catching up leaves sync-needed raised;
8358        // after `unregistered` the next round starts at once.
8359        let status = live.map(|(status, _, _)| status);
8360        if !live_ended || status != Some(LiveStatus::Active) || !catching_up.is_empty() {
8361            self.set_sync_needed(true, status == Some(LiveStatus::Unregistered));
8362        }
8363        self.ack_after_pull(transport);
8364        // §7.4.5: the reset is over once the first post-reset pull round
8365        // leaves no unit catching up: the tables are rebuilt and current.
8366        if self.upgrading && catching_up.is_empty() {
8367            self.set_upgrading(false);
8368        }
8369        report.bootstrapping = catching_up;
8370        SyncOutcome::Ok(report)
8371    }
8372
8373    /// §1.6 / §10.1: the failure an in-band `ERROR` frame reports.
8374    fn error_frame_failure(
8375        code: String,
8376        message: String,
8377        recommended_action: String,
8378        details: Option<RawJson>,
8379    ) -> (String, String, Option<String>, Option<Value>) {
8380        let recommended_action = (!recommended_action.is_empty()).then_some(recommended_action);
8381        let details = details
8382            .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
8383            .filter(Value::is_object);
8384        (code, message, recommended_action, details)
8385    }
8386
8387    /// §4.5 at `LIVE_END` of a pull: join rule J for an `active` section, the
8388    /// §4.6 replica reset for `reset`, nothing for `unregistered`; then the
8389    /// replica cursor, the echo digest and the digest of the list the server
8390    /// now stores.
8391    fn finish_live_section(
8392        &mut self,
8393        status: LiveStatus,
8394        reason_code: &str,
8395        through_seq: i64,
8396        echo_digest: Option<[u8; 32]>,
8397        registration_digest: &str,
8398        report: &mut SyncReport,
8399    ) -> Result<(), String> {
8400        let rollback = self.capture();
8401        let resets = report.resets.len();
8402        self.begin_observation("syncular_live_end")?;
8403        let finished = (|| {
8404            let mut batch = ChangeAccumulator::default();
8405            let before = self.stored(replica_cursor(&self.conn))?;
8406            let mut cursor = if status == LiveStatus::Unregistered {
8407                before
8408            } else {
8409                -1
8410            };
8411            let joined = before.max(through_seq);
8412            for unit in self.stored(load_units(&self.conn))? {
8413                if unit.status != UnitStatus::Active || unit.bootstrapping() {
8414                    continue;
8415                }
8416                match status {
8417                    // §4.6 replica reset: every live unit bootstraps again;
8418                    // post-bootstrap units keep their cursors.
8419                    LiveStatus::Reset if unit.cursor.is_none() => {
8420                        for (base_key, value) in self.stored(window_refs(&self.conn, &unit.id))? {
8421                            batch.window(&base_key, &value);
8422                        }
8423                        report.resets.push(unit.id.clone());
8424                        self.stored(save_unit(
8425                            &self.conn,
8426                            &UnitRecord {
8427                                reason_code: Some(reason_code.to_owned()),
8428                                cursor: Some(-1),
8429                                seeded: Vec::new(),
8430                                ..unit
8431                            },
8432                        ))?;
8433                    }
8434                    // §4.5 join rule J.
8435                    LiveStatus::Active => {
8436                        let next = unit.cursor.unwrap_or(joined).max(through_seq);
8437                        if next >= joined {
8438                            cursor = joined;
8439                        }
8440                        if unit.cursor.is_some() {
8441                            self.stored(save_unit(
8442                                &self.conn,
8443                                &UnitRecord {
8444                                    cursor: (next < joined).then_some(next),
8445                                    ..unit
8446                                },
8447                            ))?;
8448                        }
8449                    }
8450                    _ => {}
8451                }
8452            }
8453            self.set_meta(REPLICA_CURSOR_KEY, &cursor.to_string())?;
8454            self.set_meta(
8455                ECHO_DIGEST_KEY,
8456                &echo_digest
8457                    .map(|digest| bytes_to_hex(&digest))
8458                    .unwrap_or_default(),
8459            )?;
8460            if status == LiveStatus::Unregistered {
8461                self.delete_meta(REGISTERED_DIGEST_KEY)?;
8462            } else {
8463                self.set_meta(REGISTERED_DIGEST_KEY, registration_digest)?;
8464            }
8465            self.finish_observation("syncular_live_end", batch)
8466        })();
8467        if finished.is_err() {
8468            self.rollback_observation("syncular_live_end");
8469            self.restore(rollback);
8470            report.resets.truncate(resets);
8471        }
8472        finished
8473    }
8474
8475    // -- push results (§6.3, §7.2) ------------------------------------------------
8476
8477    fn handle_push_results(
8478        &mut self,
8479        frames: &[Frame],
8480        pushed_ids: &HashSet<&str>,
8481        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
8482        report: &mut SyncReport,
8483        last_final_push_result_id: Option<&str>,
8484    ) -> Result<(), String> {
8485        self.begin_observation("syncular_push_results")?;
8486        let rollback = self.capture();
8487        let conflict_count = self.conflicts.len();
8488        let applied_count = report.applied.len();
8489        let rejected_count = report.rejected.len();
8490        let retryable_count = report.retryable.len();
8491        let mut drained = Vec::new();
8492        let mut batch = ChangeAccumulator::default();
8493        let persisted = (|| {
8494            for frame in frames {
8495                let Frame::PushResult {
8496                    client_commit_id,
8497                    status,
8498                    results,
8499                    commit_seq,
8500                } = frame
8501                else {
8502                    return Err("unexpected acknowledgement frame".to_owned());
8503                };
8504                if !pushed_ids.contains(client_commit_id.as_str()) {
8505                    continue;
8506                }
8507                let Some(index) = self
8508                    .outbox
8509                    .iter()
8510                    .position(|commit| commit.client_commit_id == *client_commit_id)
8511                else {
8512                    continue;
8513                };
8514                let status = *status;
8515                let rejection_details = rejection_details_by_commit.get(client_commit_id);
8516                let operations = self.outbox[index].ops.clone();
8517                match status {
8518                    PushStatus::Applied | PushStatus::Cached => {
8519                        // §7.2: a lost ack replays as `cached` — proceed as if the
8520                        // ack had arrived.
8521                        let sequence =
8522                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
8523                        let affected: HashSet<_> = operations
8524                            .iter()
8525                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
8526                            .collect();
8527                        let prior = self.acknowledged_operations(Some(&affected), None)?;
8528                        for (op_index, operation) in operations.iter().enumerate() {
8529                            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))
8530                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8531                            if delivered {
8532                                continue;
8533                            }
8534                            let table = self
8535                                .schema
8536                                .table(&operation.table)
8537                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
8538                            let mut intended: Map<String, Value> = self
8539                                .stored_row(table, &operation.row_id, true)?
8540                                .map(|(row, _)| {
8541                                    table
8542                                        .columns
8543                                        .iter()
8544                                        .zip(&row)
8545                                        .map(|(column, value)| {
8546                                            (column.name.clone(), column_value_to_json(value))
8547                                        })
8548                                        .collect()
8549                                })
8550                                .unwrap_or_default();
8551                            let mut scope_image = intended.clone();
8552                            for op in prior
8553                                .iter()
8554                                .chain(
8555                                    self.failed_commits
8556                                        .iter()
8557                                        .flat_map(|(commit, _)| &commit.ops),
8558                                )
8559                                .chain(
8560                                    self.outbox
8561                                        .iter()
8562                                        .take(index)
8563                                        .flat_map(|commit| &commit.ops),
8564                                )
8565                                .chain(operations.iter().take(op_index + 1))
8566                            {
8567                                if op.table != operation.table || op.row_id != operation.row_id {
8568                                    continue;
8569                                }
8570                                if let Some(values) = &op.values {
8571                                    intended.extend(values.clone());
8572                                } else {
8573                                    scope_image = intended.clone();
8574                                    intended.clear();
8575                                }
8576                            }
8577                            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)",
8578                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
8579                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
8580                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
8581                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8582                        }
8583                        let journal_results = results
8584                            .iter()
8585                            .map(|result| {
8586                                let op_index = match result {
8587                                    OpResult::Applied { op_index }
8588                                    | OpResult::Conflict { op_index, .. }
8589                                    | OpResult::Error { op_index, .. } => *op_index,
8590                                };
8591                                CommitOperationOutcome::Applied { op_index }
8592                            })
8593                            .collect::<Vec<_>>();
8594                        let outcome_status = if status == PushStatus::Applied {
8595                            CommitOutcomeStatus::Applied
8596                        } else {
8597                            CommitOutcomeStatus::Cached
8598                        };
8599                        let persisted = self
8600                            .persist_commit_outcome(
8601                                client_commit_id,
8602                                outcome_status,
8603                                &journal_results,
8604                                None,
8605                            )
8606                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8607                        persisted?;
8608                        self.sync_needed = true;
8609                        batch.status = true;
8610                        batch.outcomes = true;
8611                    }
8612                    PushStatus::Rejected => {
8613                        if results.iter().any(|result| {
8614                            matches!(
8615                                result,
8616                                OpResult::Error {
8617                                    code,
8618                                    retryable: true,
8619                                    ..
8620                                } if code == "sync.idempotency_cache_miss"
8621                            )
8622                        }) {
8623                            // §6.3/§7.2: a serving failure, not an outcome — keep the
8624                            // exact commit queued for an identical retry.
8625                            report.retryable.push(client_commit_id.to_owned());
8626                            continue;
8627                        }
8628
8629                        let mut journal_results = Vec::with_capacity(results.len());
8630                        let mut conflicts = Vec::new();
8631                        let mut rejections = Vec::new();
8632                        for result in results {
8633                            match result {
8634                                OpResult::Applied { op_index } => {
8635                                    journal_results.push(CommitOperationOutcome::Applied {
8636                                        op_index: *op_index,
8637                                    });
8638                                }
8639                                OpResult::Conflict {
8640                                    op_index,
8641                                    code,
8642                                    message,
8643                                    server_version,
8644                                    server_row,
8645                                    conflict_columns: conflict_columns_bitmap,
8646                                } => {
8647                                    let operation = operations
8648                                        .get(*op_index as usize)
8649                                        .map(CommitOperation::from);
8650                                    let (table, row_id) = operation
8651                                        .as_ref()
8652                                        .map(|op| (op.table.clone(), op.row_id.clone()))
8653                                        .unwrap_or_default();
8654                                    let server_row_json = self
8655                                        .schema
8656                                        .table(&table)
8657                                        .and_then(|t| {
8658                                            decode_row_bytes(t, server_row, &self.encryption)
8659                                                .ok()
8660                                                .map(|row| (t, row))
8661                                        })
8662                                        .map(|(t, row)| {
8663                                            let mut map = Map::new();
8664                                            for (i, column) in t.columns.iter().enumerate() {
8665                                                map.insert(
8666                                                    column.name.clone(),
8667                                                    column_value_to_json(
8668                                                        row.get(i).unwrap_or(&None),
8669                                                    ),
8670                                                );
8671                                            }
8672                                            map
8673                                        })
8674                                        .unwrap_or_default();
8675                                    // §6.3: decode the presence-layout bitmap
8676                                    // into the marked column names.
8677                                    let conflict_columns = self
8678                                        .schema
8679                                        .table(&table)
8680                                        .map(|t| {
8681                                            t.columns
8682                                                .iter()
8683                                                .enumerate()
8684                                                .filter(|(index, _)| {
8685                                                    conflict_columns_bitmap
8686                                                        .get(index / 8)
8687                                                        .is_some_and(|byte| {
8688                                                            byte & (1 << (index % 8)) != 0
8689                                                        })
8690                                                })
8691                                                .map(|(_, column)| column.name.clone())
8692                                                .collect::<Vec<_>>()
8693                                        })
8694                                        .unwrap_or_default();
8695                                    let conflict = ConflictRecord {
8696                                        client_commit_id: client_commit_id.to_owned(),
8697                                        op_index: *op_index,
8698                                        table,
8699                                        row_id,
8700                                        code: code.clone(),
8701                                        message: message.clone(),
8702                                        server_version: *server_version,
8703                                        server_row: server_row_json,
8704                                        conflict_columns,
8705                                        operation,
8706                                    };
8707                                    journal_results.push(CommitOperationOutcome::Conflict {
8708                                        conflict: conflict.clone(),
8709                                    });
8710                                    conflicts.push(conflict);
8711                                }
8712                                OpResult::Error {
8713                                    op_index,
8714                                    code,
8715                                    message,
8716                                    retryable,
8717                                } => {
8718                                    let rejection = RejectionRecord {
8719                                        client_commit_id: client_commit_id.to_owned(),
8720                                        op_index: *op_index,
8721                                        code: code.clone(),
8722                                        message: message.clone(),
8723                                        retryable: *retryable,
8724                                        details: rejection_details
8725                                            .and_then(|details| details.get(op_index))
8726                                            .cloned(),
8727                                        operation: operations
8728                                            .get(*op_index as usize)
8729                                            .map(CommitOperation::from),
8730                                    };
8731                                    journal_results.push(CommitOperationOutcome::Error {
8732                                        rejection: rejection.clone(),
8733                                    });
8734                                    rejections.push(rejection);
8735                                }
8736                            }
8737                        }
8738                        if self.retain_failed_commits {
8739                            self.retain_failed_commit(client_commit_id, &operations)?;
8740                        }
8741                        let outcome_status = if conflicts.is_empty() {
8742                            CommitOutcomeStatus::Rejected
8743                        } else {
8744                            CommitOutcomeStatus::Conflict
8745                        };
8746                        let persisted = self
8747                            .persist_commit_outcome(
8748                                client_commit_id,
8749                                outcome_status,
8750                                &journal_results,
8751                                Some(&operations),
8752                            )
8753                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8754                        persisted?;
8755                        batch.conflicts = !conflicts.is_empty();
8756                        batch.rejections = !rejections.is_empty();
8757                        batch.status = true;
8758                        batch.outcomes = true;
8759                        self.conflicts.extend(conflicts);
8760                        self.rejections.extend(rejections);
8761                    }
8762                }
8763                drained.push(self.outbox.remove(index));
8764                if batch.status {
8765                    for operation in &operations {
8766                        if !self.record_row_scopes(
8767                            &mut batch,
8768                            &operation.table,
8769                            &operation.row_id,
8770                            false,
8771                        ) {
8772                            batch.table(&operation.table);
8773                        }
8774                    }
8775                }
8776                match status {
8777                    PushStatus::Applied | PushStatus::Cached => {
8778                        report.applied.push(client_commit_id.to_owned())
8779                    }
8780                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
8781                }
8782            }
8783            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)) {
8784                self.prune_commit_outcomes()?;
8785            }
8786            self.reconcile_rows(&drained)?;
8787            self.rebuild_overlay_if_dirty()?;
8788            self.finish_observation("syncular_push_results", batch)
8789        })();
8790        if let Err(error) = persisted {
8791            self.rollback_observation("syncular_push_results");
8792            self.restore(rollback);
8793            report.applied.truncate(applied_count);
8794            report.rejected.truncate(rejected_count);
8795            report.retryable.truncate(retryable_count);
8796            return Err(error);
8797        }
8798        if !rollback.sync_needed && self.sync_needed {
8799            self.sync_intent_queue.push_back(SyncIntent::Interactive);
8800        }
8801        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
8802        Ok(())
8803    }
8804
8805    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
8806        status != PushStatus::Rejected
8807            || !results.iter().any(|result| {
8808                matches!(
8809                    result,
8810                    OpResult::Error {
8811                        code,
8812                        retryable: true,
8813                        ..
8814                    } if code == "sync.idempotency_cache_miss"
8815                )
8816            })
8817    }
8818
8819    // -- unit sections and the live section ------------------------------------------
8820
8821    /// One unit section (§4.4): apply its segments, then persist its
8822    /// `SUB_END` (§4.6 reset, §3.3 revocation and purge, the echo, cursor and
8823    /// resume token). A section of a unit the app released while the round was
8824    /// in flight is skipped; a fail-closed first page fails the unit and skips
8825    /// the rest of its section (§5.6).
8826    fn process_section(
8827        &mut self,
8828        transport: &mut dyn Transport,
8829        start: &SectionStart,
8830        body: Vec<Frame>,
8831        sub_end: Option<(i64, Option<String>)>,
8832        meta: &RequestMeta,
8833        report: &mut SyncReport,
8834    ) -> Result<(), SectionError> {
8835        let storage = |message| SectionError::Abort("storage.failed".to_owned(), message);
8836        let Some(unit) = self
8837            .stored(get_unit(&self.conn, &start.id))
8838            .map_err(storage)?
8839            .filter(|unit| unit.status == UnitStatus::Active)
8840        else {
8841            return Ok(());
8842        };
8843        let echo: Vec<EchoRecord> = start
8844            .fenced
8845            .iter()
8846            .map(|record| EchoRecord {
8847                table: record.table.clone(),
8848                status: record.status.name().to_owned(),
8849                scopes: record.scopes.iter().cloned().collect(),
8850            })
8851            .collect();
8852        // §5.6: a resumed bootstrap keeps the tables it already seeded.
8853        let mut seeded: BTreeSet<String> = if unit.bootstrap_state.is_none() {
8854            BTreeSet::new()
8855        } else {
8856            unit.seeded.iter().cloned().collect()
8857        };
8858        let applied = (|| {
8859            for frame in body {
8860                match frame {
8861                    Frame::SegmentInline { payload } => {
8862                        let segment = decode_rows_segment(&payload).map_err(|e| {
8863                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
8864                        })?;
8865                        self.progress.update(|p| {
8866                            p.phase = ProgressPhase::Import;
8867                            p.unit_id = Some(unit.id.clone());
8868                            p.table = Some(segment.table.clone());
8869                            p.segment_id = None;
8870                            p.bytes_received = payload.len() as u64;
8871                            p.bytes_total = Some(payload.len() as u64);
8872                            p.rows_processed = 0;
8873                            p.rows_total =
8874                                Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
8875                        });
8876                        let (pair, clear, effective, owners) =
8877                            self.segment_pair(&unit, &echo, &seeded, &segment.table)?;
8878                        report.segment_rows_applied +=
8879                            self.apply_segment(&pair, &segment, clear, &effective, &owners)?;
8880                        seeded.insert(segment.table);
8881                    }
8882                    Frame::SegmentRef {
8883                        segment_id,
8884                        byte_length,
8885                        media_type,
8886                        table,
8887                        row_count,
8888                        as_of_commit_seq,
8889                        scope_digest,
8890                        row_cursor,
8891                        next_row_cursor,
8892                        url,
8893                        url_expires_at_ms,
8894                        ..
8895                    } => {
8896                        // §4.2: reject a descriptor whose mediaType was not
8897                        // advertised — never skip or guess.
8898                        let advertised = match media_type {
8899                            MediaType::Rows => {
8900                                meta.accept & ACCEPT_EXTERNAL_ROWS != 0
8901                                    || meta.accept & ACCEPT_INLINE_ROWS != 0
8902                            }
8903                            MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
8904                        };
8905                        if !advertised {
8906                            return Err(SectionError::Abort(
8907                                "sync.invalid_request".to_owned(),
8908                                format!(
8909                                    "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
8910                                    media_type.name()
8911                                ),
8912                            ));
8913                        }
8914                        self.progress.update(|p| {
8915                            p.phase = ProgressPhase::Download;
8916                            p.unit_id = Some(unit.id.clone());
8917                            p.table = Some(table.clone());
8918                            p.segment_id = Some(segment_id.clone());
8919                            p.bytes_received = 0;
8920                            p.bytes_total = u64::try_from(byte_length).ok();
8921                            p.rows_processed = 0;
8922                            p.rows_total = u64::try_from(row_count).ok();
8923                        });
8924                        let observer = self.progress.clone();
8925                        let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
8926                        let bytes = if let Some(url) = url {
8927                            // §5.4: a url-carrying descriptor MUST be fetched
8928                            // from that URL; failure invalidates the whole
8929                            // descriptor (no fall-through to §5.5 — re-pull
8930                            // recovers, §1.4 rule 5).
8931                            if meta.accept & ACCEPT_SIGNED_URLS == 0 {
8932                                return Err(SectionError::Abort(
8933                                    "sync.invalid_request".to_owned(),
8934                                    "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
8935                                        .to_owned(),
8936                                ));
8937                            }
8938                            // §5.4: MUST NOT start a fetch at/past expiry.
8939                            if url_expires_at_ms.is_some_and(|exp| {
8940                                exp <= transport
8941                                    .segment_fetch_started_at(&url)
8942                                    .unwrap_or_else(|| self.clock_now_ms())
8943                            }) {
8944                                return Err(SectionError::Abort(
8945                                    "sync.segment_expired".to_owned(),
8946                                    format!(
8947                                        "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
8948                                    ),
8949                                ));
8950                            }
8951                            transport
8952                                .fetch_url(
8953                                    &url,
8954                                    u64::try_from(byte_length).unwrap_or(0),
8955                                    &mut on_progress,
8956                                )
8957                                .map_err(|e| self.segment_fetch_failed(e))?
8958                        } else {
8959                            // §5.5: the owning unit's scope map, each value a one-element list.
8960                            let requested_scopes_json =
8961                                canonical_scope_json(&unit_scope_lists(&unit.scopes));
8962                            transport
8963                                .download_segment(
8964                                    &SegmentRequest {
8965                                        segment_id: segment_id.clone(),
8966                                        table: table.clone(),
8967                                        requested_scopes_json,
8968                                        byte_length: u64::try_from(byte_length).unwrap_or(0),
8969                                    },
8970                                    &mut on_progress,
8971                                )
8972                                .map_err(|e| self.segment_fetch_failed(e))?
8973                        };
8974                        // §5.4: the descriptor's byteLength caps the body before
8975                        // §5.1 verifies the content address; the client discards
8976                        // the segment and re-pulls on either failure.
8977                        if bytes.len() as i64 > byte_length {
8978                            self.schedule_background_retry();
8979                            return Err(SectionError::Abort(
8980                                "sync.invalid_request".to_owned(),
8981                                "segment exceeds its byteLength".to_owned(),
8982                            ));
8983                        }
8984                        on_progress(bytes.len() as u64);
8985                        let digest = Sha256::digest(&bytes);
8986                        let expected = segment_id
8987                            .strip_prefix("sha256:")
8988                            .unwrap_or(segment_id.as_str());
8989                        if bytes_to_hex(&digest) != expected {
8990                            // §5.1: the client discards the segment and re-pulls.
8991                            self.schedule_background_retry();
8992                            return Err(SectionError::Abort(
8993                                "sync.invalid_request".to_owned(),
8994                                "segment bytes do not match the content address (§5.1)".to_owned(),
8995                            ));
8996                        }
8997                        self.progress.update(|p| p.phase = ProgressPhase::Import);
8998                        if media_type == MediaType::Sqlite {
8999                            // §5.3: images are whole-table — a paged sqlite
9000                            // descriptor is invalid.
9001                            if row_cursor.is_some() || next_row_cursor.is_some() {
9002                                return Err(SectionError::Abort(
9003                                    "sync.invalid_request".to_owned(),
9004                                    "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9005                                        .to_owned(),
9006                                ));
9007                            }
9008                            let (pair, clear, effective, owners) =
9009                                self.segment_pair(&unit, &echo, &seeded, &table)?;
9010                            report.segment_rows_applied += self.apply_sqlite_segment(
9011                                &pair,
9012                                &bytes,
9013                                clear,
9014                                &effective,
9015                                &owners,
9016                                row_count,
9017                                as_of_commit_seq,
9018                                &scope_digest,
9019                            )?;
9020                            seeded.insert(table);
9021                        } else {
9022                            let segment = decode_rows_segment(&bytes).map_err(|e| {
9023                                SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9024                            })?;
9025                            let (pair, clear, effective, owners) =
9026                                self.segment_pair(&unit, &echo, &seeded, &segment.table)?;
9027                            report.segment_rows_applied +=
9028                                self.apply_segment(&pair, &segment, clear, &effective, &owners)?;
9029                            seeded.insert(segment.table);
9030                        }
9031                    }
9032                    _ => {
9033                        return Err(SectionError::Abort(
9034                            "sync.invalid_request".to_owned(),
9035                            "unexpected frame inside a unit section".to_owned(),
9036                        ));
9037                    }
9038                }
9039            }
9040            Ok(())
9041        })();
9042        match applied {
9043            Ok(()) => {}
9044            Err(SectionError::FailClosed) => {
9045                // §5.6: the failure is unit-local; the rest of the response
9046                // still applies and the failed unit leaves the list.
9047                self.begin_observation("syncular_section_failure")
9048                    .map_err(storage)?;
9049                let failed = (|| {
9050                    let mut batch = ChangeAccumulator::default();
9051                    for (base_key, value) in self.stored(window_refs(&self.conn, &unit.id))? {
9052                        batch.window(&base_key, &value);
9053                    }
9054                    batch.status = true;
9055                    self.stored(save_unit(
9056                        &self.conn,
9057                        &UnitRecord {
9058                            status: UnitStatus::Failed,
9059                            reason_code: Some("sync.scope_revoked".to_owned()),
9060                            ..unit.clone()
9061                        },
9062                    ))?;
9063                    self.finish_observation("syncular_section_failure", batch)
9064                })();
9065                if let Err(message) = failed {
9066                    self.rollback_observation("syncular_section_failure");
9067                    return Err(storage(message));
9068                }
9069                report.failed.push(unit.id);
9070                return Ok(());
9071            }
9072            Err(error) => return Err(error),
9073        }
9074        let Some((next_cursor, bootstrap_state)) = sub_end else {
9075            return Ok(());
9076        };
9077        let rollback = self.capture();
9078        let counts = (
9079            report.resets.len(),
9080            report.revoked.len(),
9081            report.failed.len(),
9082        );
9083        self.begin_observation("syncular_sub_end")
9084            .map_err(storage)?;
9085        let finished = self
9086            .finish_section(start, echo, next_cursor, bootstrap_state, seeded, report)
9087            .and_then(|batch| {
9088                self.rebuild_overlay_if_dirty()?;
9089                self.finish_observation("syncular_sub_end", batch)
9090            });
9091        if let Err(message) = finished {
9092            self.rollback_observation("syncular_sub_end");
9093            self.restore(rollback);
9094            report.resets.truncate(counts.0);
9095            report.revoked.truncate(counts.1);
9096            report.failed.truncate(counts.2);
9097            return Err(storage(message));
9098        }
9099        Ok(())
9100    }
9101
9102    /// §5.6: the table, first-page flag, effective scopes and owners of one
9103    /// segment of `unit`'s section.
9104    #[allow(clippy::type_complexity)]
9105    fn segment_pair(
9106        &self,
9107        unit: &UnitRecord,
9108        echo: &[EchoRecord],
9109        seeded: &BTreeSet<String>,
9110        name: &str,
9111    ) -> Result<
9112        (
9113            TableSchema,
9114            bool,
9115            Vec<(String, Vec<String>)>,
9116            Vec<Vec<(String, Vec<String>)>>,
9117        ),
9118        SectionError,
9119    > {
9120        let table = self.schema.table(name).cloned().ok_or_else(|| {
9121            SectionError::Abort(
9122                "sync.schema_mismatch".to_owned(),
9123                format!("segment table {name:?} is not in the client schema"),
9124            )
9125        })?;
9126        let effective = covers(&table, &unit.scopes)
9127            .then(|| pair_scopes(&unit.scopes, name, Some(echo)))
9128            .flatten()
9129            .ok_or_else(|| {
9130                SectionError::Abort(
9131                    "sync.invalid_request".to_owned(),
9132                    "a segment names no active pair of its unit".to_owned(),
9133                )
9134            })?;
9135        let units = self
9136            .stored(load_units(&self.conn))
9137            .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9138        let owners = unit_owners(&units, &table, &unit.id);
9139        Ok((table, !seeded.contains(name), effective, owners))
9140    }
9141
9142    /// §4.4 `SUB_END` of an active, revoked or reset section, inside the
9143    /// caller's observation: a reset discards cursor, resume token and
9144    /// first-page record and keeps the echo; a revoked pair purges by its last
9145    /// echo (§3.3); otherwise the echo replaces the persisted one, a revoked
9146    /// fenced record that turns active again re-bootstraps the unit, and a
9147    /// bootstrap section persists its pin, resume token and seeded tables.
9148    fn finish_section(
9149        &mut self,
9150        start: &SectionStart,
9151        echo: Vec<EchoRecord>,
9152        next_cursor: i64,
9153        bootstrap_state: Option<String>,
9154        seeded: BTreeSet<String>,
9155        report: &mut SyncReport,
9156    ) -> Result<ChangeAccumulator, String> {
9157        #[cfg(feature = "bench-internals")]
9158        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9159        let mut batch = ChangeAccumulator::default();
9160        // §4.4: SUB_END writes only into a unit that is still registered.
9161        let Some(unit) = self
9162            .stored(get_unit(&self.conn, &start.id))?
9163            .filter(|unit| unit.status == UnitStatus::Active)
9164        else {
9165            return Ok(batch);
9166        };
9167        for (base_key, value) in self.stored(window_refs(&self.conn, &unit.id))? {
9168            batch.window(&base_key, &value);
9169        }
9170        if start.status == SubStatus::Reset {
9171            // §4.6: staleness, never a purge; the rows stay until the
9172            // bootstrap replaces them.
9173            report.resets.push(unit.id.clone());
9174            self.stored(save_unit(
9175                &self.conn,
9176                &UnitRecord {
9177                    reason_code: Some(start.reason_code.clone()),
9178                    cursor: Some(-1),
9179                    bootstrap_state: None,
9180                    seeded: Vec::new(),
9181                    ..unit
9182                },
9183            ))?;
9184            return Ok(batch);
9185        }
9186        let revoked = start.status == SubStatus::Revoked;
9187        let units = self.stored(load_units(&self.conn))?;
9188        let mut failed = false;
9189        let mut rearmed = false;
9190        for table in self.schema.tables.clone() {
9191            if !covers(&table, &unit.scopes) {
9192                continue;
9193            }
9194            let last = unit
9195                .echo
9196                .as_deref()
9197                .and_then(|last| pair_scopes(&unit.scopes, &table.name, Some(last)));
9198            let next = (!revoked)
9199                .then(|| pair_scopes(&unit.scopes, &table.name, Some(&echo)))
9200                .flatten();
9201            // §3.3: a revoked fenced record that turns active again re-bootstraps.
9202            rearmed |= unit.echo.is_some() && last.is_none() && next.is_some();
9203            // §3.3: purge a pair the response revokes, keyed on the echo the
9204            // client persisted while the pair was active.
9205            if let (Some(last), None) = (&last, &next) {
9206                let owners = unit_owners(&units, &table, &unit.id);
9207                failed |= !self.purge_pair(&mut batch, &table, last, &owners)?;
9208            }
9209        }
9210        if revoked || failed {
9211            if revoked {
9212                report.revoked.push(unit.id.clone());
9213            }
9214            if failed {
9215                report.failed.push(unit.id.clone());
9216            }
9217            batch.status = true;
9218            self.stored(save_unit(
9219                &self.conn,
9220                &UnitRecord {
9221                    status: if failed {
9222                        UnitStatus::Failed
9223                    } else {
9224                        UnitStatus::Revoked
9225                    },
9226                    reason_code: Some(if failed {
9227                        "sync.scope_revoked".to_owned()
9228                    } else {
9229                        start.reason_code.clone()
9230                    }),
9231                    cursor: Some(-1),
9232                    bootstrap_state: None,
9233                    seeded: Vec::new(),
9234                    echo: None,
9235                    ..unit
9236                },
9237            ))?;
9238            return Ok(batch);
9239        }
9240        let completed = start.bootstrap && !rearmed && bootstrap_state.is_none();
9241        let (cursor, bootstrap_state, seeded) = if rearmed {
9242            (Some(-1), None, Vec::new())
9243        } else if start.bootstrap {
9244            let seeded = if bootstrap_state.is_none() {
9245                Vec::new()
9246            } else {
9247                seeded.into_iter().collect()
9248            };
9249            (Some(next_cursor), bootstrap_state, seeded)
9250        } else {
9251            (
9252                unit.cursor,
9253                unit.bootstrap_state.clone(),
9254                unit.seeded.clone(),
9255            )
9256        };
9257        self.stored(save_unit(
9258            &self.conn,
9259            &UnitRecord {
9260                reason_code: None,
9261                echo: Some(echo.clone()),
9262                cursor,
9263                bootstrap_state,
9264                seeded,
9265                ..unit.clone()
9266            },
9267        ))?;
9268        if !completed {
9269            return Ok(batch);
9270        }
9271        // §7.1: a completed bootstrap covers acknowledged intent of its pairs
9272        // at or below its pin.
9273        for table in self.schema.tables.clone() {
9274            let Some(effective) = covers(&table, &unit.scopes)
9275                .then(|| pair_scopes(&unit.scopes, &table.name, Some(&echo)))
9276                .flatten()
9277            else {
9278                continue;
9279            };
9280            let mut statement = self
9281                .conn
9282                .prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
9283                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9284            let rows = statement
9285                .query_map(rusqlite::params![table.name, next_cursor], |row| {
9286                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9287                })
9288                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9289            let mut covered = HashSet::new();
9290            for row in rows {
9291                let (id, intent) =
9292                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9293                let values: Map<String, Value> =
9294                    serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
9295                if owns_local_row(&table, &effective, &[], &values) {
9296                    covered.insert(id);
9297                }
9298            }
9299            drop(statement);
9300            for id in covered {
9301                self.conn
9302                    .execute(
9303                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3",
9304                        rusqlite::params![table.name, id, next_cursor],
9305                    )
9306                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9307                self.overlay_dirty.table(&table.name);
9308                batch.table(&table.name);
9309            }
9310        }
9311        Ok(batch)
9312    }
9313
9314    /// §3.3: purge one revoked pair inside the caller's observation: delete
9315    /// the rows matching its last effective scopes that no owner holds, drop
9316    /// the retained intent and every pending commit that provably writes into
9317    /// them, and delete now-unauthorized blob bodies. `Ok(false)` when the
9318    /// table has no local scope-column mapping (fail closed: nothing deleted).
9319    fn purge_pair(
9320        &mut self,
9321        batch: &mut ChangeAccumulator,
9322        table: &TableSchema,
9323        last: &[(String, Vec<String>)],
9324        owners: &[Vec<(String, Vec<String>)>],
9325    ) -> Result<bool, String> {
9326        if local_scope_predicate(table, last, owners).is_err() {
9327            return Ok(false);
9328        }
9329        for commit in self.drop_failed_commits(|name, values| {
9330            name == table.name && owns_local_row(table, last, owners, values)
9331        })? {
9332            batch.conflicts = true;
9333            batch.rejections = true;
9334            batch.outcomes = true;
9335            for operation in commit.ops {
9336                batch.table(&operation.table);
9337            }
9338        }
9339        self.purge_scope_rows(&table.name, last, owners)
9340            .map_err(|()| "storage.failed: the revoked scope purge failed".to_owned())?;
9341        self.record_scope_map(batch, &table.name, last);
9342        if self.drop_doomed_outbox(table, last, owners)? {
9343            batch.status = true;
9344            batch.rejections = true;
9345            batch.outcomes = true;
9346        }
9347        // §5.9.7 B2: revocation deletes now-unauthorized blob bodies.
9348        self.delete_unreferenced_cached_blobs()?;
9349        Ok(true)
9350    }
9351
9352    /// §4.5 client application: apply one live-section or delta `COMMIT`
9353    /// with only the changes that match the persisted echo of a receiving
9354    /// unit; every applied change then goes through the version guard.
9355    /// Returns whether any change of the frame applied.
9356    fn apply_live_commit(
9357        &mut self,
9358        receivers: &[UnitRecord],
9359        tables: &[String],
9360        changes: Vec<ssp2::model::Change>,
9361        commit_seq: i64,
9362        source: AppliedServerCommitSource,
9363    ) -> Result<bool, (String, String)> {
9364        let changes: Vec<ssp2::model::Change> = changes
9365            .into_iter()
9366            .filter(|change| {
9367                // An unknown table fails loud in the apply below.
9368                let Some(table) = tables
9369                    .get(change.table_index as usize)
9370                    .and_then(|name| self.schema.table(name))
9371                else {
9372                    return true;
9373                };
9374                receivers.iter().any(|unit| {
9375                    covers(table, &unit.scopes)
9376                        && pair_scopes(&unit.scopes, &table.name, unit.echo.as_deref()).is_some_and(
9377                            |scopes| {
9378                                scopes.iter().all(|(variable, values)| {
9379                                    change
9380                                        .scopes
9381                                        .iter()
9382                                        .find(|(key, _)| key == variable)
9383                                        .is_some_and(|(_, value)| values.contains(value))
9384                                })
9385                            },
9386                        )
9387                })
9388            })
9389            .collect();
9390        if changes.is_empty() {
9391            return Ok(false);
9392        }
9393        let affected_tables = changes
9394            .iter()
9395            .filter_map(|change| tables.get(change.table_index as usize))
9396            .cloned()
9397            .collect::<BTreeSet<_>>();
9398        let previous_batches = self.change_queue.len();
9399        self.apply_commit_frame(tables, &changes, commit_seq)?;
9400        if self.change_queue.len() > previous_batches {
9401            if let Some(batch) = self.change_queue.back_mut() {
9402                batch
9403                    .applied_commits
9404                    .extend(
9405                        affected_tables
9406                            .into_iter()
9407                            .map(|table| AppliedServerCommit {
9408                                table,
9409                                commit_seq,
9410                                source,
9411                            }),
9412                    );
9413            }
9414        }
9415        Ok(true)
9416    }
9417
9418    // Both pull and realtime frames use the same durable observation boundary.
9419    fn apply_commit_frame(
9420        &mut self,
9421        tables: &[String],
9422        changes: &[ssp2::model::Change],
9423        sequence: i64,
9424    ) -> Result<(), (String, String)> {
9425        #[cfg(feature = "bench-internals")]
9426        let _phase = self.benchmark_phases.start(Phase::CommitApply);
9427        let rollback = self.capture();
9428        self.coalesce_unit(changes.len())
9429            .and_then(|()| self.begin_observation("syncular_commit"))
9430            .map_err(|message| ("storage.failed".into(), message))?;
9431        let applied = (|| {
9432            let mut batch = ChangeAccumulator::default();
9433            self.record_commit_changes(&mut batch, tables, changes);
9434            {
9435                let storage_failed = |error| {
9436                    (
9437                        "storage.failed".to_owned(),
9438                        Self::sqlite_failure(&self.storage_failure, error),
9439                    )
9440                };
9441                let pinned: HashSet<(&str, &str)> = self
9442                    .outbox
9443                    .iter()
9444                    .flat_map(|commit| &commit.ops)
9445                    .map(|op| (op.table.as_str(), op.row_id.as_str()))
9446                    .collect();
9447                for change in changes {
9448                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
9449                        (
9450                            "sync.invalid_request".to_owned(),
9451                            "change tableIndex out of range".to_owned(),
9452                        )
9453                    })?;
9454                    if pinned.contains(&(table.as_str(), change.row_id.as_str())) {
9455                        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)")
9456                            .and_then(|mut statement| statement.execute(rusqlite::params![table, change.row_id, sequence]))
9457                            .map_err(storage_failed)?;
9458                    }
9459                    self.conn.prepare_cached("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3")
9460                        .and_then(|mut statement| statement.execute(rusqlite::params![table, change.row_id, sequence]))
9461                        .map_err(storage_failed)?;
9462                }
9463            }
9464            self.apply_commit_changes(tables, changes)?;
9465            self.rebuild_overlay_if_dirty()
9466                .map_err(|message| ("storage.failed".into(), message))?;
9467            self.finish_observation("syncular_commit", batch)
9468                .map_err(|message| ("storage.failed".into(), message))
9469        })();
9470        if applied.is_err() {
9471            self.rollback_observation("syncular_commit");
9472            self.restore(rollback);
9473        }
9474        applied
9475    }
9476
9477    fn apply_commit_changes(
9478        &mut self,
9479        tables: &[String],
9480        changes: &[ssp2::model::Change],
9481    ) -> Result<(), (String, String)> {
9482        // A clean overlay can reconcile changed primary keys independently
9483        // when their tables have no secondary unique constraints. Such a
9484        // constraint can change whether another row's write succeeds, so a
9485        // table with one and any local intent takes the table rebuild.
9486        let changed_tables: BTreeSet<&str> = changes
9487            .iter()
9488            .filter_map(|change| tables.get(change.table_index as usize))
9489            .map(String::as_str)
9490            .collect();
9491        let mut mirror_visible = !self.overlay_dirty.get();
9492        for name in &changed_tables {
9493            if mirror_visible
9494                && self
9495                    .schema
9496                    .table(name)
9497                    .is_some_and(|table| table.indexes.iter().any(|index| index.unique))
9498            {
9499                let (pending, protected) = self
9500                    .table_intent(name)
9501                    .map_err(|message| ("storage.failed".to_owned(), message))?;
9502                mirror_visible = !pending && !protected;
9503            }
9504        }
9505        for change in changes {
9506            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9507                (
9508                    "sync.invalid_request".to_owned(),
9509                    "change tableIndex out of range".to_owned(),
9510                )
9511            })?;
9512            let table = self.schema.table(table_name).ok_or_else(|| {
9513                (
9514                    "sync.schema_mismatch".to_owned(),
9515                    format!("change targets unknown table {table_name:?}"),
9516                )
9517            })?;
9518            let landed = match change.op {
9519                Op::Upsert => {
9520                    let payload = change.row.as_ref().ok_or_else(|| {
9521                        (
9522                            "sync.invalid_request".to_owned(),
9523                            "upsert change without row payload".to_owned(),
9524                        )
9525                    })?;
9526                    // §5.11: decrypt encrypted columns on apply.
9527                    let row = {
9528                        #[cfg(feature = "bench-internals")]
9529                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
9530                        decode_row_bytes(table, payload, &self.encryption).map_err(|m| {
9531                            // §5.11: a decrypt failure keeps its own identity.
9532                            match m.strip_prefix("client.decrypt_failed: ") {
9533                                Some(detail) => {
9534                                    ("client.decrypt_failed".to_owned(), detail.to_owned())
9535                                }
9536                                None => ("sync.invalid_request".to_owned(), m),
9537                            }
9538                        })?
9539                    };
9540                    let table_name = table.name.clone();
9541                    // §4.5 version guard: only a newer upsert lands.
9542                    let landed = self
9543                        .write_base_row(&table_name, &row, change.row_version)
9544                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9545                    if mirror_visible && landed {
9546                        self.write_row(
9547                            &visible_table(&table_name),
9548                            &table_name,
9549                            &row,
9550                            change.row_version,
9551                        )
9552                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9553                    }
9554                    landed
9555                }
9556                // §4.5 version guard: a delete at or below the local version
9557                // is skipped, a delete of an absent row is a no-op.
9558                Op::Delete => self
9559                    .delete_base_row(table_name, &change.row_id, change.row_version)
9560                    .map_err(|m| ("sync.invalid_request".to_owned(), m))?,
9561            };
9562            // The change dropped the row's acknowledged intent either way
9563            // (§7.1), so the overlay of a skipped change rebuilds from the
9564            // unchanged base.
9565            self.overlay_dirty.table(table_name);
9566            if mirror_visible && !(landed && change.op == Op::Upsert) {
9567                let predicate = row_id_predicate(table);
9568                let visible = visible_table(table_name);
9569                let mut statements = vec![format!("DELETE FROM {visible} WHERE {predicate}")];
9570                if !landed {
9571                    statements.push(format!(
9572                        "INSERT INTO {visible} SELECT * FROM {} WHERE {predicate}",
9573                        base_table(table_name)
9574                    ));
9575                }
9576                for statement in &statements {
9577                    self.conn
9578                        .prepare_cached(statement)
9579                        .and_then(|mut statement| {
9580                            statement.execute(rusqlite::params![change.row_id])
9581                        })
9582                        .map_err(|error| ("sync.invalid_request".to_owned(), error.to_string()))?;
9583                }
9584            }
9585        }
9586        if mirror_visible {
9587            let changed_rows: HashSet<(&str, &str)> = changes
9588                .iter()
9589                .map(|change| {
9590                    (
9591                        tables[change.table_index as usize].as_str(),
9592                        change.row_id.as_str(),
9593                    )
9594                })
9595                .collect();
9596            self.replay_overlay(&changed_tables, Some(&changed_rows))
9597                .map_err(|message| ("storage.failed".to_owned(), message))?;
9598            self.overlay_dirty.set(false);
9599        }
9600        Ok(())
9601    }
9602
9603    /// §5.6 segment application: validate against the generated schema,
9604    /// clear the pair's scope on the table's first page (fail closed
9605    /// without a mapping), then upsert each row with its segment-carried
9606    /// server version under the §4.5 version guard (§5.2).
9607    fn apply_segment(
9608        &mut self,
9609        table: &TableSchema,
9610        segment: &RowsSegment,
9611        first_page: bool,
9612        effective: &[(String, Vec<String>)],
9613        owners: &[Vec<(String, Vec<String>)>],
9614    ) -> Result<u32, SectionError> {
9615        // §5.2: the column table validates against the generated schema —
9616        // order, names, types, nullability; mismatch is fatal. §5.11: the
9617        // server sends the WIRE types (bytes for an encrypted column), so
9618        // validate against wire_columns.
9619        let matches = segment.table == table.name
9620            && segment.schema_version == self.schema.version
9621            && segment.columns.len() == table.wire_columns.len()
9622            && segment
9623                .columns
9624                .iter()
9625                .zip(table.wire_columns.iter())
9626                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9627        if !matches {
9628            return Err(SectionError::Abort(
9629                "sync.schema_mismatch".to_owned(),
9630                "segment column table does not match the generated schema (§5.2)".to_owned(),
9631            ));
9632        }
9633        let mut applied = 0u32;
9634        let empty = Vec::new();
9635        let blocks = if segment.blocks.is_empty() {
9636            std::slice::from_ref(&empty)
9637        } else {
9638            &segment.blocks
9639        };
9640        for (index, block) in blocks.iter().enumerate() {
9641            let clear = first_page && index == 0;
9642            if !clear && block.is_empty() {
9643                continue;
9644            }
9645            let rollback = self.capture();
9646            let (pending, protected) = self
9647                .table_intent(&table.name)
9648                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9649            let reconcile_pending =
9650                !protected && pending && table.indexes.iter().any(|index| index.unique);
9651            let mirror_visible = !protected && !reconcile_pending;
9652            self.coalesce_unit(block.len())
9653                .and_then(|()| self.begin_observation("syncular_segment_block"))
9654                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9655            let outcome = (|| {
9656                self.rebuild_overlay_if_dirty()
9657                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9658                let mut changed_keys = Vec::new();
9659                let mut batch = ChangeAccumulator::default();
9660                if !block.is_empty()
9661                    || (clear && self.scoped_rows_exist(&table.name, effective, owners))
9662                {
9663                    batch.table(&table.name);
9664                }
9665                if clear {
9666                    self.purge_scope_rows(&table.name, effective, owners)
9667                        .map_err(|()| SectionError::FailClosed)?;
9668                }
9669                for (row_index, row) in block.iter().enumerate() {
9670                    let decrypted;
9671                    let values = if table.has_encrypted_columns() {
9672                        let mut values = row.values.clone();
9673                        crate::values::decrypt_segment_row(table, &mut values, &self.encryption)
9674                            .map_err(|message| {
9675                                SectionError::Abort("client.decrypt_failed".into(), message)
9676                            })?;
9677                        decrypted = values;
9678                        &decrypted
9679                    } else {
9680                        &row.values
9681                    };
9682                    if !self
9683                        .write_base_row(&table.name, values, row.server_version)
9684                        .map_err(|message| {
9685                            SectionError::Abort("sync.invalid_request".into(), message)
9686                        })?
9687                    {
9688                        continue;
9689                    }
9690                    if reconcile_pending {
9691                        changed_keys.push(
9692                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
9693                                |message| SectionError::Abort("storage.failed".into(), message),
9694                            )?,
9695                        );
9696                    }
9697                    if mirror_visible {
9698                        self.write_row(
9699                            &visible_table(&table.name),
9700                            &table.name,
9701                            values,
9702                            row.server_version,
9703                        )
9704                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9705                    }
9706                    let processed = u64::from(applied) + row_index as u64 + 1;
9707                    if processed.is_multiple_of(1024) {
9708                        self.progress.update(|p| p.rows_processed = processed);
9709                    }
9710                }
9711                self.reconcile_import(table, &mut changed_keys, protected, reconcile_pending)
9712                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9713                self.finish_observation("syncular_segment_block", batch)
9714                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
9715            })();
9716            if let Err(error) = outcome {
9717                self.rollback_observation("syncular_segment_block");
9718                self.restore(rollback);
9719                return Err(error);
9720            }
9721            applied += block.len() as u32;
9722            self.progress
9723                .update(|p| p.rows_processed = u64::from(applied));
9724            std::thread::yield_now();
9725        }
9726        self.progress
9727            .update(|p| p.rows_processed = u64::from(applied));
9728        Ok(applied)
9729    }
9730
9731    /// §5.3 sqlite-image application: validate the in-file metadata
9732    /// against the descriptor, validate column names/order against the
9733    /// generated schema, run the §5.6 first-page clear when fresh, then
9734    /// replace-or-upsert every image row with its `_syncular_version`.
9735    /// Mechanics: the image lands in a temp file read through a second
9736    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
9737    /// Each image chunk commits its writes and revision before readers continue.
9738    #[allow(clippy::too_many_arguments)]
9739    fn apply_sqlite_segment(
9740        &mut self,
9741        table: &TableSchema,
9742        bytes: &[u8],
9743        first_page: bool,
9744        effective: &[(String, Vec<String>)],
9745        owners: &[Vec<(String, Vec<String>)>],
9746        row_count: i64,
9747        as_of_commit_seq: i64,
9748        scope_digest: &str,
9749    ) -> Result<u32, SectionError> {
9750        let invalid = |detail: &str| {
9751            SectionError::Abort(
9752                "sync.invalid_request".to_owned(),
9753                format!("sqlite segment rejected: {detail} (§5.3)"),
9754            )
9755        };
9756        // §5.11: an image bypasses the decrypt seam, so a server cannot serve
9757        // one for a table with encrypted columns.
9758        if table.has_encrypted_columns() {
9759            return Err(invalid("table has encrypted columns"));
9760        }
9761
9762        // The image lands beside the replica so it shares the replica's
9763        // directory permissions and volume; an in-memory replica has no
9764        // directory and uses the system temp dir. The file is owner-only.
9765        let directory = self
9766            .previous_version_replica_path()
9767            .and_then(|replica| std::path::Path::new(&replica).parent().map(Into::into))
9768            .unwrap_or_else(std::env::temp_dir);
9769        let path = directory.join(format!(".syncular-image-{}.tmp", uuid::Uuid::new_v4()));
9770        let outcome = (|| {
9771            let mut options = std::fs::OpenOptions::new();
9772            options.write(true).create_new(true);
9773            #[cfg(unix)]
9774            std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
9775            std::io::Write::write_all(
9776                &mut options
9777                    .open(&path)
9778                    .map_err(|_| invalid("image temp file write failed"))?,
9779                bytes,
9780            )
9781            .map_err(|_| invalid("image temp file write failed"))?;
9782            let img = rusqlite::Connection::open_with_flags(
9783                &path,
9784                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
9785            )
9786            .map_err(|_| invalid("bytes do not open as a SQLite database"))?;
9787            self.apply_sqlite_image(
9788                &img,
9789                table,
9790                first_page,
9791                effective,
9792                owners,
9793                row_count,
9794                as_of_commit_seq,
9795                scope_digest,
9796            )
9797        })();
9798        let _ = std::fs::remove_file(&path);
9799        outcome
9800    }
9801
9802    #[allow(clippy::too_many_arguments)]
9803    fn apply_sqlite_image(
9804        &mut self,
9805        img: &rusqlite::Connection,
9806        table: &crate::schema::TableSchema,
9807        first_fresh_page: bool,
9808        effective: &[(String, Vec<String>)],
9809        owners: &[Vec<(String, Vec<String>)>],
9810        row_count: i64,
9811        as_of_commit_seq: i64,
9812        scope_digest: &str,
9813    ) -> Result<u32, SectionError> {
9814        let invalid = |detail: String| {
9815            SectionError::Abort(
9816                "sync.invalid_request".to_owned(),
9817                format!("sqlite segment rejected: {detail} (§5.3)"),
9818            )
9819        };
9820
9821        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
9822        //    one row).
9823        type MetaRow = (i64, String, i64, i64, String, i64, i64);
9824        let meta: MetaRow = img
9825            .query_row(
9826                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
9827                        \"scopeDigest\", \"rowCount\",
9828                        (SELECT count(*) FROM _syncular_segment)
9829                 FROM _syncular_segment",
9830                [],
9831                |row| {
9832                    Ok((
9833                        row.get(0)?,
9834                        row.get(1)?,
9835                        row.get(2)?,
9836                        row.get(3)?,
9837                        row.get(4)?,
9838                        row.get(5)?,
9839                        row.get(6)?,
9840                    ))
9841                },
9842            )
9843            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
9844        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
9845        if meta_count != 1 {
9846            return Err(invalid(format!(
9847                "_syncular_segment must contain exactly one row, found {meta_count}"
9848            )));
9849        }
9850        if format != 1 {
9851            return Err(invalid(format!("format {format}")));
9852        }
9853        if meta_table != table.name {
9854            return Err(invalid(format!("image table {meta_table:?}")));
9855        }
9856        if schema_version != i64::from(self.schema.version) {
9857            return Err(invalid(format!("schemaVersion {schema_version}")));
9858        }
9859        if pin != as_of_commit_seq {
9860            return Err(invalid(format!("asOfCommitSeq {pin}")));
9861        }
9862        if digest != scope_digest {
9863            return Err(invalid("scopeDigest mismatch".to_owned()));
9864        }
9865        if meta_rows != row_count {
9866            return Err(invalid(format!("rowCount {meta_rows}")));
9867        }
9868
9869        // 2. Column names and order vs the generated schema (§5.3 rule 3).
9870        let mut names: Vec<String> = Vec::new();
9871        let mut primary_keys: Vec<String> = Vec::new();
9872        {
9873            let mut stmt = img
9874                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
9875                .map_err(|_| invalid("image data table missing".to_owned()))?;
9876            let mut rows = stmt
9877                .query([])
9878                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
9879            while let Some(row) = rows
9880                .next()
9881                .map_err(|_| invalid("image data table unreadable".to_owned()))?
9882            {
9883                if row
9884                    .get::<_, i64>(5)
9885                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
9886                    > 0
9887                {
9888                    primary_keys.push(row.get(1).map_err(|error| {
9889                        invalid(Self::sqlite_failure(&self.storage_failure, error))
9890                    })?);
9891                }
9892                names.push(
9893                    row.get::<_, String>(1)
9894                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
9895                );
9896            }
9897        }
9898        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
9899        expected.push("_syncular_version");
9900        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
9901            return Err(SectionError::Abort(
9902                "sync.schema_mismatch".to_owned(),
9903                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
9904            ));
9905        }
9906
9907        if primary_keys != vec![table.primary_key.clone()] {
9908            return Err(invalid(
9909                "image primary key does not match generated schema".into(),
9910            ));
9911        }
9912        let actual_count: i64 = img
9913            .query_row(
9914                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
9915                [],
9916                |row| row.get(0),
9917            )
9918            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9919        if actual_count != row_count {
9920            return Err(invalid("image row count does not match descriptor".into()));
9921        }
9922        let insert = self.insert_row_sql(&base_table(&table.name), table);
9923        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
9924        let column_list = names
9925            .iter()
9926            .map(|n| quote_ident(n))
9927            .collect::<Vec<_>>()
9928            .join(", ");
9929        let mut stmt = img
9930            .prepare(&format!(
9931                "SELECT {column_list} FROM {} ORDER BY {}",
9932                quote_ident(&table.name),
9933                quote_ident(&table.primary_key)
9934            ))
9935            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9936        let mut rows = stmt
9937            .query([])
9938            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9939        let mut applied = 0u32;
9940        loop {
9941            let clear = applied == 0 && first_fresh_page;
9942            let rollback = self.capture();
9943            let (pending, protected) = self
9944                .table_intent(&table.name)
9945                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9946            let reconcile_pending =
9947                !protected && pending && table.indexes.iter().any(|index| index.unique);
9948            let mirror_visible = !protected && !reconcile_pending;
9949            // A chunk holds up to 1,024 rows (§5.3).
9950            self.coalesce_unit(1024)
9951                .and_then(|()| self.begin_observation("syncular_image_chunk"))
9952                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9953            let result = (|| {
9954                self.rebuild_overlay_if_dirty()
9955                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9956                let mut changed_keys = Vec::new();
9957                let mut batch = ChangeAccumulator::default();
9958                if clear {
9959                    if self.scoped_rows_exist(&table.name, effective, owners) {
9960                        batch.table(&table.name);
9961                    }
9962                    self.purge_scope_rows(&table.name, effective, owners)
9963                        .map_err(|()| SectionError::FailClosed)?;
9964                }
9965                let mut processed = 0u32;
9966                {
9967                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
9968                        invalid(Self::sqlite_failure(&self.storage_failure, error))
9969                    })?;
9970                    let mut mirror = if mirror_visible {
9971                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
9972                            invalid(Self::sqlite_failure(&self.storage_failure, error))
9973                        })?)
9974                    } else {
9975                        None
9976                    };
9977                    for _ in 0..1024 {
9978                        let Some(row) = rows.next().map_err(|error| {
9979                            invalid(Self::sqlite_failure(&self.storage_failure, error))
9980                        })?
9981                        else {
9982                            break;
9983                        };
9984                        for (i, column) in table.columns.iter().enumerate() {
9985                            let cell = row.get_ref(i).map_err(|error| {
9986                                invalid(Self::sqlite_failure(&self.storage_failure, error))
9987                            })?;
9988                            let param = image_cell_param(column, cell).map_err(invalid)?;
9989                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
9990                                invalid(Self::sqlite_failure(&self.storage_failure, error))
9991                            })?;
9992                            if let Some(mirror) = &mut mirror {
9993                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
9994                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
9995                                })?;
9996                            }
9997                        }
9998                        let version_index = table.columns.len();
9999                        let version: i64 = row.get(version_index).map_err(|error| {
10000                            invalid(Self::sqlite_failure(&self.storage_failure, error))
10001                        })?;
10002                        if version < 1 {
10003                            return Err(invalid("image row version must be positive".into()));
10004                        }
10005                        ins.raw_bind_parameter(version_index + 1, version)
10006                            .map_err(|error| {
10007                                invalid(Self::sqlite_failure(&self.storage_failure, error))
10008                            })?;
10009                        // §4.5 version guard: the base upsert decides.
10010                        let landed = ins.raw_execute().map_err(|error| {
10011                            invalid(Self::sqlite_failure(&self.storage_failure, error))
10012                        })? > 0;
10013                        if !landed {
10014                            processed += 1;
10015                            continue;
10016                        }
10017                        if let Some(mirror) = &mut mirror {
10018                            mirror
10019                                .raw_bind_parameter(version_index + 1, version)
10020                                .map_err(|error| {
10021                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
10022                                })?;
10023                            mirror.raw_execute().map_err(|error| {
10024                                invalid(Self::sqlite_failure(&self.storage_failure, error))
10025                            })?;
10026                        }
10027                        if reconcile_pending {
10028                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
10029                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
10030                            )?);
10031                        }
10032                        processed += 1;
10033                    }
10034                }
10035                if processed > 0 {
10036                    batch.table(&table.name);
10037                    self.overlay_dirty.table(&table.name);
10038                }
10039                self.reconcile_import(table, &mut changed_keys, protected, reconcile_pending)
10040                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10041                self.finish_observation("syncular_image_chunk", batch)
10042                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10043                Ok(processed)
10044            })();
10045            let processed = match result {
10046                Ok(processed) => processed,
10047                Err(error) => {
10048                    self.rollback_observation("syncular_image_chunk");
10049                    self.restore(rollback);
10050                    return Err(error);
10051                }
10052            };
10053            applied += processed;
10054            self.progress
10055                .update(|p| p.rows_processed = u64::from(applied));
10056            std::thread::yield_now();
10057            if processed < 1024 || i64::from(applied) == actual_count {
10058                break;
10059            }
10060        }
10061        if i64::from(applied) != actual_count {
10062            return Err(invalid(format!(
10063                "image yielded {applied} rows, expected {actual_count}"
10064            )));
10065        }
10066        Ok(applied)
10067    }
10068
10069    /// §7.1 keyed reconciliation of the rows `drained` commits touch: restore
10070    /// them from the base and replay their remaining intent. A table with a
10071    /// secondary unique index restores and replays every row with intent,
10072    /// since one row's value can admit or defer another row's write.
10073    fn reconcile_rows(&self, drained: &[Arc<OutboxCommit>]) -> Result<(), String> {
10074        let names: BTreeSet<&str> = drained
10075            .iter()
10076            .flat_map(|commit| &commit.ops)
10077            .map(|op| op.table.as_str())
10078            .collect();
10079        for name in names {
10080            let Some(table) = self.schema.table(name) else {
10081                continue;
10082            };
10083            let ops = drained.iter().flat_map(|commit| &commit.ops);
10084            let tables = BTreeSet::from([name]);
10085            let rows: HashSet<_> = ops
10086                .clone()
10087                .map(|op| (op.table.as_str(), op.row_id.as_str()))
10088                .collect();
10089            let unique = table.indexes.iter().any(|index| index.unique);
10090            let acknowledged = if unique {
10091                self.acknowledged_operations(None, Some(&tables))?
10092            } else {
10093                Vec::new()
10094            };
10095            let peers = acknowledged
10096                .iter()
10097                .chain(
10098                    self.failed_commits
10099                        .iter()
10100                        .flat_map(|(commit, _)| &commit.ops),
10101                )
10102                .chain(self.outbox.iter().flat_map(|commit| &commit.ops))
10103                .filter(|_| unique);
10104            self.restore_base_rows(table, &mut Vec::new(), peers.chain(ops.clone()))?;
10105            self.replay_overlay(&tables, (!unique).then_some(&rows))?;
10106        }
10107        Ok(())
10108    }
10109
10110    /// Whether `table` has pending outbox intent, and whether it has protected
10111    /// (acknowledged or retained failed) intent.
10112    fn table_intent(&self, table: &str) -> Result<(bool, bool), String> {
10113        let touches = |op: &OutboxOp| op.table == table;
10114        Ok((
10115            self.outbox
10116                .iter()
10117                .flat_map(|commit| &commit.ops)
10118                .any(touches),
10119            self.failed_commits
10120                .iter()
10121                .flat_map(|(commit, _)| &commit.ops)
10122                .any(touches)
10123                || self
10124                    .conn
10125                    .query_row(
10126                        "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows WHERE tbl=?1)",
10127                        [table],
10128                        |row| row.get(0),
10129                    )
10130                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
10131        ))
10132    }
10133
10134    /// §7.1 reconciliation after an import block or chunk. Keyed replay covers
10135    /// only pending intent, so a table with protected intent rebuilds; a
10136    /// `restore` restores the imported `keys` and every pending row first.
10137    fn reconcile_import(
10138        &mut self,
10139        table: &TableSchema,
10140        keys: &mut Vec<SqlValue>,
10141        protected: bool,
10142        restore: bool,
10143    ) -> Result<(), String> {
10144        if protected {
10145            return self.rebuild_overlay_if_dirty();
10146        }
10147        let pending = self.outbox.iter().flat_map(|commit| &commit.ops);
10148        if restore {
10149            self.restore_base_rows(table, keys, pending.clone())?;
10150        }
10151        self.apply_outbox_ops(pending.filter(|op| op.table == table.name))?;
10152        self.overlay_dirty.set(false);
10153        Ok(())
10154    }
10155
10156    /// Restore the visible rows of `keys` and of the rows `ops` touch in
10157    /// `table` from the base before the caller replays their intent. The
10158    /// caller owns the transaction and publishes its revision after replay.
10159    fn restore_base_rows<'a>(
10160        &self,
10161        table: &TableSchema,
10162        keys: &mut Vec<SqlValue>,
10163        ops: impl IntoIterator<Item = &'a OutboxOp>,
10164    ) -> Result<(), String> {
10165        let visible = visible_table(&table.name);
10166        let base = base_table(&table.name);
10167        let pk = quote_ident(&table.primary_key);
10168        let predicate = row_id_predicate(table);
10169        let mut lookup = self.conn.prepare_cached(&format!(
10170            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10171        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10172        let mut deleted = HashSet::new();
10173        for op in ops.into_iter().filter(|op| op.table == table.name) {
10174            if op.upsert {
10175                if let Some(values) = &op.values {
10176                    let key = json_to_column_value(
10177                        &table.columns[table.pk_index],
10178                        values.get(&table.primary_key),
10179                    )?;
10180                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
10181                }
10182            } else if deleted.insert(&op.row_id) {
10183                keys.extend(
10184                    lookup
10185                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10186                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10187                        .collect::<Result<Vec<_>, _>>()
10188                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
10189                );
10190            }
10191        }
10192        // Delete the complete affected set before restoring any base value, so
10193        // old optimistic unique values cannot collide with the new server rows.
10194        for chunk in keys.chunks(400) {
10195            let holes = vec!["?"; chunk.len()].join(",");
10196            self.conn
10197                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10198                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10199                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10200        }
10201        let updates = table
10202            .columns
10203            .iter()
10204            .map(|column| column.name.as_str())
10205            .chain(std::iter::once("_syncular_version"))
10206            .filter(|name| *name != table.primary_key)
10207            .map(|name| {
10208                let name = quote_ident(name);
10209                format!("{name}=excluded.{name}")
10210            })
10211            .collect::<Vec<_>>()
10212            .join(",");
10213        for chunk in keys.chunks(400) {
10214            let holes = vec!["?"; chunk.len()].join(",");
10215            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10216                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10217                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10218        }
10219        Ok(())
10220    }
10221
10222    // -- application-authorized local purge ---------------------------------------
10223
10224    fn compile_local_data_purge(
10225        &self,
10226        input: &LocalDataPurgeInput,
10227    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10228        let invalid = |message: String| format!("sync.invalid_request: {message}");
10229        if input.purge_id.is_empty()
10230            || input.purge_id.len() > 128
10231            || !is_local_operation_code_like(&input.purge_id)
10232        {
10233            return Err(invalid(
10234                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10235            ));
10236        }
10237        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10238            return Err(invalid(format!(
10239                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10240            )));
10241        }
10242
10243        let mut deduplicated: BTreeMap<
10244            String,
10245            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10246        > = BTreeMap::new();
10247        for target in &input.targets {
10248            let table = self.schema.table(&target.table).ok_or_else(|| {
10249                invalid(format!(
10250                    "local purge names unknown table {:?}",
10251                    target.table
10252                ))
10253            })?;
10254            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10255                return Err(invalid(format!(
10256                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10257                    target.table
10258                )));
10259            }
10260            let mut selectors = Vec::with_capacity(target.selectors.len());
10261            let mut canonical_selectors = BTreeMap::new();
10262            for (column_name, raw_values) in &target.selectors {
10263                let Some((column_index, column)) = table
10264                    .columns
10265                    .iter()
10266                    .enumerate()
10267                    .find(|(_, column)| column.name == *column_name)
10268                else {
10269                    return Err(invalid(format!(
10270                        "local purge target {:?} names unknown column {:?}",
10271                        target.table, column_name
10272                    )));
10273                };
10274                let encrypted = table
10275                    .encrypted_columns
10276                    .iter()
10277                    .any(|candidate| candidate.index == column_index);
10278                if column.ty != ColumnType::String || encrypted {
10279                    return Err(invalid(format!(
10280                        "local purge selector {:?}.{:?} must be a plaintext string column",
10281                        target.table, column_name
10282                    )));
10283                }
10284                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10285                    return Err(invalid(format!(
10286                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10287                        target.table, column_name
10288                    )));
10289                }
10290                let mut values = raw_values.clone();
10291                values.sort();
10292                values.dedup();
10293                if values.iter().any(|value| {
10294                    value.is_empty()
10295                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10296                        || !is_local_operation_code_like(value)
10297                }) {
10298                    return Err(invalid(format!(
10299                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10300                    )));
10301                }
10302                selectors.push((column_name.clone(), values.clone()));
10303                canonical_selectors.insert(column_name.clone(), values);
10304            }
10305            let canonical = LocalDataPurgeTarget {
10306                table: target.table.clone(),
10307                selectors: canonical_selectors,
10308            };
10309            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10310            deduplicated.insert(
10311                key,
10312                (
10313                    CompiledLocalDataPurgeTarget {
10314                        table: target.table.clone(),
10315                        selectors,
10316                    },
10317                    canonical,
10318                ),
10319            );
10320        }
10321        let targets = deduplicated
10322            .values()
10323            .map(|(compiled, _)| compiled.clone())
10324            .collect::<Vec<_>>();
10325        let canonical = deduplicated
10326            .values()
10327            .map(|(_, target)| target.clone())
10328            .collect::<Vec<_>>();
10329        let canonical_plan =
10330            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10331        Ok((targets, canonical_plan))
10332    }
10333
10334    fn local_purge_base_row_ids(
10335        &self,
10336        targets: &[CompiledLocalDataPurgeTarget],
10337    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10338        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10339        for target in targets {
10340            let table = self
10341                .schema
10342                .table(&target.table)
10343                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10344            let mut clauses = Vec::with_capacity(target.selectors.len());
10345            let mut params = Vec::new();
10346            for (column, values) in &target.selectors {
10347                clauses.push(format!(
10348                    "{} IN ({})",
10349                    quote_ident(column),
10350                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10351                ));
10352                params.extend(values.iter().cloned().map(SqlValue::Text));
10353            }
10354            let sql = format!(
10355                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10356                quote_ident(&table.primary_key),
10357                base_table(&target.table),
10358                clauses.join(" AND ")
10359            );
10360            let mut statement = self
10361                .conn
10362                .prepare(&sql)
10363                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10364            let rows = statement
10365                .query_map(rusqlite::params_from_iter(params), |row| {
10366                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
10367                    render_row_id_json(Some(&value))
10368                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
10369                })
10370                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10371            let ids = by_table.entry(target.table.clone()).or_default();
10372            for row in rows {
10373                ids.insert(
10374                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
10375                );
10376            }
10377        }
10378        Ok(by_table)
10379    }
10380
10381    fn local_purge_values_match(
10382        target: &CompiledLocalDataPurgeTarget,
10383        values: &Map<String, Value>,
10384    ) -> bool {
10385        target.selectors.iter().all(|(column, allowed)| {
10386            values
10387                .get(column)
10388                .and_then(Value::as_str)
10389                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10390        })
10391    }
10392
10393    /// Apply one host-authorized local security purge. The host MUST gate the
10394    /// corresponding subscriptions first; this primitive owns local SQLite
10395    /// cleanup and never grants/revokes server authority by itself.
10396    pub fn purge_local_data(
10397        &mut self,
10398        input: &LocalDataPurgeInput,
10399    ) -> Result<LocalDataPurgeResult, String> {
10400        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10401        let meta_key = format!("localPurge:{}", input.purge_id);
10402        let applied_plan = self
10403            .conn
10404            .query_row(
10405                "SELECT value FROM _syncular_meta WHERE key = ?1",
10406                rusqlite::params![meta_key],
10407                |row| row.get::<_, String>(0),
10408            )
10409            .optional()
10410            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10411        if let Some(applied_plan) = applied_plan {
10412            if applied_plan != canonical_plan {
10413                return Err(format!(
10414                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
10415                    input.purge_id
10416                ));
10417            }
10418            return Ok(LocalDataPurgeResult {
10419                already_applied: true,
10420                purged_rows: 0,
10421                dropped_commits: 0,
10422            });
10423        }
10424
10425        // RFC 0005 D7: the container file cannot be a purge target (its name is
10426        // not in the running schema), so purge drops it as a fixed step, before
10427        // any mirror row is touched and unconditionally.
10428        let _ = self.drop_previous_version();
10429
10430        let rollback = self.capture();
10431        self.begin_observation("syncular_local_purge")?;
10432        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10433            let row_ids = self.local_purge_base_row_ids(&targets)?;
10434            let retained_dropped = self.drop_failed_commits(|table, values| {
10435                targets.iter().any(|target| {
10436                    target.table == table && Self::local_purge_values_match(target, values)
10437                })
10438            })?;
10439            let doomed = self
10440                .outbox
10441                .iter()
10442                .filter(|commit| {
10443                    commit.ops.iter().any(|operation| {
10444                        let matching_targets = targets
10445                            .iter()
10446                            .filter(|target| target.table == operation.table)
10447                            .collect::<Vec<_>>();
10448                        if matching_targets.is_empty() {
10449                            return false;
10450                        }
10451                        if row_ids
10452                            .get(&operation.table)
10453                            .is_some_and(|ids| ids.contains(&operation.row_id))
10454                        {
10455                            return true;
10456                        }
10457                        operation.values.as_ref().is_some_and(|values| {
10458                            matching_targets
10459                                .iter()
10460                                .any(|target| Self::local_purge_values_match(target, values))
10461                        })
10462                    })
10463                })
10464                .cloned()
10465                .collect::<Vec<_>>();
10466            let doomed_ids = doomed
10467                .iter()
10468                .map(|commit| commit.client_commit_id.clone())
10469                .collect::<BTreeSet<_>>();
10470            let mut batch = ChangeAccumulator::default();
10471            for commit in &retained_dropped {
10472                batch.conflicts = true;
10473                batch.rejections = true;
10474                batch.outcomes = true;
10475                for operation in &commit.ops {
10476                    batch.table(&operation.table);
10477                }
10478            }
10479            let mut rejections = Vec::new();
10480            for commit in &doomed {
10481                for operation in &commit.ops {
10482                    batch.table(&operation.table);
10483                    self.overlay_dirty.table(&operation.table);
10484                }
10485                let results = commit
10486                    .ops
10487                    .iter()
10488                    .enumerate()
10489                    .map(|(op_index, operation)| {
10490                        let rejection = RejectionRecord {
10491                            client_commit_id: commit.client_commit_id.clone(),
10492                            op_index: op_index as i32,
10493                            code: "client.local_data_purged".to_owned(),
10494                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10495                            retryable: false,
10496                            details: None,
10497                            operation: Some(CommitOperation::from(operation)),
10498                        };
10499                        rejections.push(rejection.clone());
10500                        CommitOperationOutcome::Error { rejection }
10501                    })
10502                    .collect::<Vec<_>>();
10503                self.persist_commit_outcome(
10504                    &commit.client_commit_id,
10505                    CommitOutcomeStatus::Rejected,
10506                    &results,
10507                    Some(&commit.ops),
10508                )?;
10509                self.delete_outbox_persisted(&commit.client_commit_id)?;
10510            }
10511            if !doomed.is_empty() {
10512                self.outbox
10513                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10514                self.rejections.extend(rejections);
10515                self.prune_commit_outcomes()?;
10516                batch.status = true;
10517                batch.rejections = true;
10518                batch.outcomes = true;
10519            }
10520
10521            let mut purged_rows = 0usize;
10522            for (table, ids) in &row_ids {
10523                if ids.is_empty() {
10524                    continue;
10525                }
10526                batch.table(table);
10527                self.overlay_dirty.table(table);
10528                for row_id in ids {
10529                    self.delete_base_row(table, row_id, i64::MAX)?;
10530                    purged_rows += 1;
10531                }
10532            }
10533            self.rebuild_overlay_if_dirty()?;
10534            self.delete_unreferenced_cached_blobs()?;
10535            self.conn
10536                .execute(
10537                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10538                    rusqlite::params![meta_key, canonical_plan],
10539                )
10540                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10541            Ok((
10542                batch,
10543                LocalDataPurgeResult {
10544                    already_applied: false,
10545                    purged_rows,
10546                    dropped_commits: doomed.len(),
10547                },
10548            ))
10549        })()
10550        .and_then(|(batch, result)| {
10551            self.finish_observation("syncular_local_purge", batch)
10552                .map(|()| result)
10553        });
10554        let result = match applied {
10555            Ok(result) => result,
10556            Err(error) => {
10557                self.rollback_observation("syncular_local_purge");
10558                self.restore(rollback);
10559                return Err(error);
10560            }
10561        };
10562        self.cancel_sync_round();
10563        Ok(result)
10564    }
10565
10566    /// Application-authorized recovery of the replicated projection. Unlike a
10567    /// local security purge, this retains the entire outbox, device identity,
10568    /// lease, outcomes, unit registrations, and protected bookkeeping.
10569    /// The projection reset, unit rewind, optimistic replay, and
10570    /// idempotency marker share one savepoint for interruption safety.
10571    pub fn rebootstrap_local_data(
10572        &mut self,
10573        input: &LocalDataRebootstrapInput,
10574    ) -> Result<LocalDataRebootstrapResult, String> {
10575        if input.rebootstrap_id.is_empty()
10576            || input.rebootstrap_id.len() > 128
10577            || !is_local_operation_code_like(&input.rebootstrap_id)
10578        {
10579            return Err(
10580                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10581                    .to_owned(),
10582            );
10583        }
10584        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10585        if let Some(persisted) = meta_get(&self.conn, &meta_key).map_err(|_| {
10586            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10587        })? {
10588            let (retained_commits, reset_subscriptions) =
10589                decode_local_rebootstrap_receipt(&persisted)?;
10590            return Ok(LocalDataRebootstrapResult {
10591                already_applied: true,
10592                retained_commits,
10593                reset_subscriptions,
10594            });
10595        }
10596        if self.stopped || self.schema_floor.is_some() {
10597            return Err(
10598                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10599                    .to_owned(),
10600            );
10601        }
10602
10603        let retained_commits = self.outbox.len();
10604        let reset_subscriptions = self.stored(load_units(&self.conn))?.len();
10605        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10606        let rollback = self.capture();
10607        self.begin_observation("syncular_local_rebootstrap")?;
10608        let mut batch = ChangeAccumulator::default();
10609        let applied = (|| -> Result<(), String> {
10610            self.run_schema_reset_observed(&mut batch, false)?;
10611            self.conn
10612                .execute(
10613                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10614                    rusqlite::params![meta_key, receipt],
10615                )
10616                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10617            self.sync_needed = true;
10618            self.sync_intent_queue.push_back(SyncIntent::Interactive);
10619            batch.status = true;
10620            Ok(())
10621        })()
10622        .and_then(|()| self.finish_observation("syncular_local_rebootstrap", batch));
10623        if let Err(error) = applied {
10624            self.rollback_observation("syncular_local_rebootstrap");
10625            self.restore(rollback);
10626            return Err(error);
10627        }
10628
10629        Ok(LocalDataRebootstrapResult {
10630            already_applied: false,
10631            retained_commits,
10632            reset_subscriptions,
10633        })
10634    }
10635
10636    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
10637
10638    /// Delete base and visible rows owned by the effective scopes
10639    /// ([`local_scope_predicate`]); `Err(())` = no local scope-column mapping
10640    /// for a key (the fail-closed case).
10641    fn purge_scope_rows(
10642        &mut self,
10643        table_name: &str,
10644        effective: &[(String, Vec<String>)],
10645        owners: &[Vec<(String, Vec<String>)>],
10646    ) -> Result<(), ()> {
10647        let table = self.schema.table(table_name).ok_or(())?;
10648        let Some((predicate, params)) = local_scope_predicate(table, effective, owners)? else {
10649            return Ok(());
10650        };
10651        self.overlay_dirty.table(table_name);
10652        for source in [base_table(table_name), visible_table(table_name)] {
10653            self.conn
10654                .execute(
10655                    &format!("DELETE FROM {source} WHERE {predicate}"),
10656                    rusqlite::params_from_iter(&params),
10657                )
10658                .map_err(|_| ())?;
10659        }
10660        Ok(())
10661    }
10662
10663    /// §3.3: drop pending commits whose upserts provably land in the
10664    /// revoked effective scopes and no other owner covers (row ownership)
10665    /// — whole-commit, never per-operation.
10666    fn drop_doomed_outbox(
10667        &mut self,
10668        table: &TableSchema,
10669        effective: &[(String, Vec<String>)],
10670        owners: &[Vec<(String, Vec<String>)>],
10671    ) -> Result<bool, String> {
10672        let doomed: Vec<_> =
10673            self.outbox
10674                .iter()
10675                .filter(|commit| {
10676                    commit.ops.iter().any(|op| {
10677                        op.upsert
10678                            && op.table == table.name
10679                            && op.values.as_ref().is_some_and(|values| {
10680                                owns_local_row(table, effective, owners, values)
10681                            })
10682                    })
10683                })
10684                .cloned()
10685                .collect();
10686        if doomed.is_empty() {
10687            return Ok(false);
10688        }
10689        let mut rejections = Vec::new();
10690        for commit in &doomed {
10691            let results = commit
10692                .ops
10693                .iter()
10694                .enumerate()
10695                .map(|(op_index, operation)| {
10696                    self.overlay_dirty.table(&operation.table);
10697                    let rejection = RejectionRecord {
10698                        client_commit_id: commit.client_commit_id.clone(),
10699                        op_index: op_index as i32,
10700                        code: "sync.scope_revoked".to_owned(),
10701                        message: "the commit was dropped because its effective scope was revoked"
10702                            .to_owned(),
10703                        retryable: false,
10704                        details: None,
10705                        operation: Some(CommitOperation::from(operation)),
10706                    };
10707                    rejections.push(rejection.clone());
10708                    CommitOperationOutcome::Error { rejection }
10709                })
10710                .collect::<Vec<_>>();
10711            self.persist_commit_outcome(
10712                &commit.client_commit_id,
10713                CommitOutcomeStatus::Rejected,
10714                &results,
10715                Some(&commit.ops),
10716            )?;
10717            self.delete_outbox_persisted(&commit.client_commit_id)?;
10718        }
10719        self.prune_commit_outcomes()?;
10720        let doomed_ids = doomed
10721            .iter()
10722            .map(|commit| commit.client_commit_id.as_str())
10723            .collect::<BTreeSet<_>>();
10724        self.outbox
10725            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10726        self.rejections.extend(rejections);
10727        Ok(true)
10728    }
10729
10730    // -- blobs (§5.9) ----------------------------------------------------------------
10731
10732    /// §5.9.7: hash bytes into the content address, cache them, queue the
10733    /// upload (flushed before the next push, B4). Returns the canonical
10734    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
10735    /// column value.
10736    pub fn upload_blob(
10737        &mut self,
10738        bytes: &[u8],
10739        media_type: Option<String>,
10740        name: Option<String>,
10741    ) -> Result<Value, String> {
10742        let blob_id = blob_id_for(bytes);
10743        let now = self.clock_now_ms();
10744        let tx = self
10745            .conn
10746            .transaction()
10747            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10748        tx.execute(
10749            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10750             ON CONFLICT(blob_id) DO NOTHING",
10751            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10752        )
10753        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10754        tx.execute(
10755            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10756            rusqlite::params![blob_id, media_type, now],
10757        )
10758        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10759        tx.commit()
10760            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10761        self.enforce_blob_cache_cap();
10762        let mut obj = Map::new();
10763        obj.insert("blobId".to_owned(), Value::from(blob_id));
10764        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
10765        if let Some(mt) = media_type {
10766            obj.insert("mediaType".to_owned(), Value::from(mt));
10767        }
10768        if let Some(n) = name {
10769            obj.insert("name".to_owned(), Value::from(n));
10770        }
10771        Ok(Value::Object(obj))
10772    }
10773
10774    /// §5.9.7: resolve an owned blob body without driver encoding. A
10775    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
10776    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
10777    pub fn fetch_blob_bytes(
10778        &mut self,
10779        transport: &mut dyn Transport,
10780        blob_id_or_ref: &str,
10781    ) -> Result<FetchedBlob, (String, String)> {
10782        let simple = |m: String| ("client.failed".to_owned(), m);
10783        // A BlobRef declares the size that caps the download (§5.9.5); a bare
10784        // blobId declares none.
10785        let (blob_id, byte_length) = if blob_id_or_ref.starts_with("sha256:") {
10786            (blob_id_or_ref.to_owned(), None)
10787        } else {
10788            let value: Value = serde_json::from_str(blob_id_or_ref)
10789                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
10790            (
10791                value
10792                    .get("blobId")
10793                    .and_then(Value::as_str)
10794                    .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
10795                    .to_owned(),
10796                value.get("byteLength").and_then(Value::as_u64),
10797            )
10798        };
10799        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
10800            return Ok(cached);
10801        }
10802        if !self.transport_enabled {
10803            return Err(("sync.offline".into(), "transport gate is closed".into()));
10804        }
10805        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
10806        // blob.not_found) verbatim so the harness can assert on it. The
10807        // authorized endpoint serves bytes inline OR (always-issue, presign
10808        // configured) a signed url the client fetches directly — no host auth,
10809        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
10810        #[cfg(feature = "bench-internals")]
10811        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
10812        let bytes = match transport
10813            .blob_download(&blob_id, byte_length)
10814            .map_err(|e| (e.code, e.message))?
10815        {
10816            BlobDownload::Bytes(bytes) => bytes,
10817            BlobDownload::Url {
10818                url,
10819                url_expires_at_ms,
10820            } => {
10821                // §5.9.5: MUST NOT start a fetch at/past expiry.
10822                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10823                    return Err((
10824                        "sync.segment_expired".to_owned(),
10825                        format!(
10826                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
10827                        ),
10828                    ));
10829                }
10830                transport
10831                    .fetch_blob_url(&url, byte_length)
10832                    .map_err(|e| (e.code, e.message))?
10833            }
10834        };
10835        #[cfg(feature = "bench-internals")]
10836        drop(download_phase);
10837        #[cfg(feature = "bench-internals")]
10838        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
10839        // §5.9.5 inherits §5.1: the declared size caps the body before the
10840        // content address is verified.
10841        if byte_length.is_some_and(|cap| bytes.len() as u64 > cap) {
10842            return Err((
10843                "sync.invalid_request".to_owned(),
10844                "blob exceeds its byteLength".to_owned(),
10845            ));
10846        }
10847        if blob_id_for(&bytes) != blob_id {
10848            return Err(simple(format!(
10849                "blob content address mismatch for {blob_id}"
10850            )));
10851        }
10852        #[cfg(feature = "bench-internals")]
10853        drop(validate_phase);
10854        #[cfg(feature = "bench-internals")]
10855        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
10856        let now = self.clock_now_ms();
10857        self.conn
10858            .execute(
10859                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
10860                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
10861            )
10862            .map_err(|e| simple(e.to_string()))?;
10863        self.conn
10864            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
10865            .map_err(|e| simple(e.to_string()))?;
10866        self.enforce_blob_cache_cap();
10867        #[cfg(feature = "bench-internals")]
10868        drop(insert_phase);
10869        self.get_cached_blob_bytes(&blob_id)
10870            .map_err(simple)?
10871            .ok_or_else(|| simple("blob cache write failed".to_owned()))
10872    }
10873
10874    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
10875        #[cfg(feature = "bench-internals")]
10876        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
10877        let mut stmt = self
10878            .conn
10879            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
10880            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10881        let mut rows = stmt
10882            .query(rusqlite::params![blob_id])
10883            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10884        if let Some(row) = rows
10885            .next()
10886            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10887        {
10888            let bytes: Vec<u8> = row
10889                .get(0)
10890                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10891            let byte_length: i64 = row
10892                .get(1)
10893                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10894            let media_type: Option<String> = row
10895                .get(2)
10896                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10897            return Ok(Some(FetchedBlob {
10898                blob_id: blob_id.to_owned(),
10899                byte_length,
10900                bytes,
10901                media_type,
10902            }));
10903        }
10904        Ok(None)
10905    }
10906
10907    /// §5.9.7 B4: upload every queued blob before push.
10908    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
10909        let pending: Vec<(String, Option<String>)> = {
10910            let mut stmt = self
10911                .conn
10912                .prepare(
10913                    "SELECT blob_id, media_type FROM (
10914                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
10915                      UNION ALL
10916                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
10917                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
10918                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
10919                    ) ORDER BY created_at_ms, blob_id",
10920                )
10921                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10922            let rows = stmt
10923                .query_map([], |row| {
10924                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
10925                })
10926                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10927            rows.collect::<Result<_, _>>()
10928                .map_err(|error| match error {
10929                    rusqlite::Error::InvalidColumnType(..)
10930                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
10931                        "sync.local_corrupt",
10932                        "Pending blob upload metadata is invalid",
10933                    ),
10934                    _ => TransportError::new("client.failed", error.to_string()),
10935                })?
10936        };
10937        let mut uploads = Vec::new();
10938        for (blob_id, media_type) in pending {
10939            let bytes = (|| {
10940                let stored: Option<(SqlValue, SqlValue)> = self
10941                    .conn
10942                    .query_row(
10943                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
10944                        rusqlite::params![blob_id],
10945                        |row| Ok((row.get(0)?, row.get(1)?)),
10946                    )
10947                    .optional()
10948                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10949                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
10950                    return Err(TransportError::new(
10951                        "sync.local_corrupt",
10952                        "Pending blob upload body is missing or corrupt",
10953                    ));
10954                };
10955                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
10956                    return Err(TransportError::new(
10957                        "sync.local_corrupt",
10958                        "Pending blob upload body is missing or corrupt",
10959                    ));
10960                }
10961                Ok(bytes)
10962            })();
10963            let failed = bytes.is_err();
10964            uploads.push(crate::round::PendingUpload {
10965                id: blob_id,
10966                bytes,
10967                media_type,
10968            });
10969            if failed {
10970                break;
10971            }
10972        }
10973        Ok(uploads)
10974    }
10975
10976    fn visible_blob_ids_sql(&self) -> String {
10977        let mut selects = Vec::new();
10978        for table in &self.schema.tables {
10979            for column in table
10980                .columns
10981                .iter()
10982                .filter(|column| column.ty == ColumnType::BlobRef)
10983            {
10984                let identifier = quote_ident(&column.name);
10985                selects.push(format!(
10986                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
10987                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
10988                     AND json_type({identifier}, '$.blobId') = 'text'",
10989                    quote_ident(&table.name),
10990                ));
10991            }
10992        }
10993        if selects.is_empty() {
10994            "SELECT NULL AS blob_id WHERE 0".to_owned()
10995        } else {
10996            selects.join(" UNION ")
10997        }
10998    }
10999
11000    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
11001    /// staged uploads pin bodies directly; no derived refcount is stored.
11002    fn enforce_blob_cache_cap(&self) {
11003        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11004            return;
11005        };
11006        let mut total: i64 = self
11007            .conn
11008            .query_row(
11009                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11010                [],
11011                |row| row.get(0),
11012            )
11013            .unwrap_or(0);
11014        if total <= max_bytes {
11015            return;
11016        }
11017        let query = format!(
11018            "SELECT blob_id, byte_length FROM _syncular_blobs
11019             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11020               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11021               AND blob_id NOT IN ({})
11022             ORDER BY created_at_ms ASC, blob_id ASC",
11023            self.visible_blob_ids_sql(),
11024        );
11025        let candidates: Vec<(String, i64)> = {
11026            let Ok(mut stmt) = self.conn.prepare(&query) else {
11027                return;
11028            };
11029            let Ok(rows) = stmt.query_map([], |row| {
11030                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11031            }) else {
11032                return;
11033            };
11034            rows.filter_map(Result::ok).collect()
11035        };
11036        for (blob_id, byte_length) in candidates {
11037            if total <= max_bytes {
11038                break;
11039            }
11040            let _ = self.conn.execute(
11041                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11042                rusqlite::params![blob_id],
11043            );
11044            total -= byte_length;
11045        }
11046    }
11047
11048    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
11049        #[cfg(feature = "bench-internals")]
11050        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11051        if !self.schema_has_blobs() {
11052            return Ok(());
11053        }
11054        self.rebuild_overlay_if_dirty()?;
11055        self.conn
11056            .execute(
11057                &format!(
11058                    "DELETE FROM _syncular_blobs
11059                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11060                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11061                   AND blob_id NOT IN ({})",
11062                    self.visible_blob_ids_sql(),
11063                ),
11064                [],
11065            )
11066            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11067        Ok(())
11068    }
11069
11070    // -- local row storage ----------------------------------------------------------
11071
11072    /// The cached per-table primary-key upsert SQL for `full_table` (see
11073    /// the `insert_sql` field: built once, reused per row).
11074    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11075        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11076            return sql.clone();
11077        }
11078        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11079        columns.push(quote_ident("_syncular_version"));
11080        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11081        let primary_key = quote_ident(&table.primary_key);
11082        let updates = columns
11083            .iter()
11084            .filter(|column| **column != primary_key)
11085            .map(|column| format!("{column}=excluded.{column}"))
11086            .collect::<Vec<_>>()
11087            .join(", ");
11088        // §4.5 version guard: server state lands in the base table only above
11089        // the stored version; the visible overlay takes every write.
11090        let guard = if full_table == base_table(&table.name) {
11091            format!(" WHERE excluded._syncular_version > {full_table}._syncular_version")
11092        } else {
11093            String::new()
11094        };
11095        let sql = format!(
11096            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}{guard}",
11097            columns.join(", "),
11098            placeholders.join(", ")
11099        );
11100        self.insert_sql
11101            .borrow_mut()
11102            .insert(full_table.to_owned(), sql.clone());
11103        sql
11104    }
11105
11106    /// §4.5: write a server row under the version guard; returns whether it
11107    /// landed.
11108    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<bool, String> {
11109        let landed = self.write_row(&base_table(table_name), table_name, row, version)?;
11110        if landed {
11111            self.overlay_dirty.table(table_name);
11112        }
11113        Ok(landed)
11114    }
11115
11116    fn write_row(
11117        &self,
11118        full_table: &str,
11119        table_name: &str,
11120        row: &Row,
11121        version: i64,
11122    ) -> Result<bool, String> {
11123        #[cfg(feature = "bench-internals")]
11124        let _phase = self.benchmark_phases.start(Phase::RowWrite);
11125        let table = self
11126            .schema
11127            .table(table_name)
11128            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11129        let sql = self.insert_row_sql(full_table, table);
11130        let mut stmt = self
11131            .conn
11132            .prepare_cached(&sql)
11133            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11134        let params = row
11135            .iter()
11136            .map(RowParam::Cell)
11137            .chain(std::iter::once(RowParam::Version(version)));
11138        stmt.execute(rusqlite::params_from_iter(params))
11139            .map(|changed| changed > 0)
11140            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11141    }
11142
11143    /// §4.5: delete a server row whose stored version lies below `version`;
11144    /// returns whether a row left.
11145    fn delete_base_row(
11146        &self,
11147        table_name: &str,
11148        row_id: &str,
11149        version: i64,
11150    ) -> Result<bool, String> {
11151        let table = self
11152            .schema
11153            .table(table_name)
11154            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11155        let sql = format!(
11156            "DELETE FROM {} WHERE {} AND _syncular_version < ?2",
11157            base_table(table_name),
11158            row_id_predicate(table)
11159        );
11160        let mut stmt = self
11161            .conn
11162            .prepare_cached(&sql)
11163            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11164        let landed = stmt
11165            .execute(rusqlite::params![row_id, version])
11166            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11167            > 0;
11168        if landed {
11169            self.overlay_dirty.table(table_name);
11170        }
11171        Ok(landed)
11172    }
11173
11174    fn has_acknowledged_rows(&self) -> Result<bool, String> {
11175        self.conn
11176            .query_row(
11177                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
11178                [],
11179                |row| row.get(0),
11180            )
11181            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11182    }
11183
11184    fn acknowledged_operations(
11185        &self,
11186        rows: Option<&HashSet<(&str, &str)>>,
11187        tables: Option<&BTreeSet<&str>>,
11188    ) -> Result<Vec<OutboxOp>, String> {
11189        let keys = rows.map(|rows| serde_json::to_string(&rows).expect("row keys"));
11190        let mut statement = self
11191            .conn
11192            .prepare_cached(if keys.is_some() {
11193                ACKED_ROWS_BY_ROW
11194            } else {
11195                "SELECT op_json,intent_json FROM _syncular_acked_rows WHERE ?1 IS NULL AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx"
11196            })
11197            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11198        let rows = statement
11199            .query_map(
11200                rusqlite::params![
11201                    keys,
11202                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
11203                ],
11204                |row| {
11205                    #[cfg(test)]
11206                    self.acknowledged_replay_count
11207                        .set(self.acknowledged_replay_count.get() + 1);
11208                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11209                },
11210            )
11211            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11212        rows.map(|row| {
11213            let (op, intent) =
11214                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11215            let op: CommitOperation =
11216                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
11217            let table = self
11218                .schema
11219                .table(&op.table)
11220                .ok_or_else(|| "sync.unknown_table".to_owned())?;
11221            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
11222                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
11223            } else {
11224                op.values
11225            };
11226            Ok(OutboxOp {
11227                table: op.table,
11228                row_id: op.row_id,
11229                upsert: op.op == "upsert",
11230                base_version: op.base_version,
11231                values,
11232                insert: op.insert,
11233            })
11234        })
11235        .collect()
11236    }
11237
11238    fn drop_acknowledged_rows(
11239        &self,
11240        matches: impl Fn(&str, &Map<String, Value>) -> bool,
11241    ) -> Result<Vec<OutboxCommit>, String> {
11242        let mut statement = self
11243            .conn
11244            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
11245            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11246        let rows = statement
11247            .query_map([], |row| {
11248                Ok((
11249                    row.get::<_, String>(0)?,
11250                    row.get::<_, String>(1)?,
11251                    row.get::<_, String>(2)?,
11252                    row.get::<_, String>(3)?,
11253                ))
11254            })
11255            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11256        let mut doomed = HashSet::new();
11257        for row in rows {
11258            let (id, table, intent, row_id) =
11259                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11260            let values =
11261                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
11262            let base_matches = self
11263                .schema
11264                .table(&table)
11265                .map(|schema| self.stored_row(schema, &row_id, true))
11266                .transpose()?
11267                .flatten()
11268                .is_some_and(|(base, _)| {
11269                    let schema = self.schema.table(&table).expect("acknowledged table");
11270                    let values = schema
11271                        .columns
11272                        .iter()
11273                        .zip(base.iter())
11274                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
11275                        .collect();
11276                    matches(&table, &values)
11277                });
11278            if matches(&table, &values) || base_matches {
11279                doomed.insert(id);
11280            }
11281        }
11282        let mut dropped = Vec::new();
11283        for id in doomed {
11284            let mut statement = self
11285                .conn
11286                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
11287                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11288            let operations = statement
11289                .query_map([&id], |row| row.get::<_, String>(0))
11290                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11291            let mut ops = Vec::new();
11292            for operation in operations {
11293                let json = operation
11294                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11295                let op: CommitOperation =
11296                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
11297                self.overlay_dirty.table(&op.table);
11298                ops.push(OutboxOp {
11299                    table: op.table,
11300                    row_id: op.row_id,
11301                    upsert: op.op == "upsert",
11302                    base_version: op.base_version,
11303                    values: op.values,
11304                    insert: op.insert,
11305                });
11306            }
11307            dropped.push(OutboxCommit {
11308                client_commit_id: id.clone(),
11309                ops,
11310            });
11311            self.conn
11312                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
11313                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11314        }
11315        Ok(dropped)
11316    }
11317
11318    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
11319    /// the outbox changed since the last rebuild (the no-op sync round).
11320    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
11321        if self.overlay_dirty.get() {
11322            self.rebuild_overlay()?;
11323        }
11324        Ok(())
11325    }
11326
11327    /// §7.1: local reads see outbox state applied optimistically — rebuild
11328    /// every visible table as (base server state) + (pending outbox replay
11329    /// on top). Optimistic rows carry version `-1`.
11330    fn rebuild_overlay(&mut self) -> Result<(), String> {
11331        #[cfg(feature = "bench-internals")]
11332        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11333        #[cfg(test)]
11334        self.overlay_rebuild_count
11335            .set(self.overlay_rebuild_count.get() + 1);
11336        let dirty = self.overlay_dirty.snapshot();
11337        let tables: Vec<_> = self
11338            .schema
11339            .tables
11340            .iter()
11341            .filter(|table| {
11342                dirty
11343                    .as_ref()
11344                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
11345            })
11346            .cloned()
11347            .collect();
11348        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
11349        self.begin_observation("syncular_overlay")?;
11350        let rebuilt = (|| {
11351            for table in &tables {
11352                for index in &table.fts_indexes {
11353                    self.drop_fts_triggers(index)?;
11354                }
11355                let visible = visible_table(&table.name);
11356                let base = base_table(&table.name);
11357                self.conn
11358                    .execute_batch(&format!(
11359                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
11360                    ))
11361                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11362            }
11363            self.replay_overlay(&names, None)?;
11364            for table in &tables {
11365                for index in &table.fts_indexes {
11366                    self.rebuild_fts_projection(table, index)?;
11367                    self.create_fts_triggers(table, index)?;
11368                }
11369            }
11370            self.conn
11371                .execute_batch("RELEASE syncular_overlay")
11372                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11373            Ok(())
11374        })();
11375        if rebuilt.is_err() {
11376            self.conn
11377                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
11378                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11379        } else {
11380            self.overlay_dirty.set(false);
11381        }
11382        rebuilt
11383    }
11384
11385    /// §7.1 replay of acknowledged, retained failed and pending intent, in
11386    /// that order, for the operations of `tables` on `rows` (every row for
11387    /// `None`) over their current visible state.
11388    fn replay_overlay(
11389        &self,
11390        tables: &BTreeSet<&str>,
11391        rows: Option<&HashSet<(&str, &str)>>,
11392    ) -> Result<(), String> {
11393        let replays = |op: &&OutboxOp| {
11394            tables.contains(op.table.as_str())
11395                && rows.is_none_or(|rows| rows.contains(&(op.table.as_str(), op.row_id.as_str())))
11396        };
11397        self.apply_outbox_ops(&self.acknowledged_operations(rows, Some(tables))?)?;
11398        for (commit, initial) in self.failed_commits.iter() {
11399            for operation in commit.ops.iter().filter(replays) {
11400                let Some(table) = self.schema.table(&operation.table) else {
11401                    continue;
11402                };
11403                if operation.upsert
11404                    && self.visible_row(table, &operation.row_id)?.is_none()
11405                    && operation.presented_values(table).is_some_and(|values| {
11406                        table
11407                            .columns
11408                            .iter()
11409                            .all(|column| values.contains_key(&column.name))
11410                    })
11411                {
11412                    let key = serde_json::to_string(&(&operation.table, &operation.row_id))
11413                        .expect("row key serialization");
11414                    if let Some(Value::Object(values)) = initial.get(&key) {
11415                        self.apply_outbox_ops(&[OutboxOp {
11416                            values: Some(values.clone()),
11417                            ..operation.clone()
11418                        }])?;
11419                    }
11420                }
11421            }
11422            self.apply_outbox_ops(commit.ops.iter().filter(replays))?;
11423        }
11424        self.apply_outbox_ops(
11425            self.outbox
11426                .iter()
11427                .flat_map(|commit| &commit.ops)
11428                .filter(replays),
11429        )
11430    }
11431
11432    /// §7.1 replay: every operation is applied over the current visible
11433    /// state. An operation that no longer applies (a server row now holds its
11434    /// unique value) stays pending and invisible until a later replay admits
11435    /// it or the server answers its push.
11436    fn apply_outbox_ops<'a>(
11437        &self,
11438        ops: impl IntoIterator<Item = &'a OutboxOp>,
11439    ) -> Result<(), String> {
11440        #[cfg(feature = "bench-internals")]
11441        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11442        for op in ops {
11443            #[cfg(feature = "bench-internals")]
11444            if let Some(phase) = &mut phase {
11445                phase.unit();
11446            }
11447            if op.upsert {
11448                if let Some((table, values)) = self
11449                    .schema
11450                    .table(&op.table)
11451                    .filter(|table| table.indexes.iter().any(|index| index.unique))
11452                    .and_then(|table| Some((table, op.presented_values(table)?)))
11453                {
11454                    let mut intended: Map<String, Value> = self
11455                        .visible_row(table, &op.row_id)?
11456                        .map(|(row, _)| {
11457                            table
11458                                .columns
11459                                .iter()
11460                                .zip(&row)
11461                                .map(|(column, value)| {
11462                                    (column.name.clone(), column_value_to_json(value))
11463                                })
11464                                .collect()
11465                        })
11466                        .unwrap_or_default();
11467                    intended.extend(values.into_owned());
11468                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
11469                        continue;
11470                    }
11471                }
11472            }
11473            self.apply_outbox_op(op)?;
11474        }
11475        Ok(())
11476    }
11477
11478    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11479        let Some(table) = self.schema.table(&op.table) else {
11480            return Ok(());
11481        };
11482        let visible = visible_table(&table.name);
11483        if !op.upsert {
11484            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11485            self.conn
11486                .prepare_cached(&sql)
11487                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11488                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11489            return Ok(());
11490        }
11491        // §7.1: an insert-intent operation that lacks a non-nullable column
11492        // cannot encode; it stays invisible until the send-time prepass drops
11493        // it as sync.outbox_incompatible (§7.4.4).
11494        let Some(values) = op.presented_values(table) else {
11495            return Ok(());
11496        };
11497        // §7.1: the overlay applies the operation's PRESENT columns
11498        // over the current local row. A partial operation over an
11499        // absent local row leaves it absent — the server answers it
11500        // with sync.row_deleted or sync.row_missing (§6.2). An
11501        // insert-intent operation presents every column.
11502        let full = table
11503            .columns
11504            .iter()
11505            .all(|column| values.contains_key(&column.name));
11506        let local = self.visible_row(table, &op.row_id)?;
11507        if local.is_none() && !full {
11508            return Ok(());
11509        }
11510        let mut row: Row = Vec::with_capacity(table.columns.len());
11511        for (index, column) in table.columns.iter().enumerate() {
11512            let incoming = match values.get(&column.name) {
11513                Some(raw) => json_to_column_value(
11514                    if raw.get("$bytes").is_some()
11515                        && table
11516                            .encrypted_columns
11517                            .iter()
11518                            .any(|encrypted| encrypted.index == index)
11519                    {
11520                        &table.wire_columns[index]
11521                    } else {
11522                        column
11523                    },
11524                    Some(raw),
11525                ),
11526                None => Ok(local
11527                    .as_ref()
11528                    .and_then(|(values, _)| values.get(index).cloned())
11529                    .flatten()),
11530            };
11531            row.push(incoming.map_err(|_| {
11532                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
11533            })?);
11534        }
11535        let version = local.as_ref().map_or(-1, |(_, version)| *version);
11536        self.write_row(&visible, &table.name, &row, version)
11537            .map(|_| ())
11538            .map_err(|error| {
11539                if error.starts_with("UNIQUE constraint failed:") {
11540                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
11541                } else {
11542                    error
11543                }
11544            })
11545    }
11546
11547    /// Whether the row is present in the overlay. Existence reads only the
11548    /// key: locked encrypted values stay opaque, and decoding the complete row
11549    /// would reject a valid plain write.
11550    fn visible_row_exists(&self, table: &TableSchema, row_id: &str) -> Result<bool, String> {
11551        self.conn
11552            .query_row(
11553                &format!(
11554                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
11555                    visible_table(&table.name),
11556                    row_id_predicate(table)
11557                ),
11558                rusqlite::params![row_id],
11559                |row| row.get(0),
11560            )
11561            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11562    }
11563
11564    /// The visible (optimistic) row's values plus version, or `None` when the
11565    /// row is absent from the overlay.
11566    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
11567        self.stored_row(table, row_id, false)
11568    }
11569
11570    fn stored_row(
11571        &self,
11572        table: &TableSchema,
11573        row_id: &str,
11574        base: bool,
11575    ) -> Result<Option<(Row, i64)>, String> {
11576        let sql = format!(
11577            "SELECT * FROM {} WHERE {}",
11578            if base {
11579                base_table(&table.name)
11580            } else {
11581                visible_table(&table.name)
11582            },
11583            row_id_predicate(table)
11584        );
11585        let mut stmt = self
11586            .conn
11587            .prepare_cached(&sql)
11588            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11589        let mut rows = stmt
11590            .query(rusqlite::params![row_id])
11591            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11592        let Some(row) = rows
11593            .next()
11594            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11595        else {
11596            return Ok(None);
11597        };
11598        let mut values = Vec::with_capacity(table.columns.len());
11599        for (index, column) in table.columns.iter().enumerate() {
11600            let raw = row
11601                .get_ref(index)
11602                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11603            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
11604                if table
11605                    .encrypted_columns
11606                    .iter()
11607                    .any(|encrypted| encrypted.index == index)
11608                {
11609                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
11610                    continue;
11611                }
11612            }
11613            let json = sql_ref_to_json(column, raw);
11614            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
11615                "sync.local_corrupt: persisted local row value is invalid".to_owned()
11616            })?);
11617        }
11618        let version: i64 = row
11619            .get(table.columns.len())
11620            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11621        Ok(Some((values, version)))
11622    }
11623
11624    // -- realtime (§8) ---------------------------------------------------------------
11625
11626    /// §8.8: the configured realtime policy.
11627    #[must_use]
11628    pub fn realtime_policy(&self) -> RealtimePolicy {
11629        self.realtime_policy
11630    }
11631
11632    /// §8.8: the explicit realtime availability state.
11633    #[must_use]
11634    pub fn realtime_state(&self) -> RealtimeState {
11635        self.realtime_state
11636    }
11637
11638    /// §8.8: set the realtime policy. Refused while the socket is connected:
11639    /// a policy change never silently re-routes an ownership state.
11640    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
11641        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
11642            return Err(
11643                "sync.invalid_request: disconnect realtime before changing the realtime policy"
11644                    .to_owned(),
11645            );
11646        }
11647        self.realtime_policy = policy;
11648        match policy {
11649            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
11650            RealtimePolicy::Required | RealtimePolicy::Optional
11651                if self.realtime_state == RealtimeState::Disabled =>
11652            {
11653                self.set_realtime_state(RealtimeState::Disconnected, None);
11654            }
11655            RealtimePolicy::Required | RealtimePolicy::Optional => {}
11656        }
11657        Ok(())
11658    }
11659
11660    /// Record an availability transition, keeping the reason code bounded and
11661    /// code-like (§7.6) and clearing it where it cannot apply.
11662    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
11663        self.realtime_state = state;
11664        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
11665        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
11666            self.realtime_reason_code = None;
11667            self.realtime_retry_delay_ms = None;
11668        }
11669    }
11670
11671    #[must_use]
11672    fn realtime_connected(&self) -> bool {
11673        self.realtime_state == RealtimeState::Connected
11674    }
11675
11676    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11677        if !self.transport_enabled {
11678            return Err("sync.offline: transport gate is closed".into());
11679        }
11680        if self.realtime_policy == RealtimePolicy::Off {
11681            return Err(
11682                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
11683            );
11684        }
11685        if self.realtime_connected() {
11686            return Ok(());
11687        }
11688        self.realtime_state = RealtimeState::Connecting;
11689        match transport.realtime_connect_for_client(&self.client_id) {
11690            Ok(()) => {
11691                self.set_realtime_state(RealtimeState::Connected, None);
11692                Ok(())
11693            }
11694            Err(error) => {
11695                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
11696                Err(format!("{}: {}", error.code, error.message))
11697            }
11698        }
11699    }
11700
11701    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11702        self.cancel_sync_round();
11703        // A deliberately disconnected or policy-disabled client has nothing
11704        // to release; a `lost` socket still does (the host owns the handle).
11705        if self.realtime_state == RealtimeState::Disconnected
11706            || self.realtime_state == RealtimeState::Disabled
11707        {
11708            return;
11709        }
11710        let _ = transport.realtime_close();
11711        self.set_realtime_state(
11712            if self.realtime_policy == RealtimePolicy::Off {
11713                RealtimeState::Disabled
11714            } else {
11715                RealtimeState::Disconnected
11716            },
11717            None,
11718        );
11719        self.presence.clear(); // §8.6.1: presence is per-connection
11720    }
11721
11722    /// §8.8: the host observed the socket end without disconnecting it (a
11723    /// peer close, an I/O error, a failed send; the native transport reports
11724    /// these as `Inbound::Disconnected`). Releases the transport, moves a
11725    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
11726    /// one background retry intent so the host reconnects and catches up.
11727    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
11728        if self.realtime_state != RealtimeState::Connected {
11729            return;
11730        }
11731        self.cancel_sync_round();
11732        let _ = transport.realtime_close();
11733        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
11734        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
11735        self.presence.clear();
11736    }
11737
11738    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
11739    /// document for `scope_key`. Requires a live socket; the document is
11740    /// ephemeral (lost on disconnect). Authorization is the connection's
11741    /// registration (§8.6.3) — an unheld key is rejected loudly by the
11742    /// server with `presence.forbidden`.
11743    pub fn set_presence(
11744        &mut self,
11745        transport: &mut dyn Transport,
11746        scope_key: &str,
11747        doc: Option<&Value>,
11748    ) -> Result<(), String> {
11749        if !self.transport_enabled {
11750            return Err("sync.offline: transport gate is closed".into());
11751        }
11752        if !self.realtime_connected() {
11753            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11754        }
11755        let text = encode_presence_publish(scope_key, doc);
11756        transport
11757            .realtime_send(&text)
11758            .map_err(|e| format!("{}: {}", e.code, e.message))
11759    }
11760
11761    /// §8.6: the peers currently present on a scope key (ephemeral).
11762    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11763        self.presence
11764            .get(scope_key)
11765            .map(|peers| peers.values().cloned().collect())
11766            .unwrap_or_default()
11767    }
11768
11769    /// §8.6 apply an inbound presence fanout to the local map.
11770    fn apply_presence(
11771        &mut self,
11772        scope_key: String,
11773        kind: Option<PresenceKind>,
11774        actor_id: Option<String>,
11775        client_id: Option<String>,
11776        doc: Option<Value>,
11777        error: Option<String>,
11778    ) {
11779        // A refused publish (§8.6.2) changes no peer; the native host surfaces
11780        // it as a `presence` event carrying the server's `error` code.
11781        if error.is_some() {
11782            return;
11783        }
11784        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11785            return;
11786        };
11787        let peer_key = format!("{actor_id} {client_id}");
11788        match kind {
11789            PresenceKind::Leave => {
11790                if let Some(peers) = self.presence.get_mut(&scope_key) {
11791                    peers.remove(&peer_key);
11792                    if peers.is_empty() {
11793                        self.presence.remove(&scope_key);
11794                    }
11795                }
11796            }
11797            _ => {
11798                let doc = match doc {
11799                    Some(Value::Object(_)) => doc.unwrap(),
11800                    _ => return,
11801                };
11802                self.presence.entry(scope_key).or_default().insert(
11803                    peer_key,
11804                    PresencePeer {
11805                        actor_id,
11806                        client_id,
11807                        doc,
11808                    },
11809                );
11810            }
11811        }
11812    }
11813
11814    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
11815    pub fn on_realtime_text(&mut self, text: &str) {
11816        if self.stopped || !self.transport_enabled || self.security_preflight {
11817            return;
11818        }
11819        match parse_control(text) {
11820            Ok(ControlMessage::Hello { requires_sync, .. }) => {
11821                if requires_sync {
11822                    // §8.1: pull before trusting the socket for continuity.
11823                    self.set_sync_needed(true, true);
11824                }
11825            }
11826            Ok(ControlMessage::Presence {
11827                scope_key,
11828                kind,
11829                actor_id,
11830                client_id,
11831                doc,
11832                error,
11833                ..
11834            }) => {
11835                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11836            }
11837            Ok(ControlMessage::Wake { .. }) => {
11838                // §8.3: any wake-up means "run a pull soon", never data.
11839                self.set_sync_needed(true, true);
11840            }
11841            _ => {}
11842        }
11843    }
11844
11845    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
11846    /// a pull response per section; an unapplied delta is a wake-up.
11847    /// Mailbox hosts send durable acknowledgements on their I/O executor.
11848    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
11849        let mut transport = crate::round::DownloadResults::default();
11850        self.on_realtime_binary(&mut transport, bytes);
11851        transport.controls
11852    }
11853
11854    /// §8.2: a delta applies exactly like a live section and sets the replica
11855    /// cursor to its `throughSeq`; one at or below the replica cursor is a
11856    /// duplicate. A delta the client does not apply (no live unit, a status
11857    /// other than `active`, a decode or apply failure) is a drop and raises
11858    /// sync-needed.
11859    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11860        if self.stopped || !self.transport_enabled || self.security_preflight {
11861            return;
11862        }
11863        #[cfg(feature = "bench-internals")]
11864        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11865        let message = match decode_message(bytes) {
11866            Ok(m) if m.msg_kind == MsgKind::Response => m,
11867            _ => {
11868                self.set_sync_needed(true, true);
11869                return;
11870            }
11871        };
11872        #[cfg(feature = "bench-internals")]
11873        drop(decode_phase);
11874        // §8.2: a delta's header means what a pull response's header means. A
11875        // schema floor (§1.6) is recorded, stops syncing and applies nothing.
11876        if let Some(Frame::RespHeader {
11877            required_schema_version: Some(required),
11878            latest_schema_version,
11879            ..
11880        }) = message.frames.first()
11881        {
11882            let floor = SchemaFloor {
11883                required_schema_version: Some(*required),
11884                latest_schema_version: *latest_schema_version,
11885            };
11886            if self.set_schema_floor(Some(floor)).is_err() {
11887                self.set_sync_needed(true, true);
11888            }
11889            return;
11890        }
11891        let through = message.frames.iter().find_map(|frame| match frame {
11892            Frame::LiveEnd { through_seq, .. } => Some(*through_seq),
11893            _ => None,
11894        });
11895        let epoch = self.get_meta(LOG_EPOCH_KEY).ok().flatten();
11896        let mut dropped = !matches!(message.frames.first(), Some(Frame::RespHeader {
11897                reset_required: false,
11898                log_epoch,
11899                ..
11900            }) if Some(log_epoch) == epoch.as_ref());
11901        let mut applied: Option<i64> = None;
11902        dropped |= self.begin_coalesced_apply().is_err();
11903        let mut receivers: Option<Vec<UnitRecord>> = None;
11904        for frame in message.frames {
11905            if dropped {
11906                break;
11907            }
11908            match frame {
11909                Frame::LiveStart { status, .. } => {
11910                    let Ok(cursor) = self.stored(replica_cursor(&self.conn)) else {
11911                        dropped = true;
11912                        break;
11913                    };
11914                    if status != LiveStatus::Active || cursor < 0 {
11915                        dropped = true;
11916                    } else if !through.is_some_and(|through| through <= cursor) {
11917                        // A catching-up unit receives no delta (§8.2).
11918                        receivers = self.stored(load_units(&self.conn)).ok().map(|units| {
11919                            units
11920                                .into_iter()
11921                                .filter(|unit| {
11922                                    unit.status == UnitStatus::Active
11923                                        && unit.echo.is_some()
11924                                        && unit.cursor.is_none()
11925                                })
11926                                .collect()
11927                        });
11928                        dropped = receivers.is_none();
11929                    }
11930                }
11931                Frame::Commit {
11932                    commit_seq,
11933                    tables,
11934                    changes,
11935                    ..
11936                } => {
11937                    let Some(receivers) = receivers.clone() else {
11938                        continue;
11939                    };
11940                    dropped = self
11941                        .apply_live_commit(
11942                            &receivers,
11943                            &tables,
11944                            changes,
11945                            commit_seq,
11946                            AppliedServerCommitSource::Delta,
11947                        )
11948                        .is_err();
11949                }
11950                Frame::LiveEnd { through_seq, .. } if receivers.is_some() => {
11951                    dropped = self
11952                        .set_meta(REPLICA_CURSOR_KEY, &through_seq.to_string())
11953                        .is_err();
11954                    applied = Some(through_seq);
11955                }
11956                Frame::Error { .. } => dropped = true,
11957                _ => {}
11958            }
11959        }
11960        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
11961        // the replica cursor, so the delta counts as a drop.
11962        if self.finish_coalesced_apply().is_err() || dropped {
11963            applied = None;
11964            dropped = true;
11965        }
11966        if let Some(cursor) = applied {
11967            // §8.2 ack point: the delta's throughSeq.
11968            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11969            let _ = transport.realtime_send(&ack);
11970        }
11971        if dropped {
11972            self.set_sync_needed(true, true);
11973        }
11974    }
11975
11976    /// §8.2 ack point after a pull round on a live connection: the replica
11977    /// cursor while it is at least `0`.
11978    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11979        if !self.realtime_connected() {
11980            return;
11981        }
11982        if let Ok(cursor @ 0..) = self.stored(replica_cursor(&self.conn)) {
11983            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11984            let _ = transport.realtime_send(&ack);
11985        }
11986    }
11987}
11988
11989#[cfg(test)]
11990mod previous_version_wiring_tests;
11991
11992#[cfg(test)]
11993mod identity_tests;
11994
11995#[cfg(test)]
11996mod insert_intent_tests;
11997
11998#[cfg(test)]
11999mod robustness_tests;