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};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32    ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
33    CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
34    CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
35    CurrentBaseProof, CurrentBaseProofReason, DiagnosticLastChange, DiagnosticLastRound,
36    DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob,
37    LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
38    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
39    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
40    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
41    RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
42    SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
43    SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
44    WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
45    MAX_DIAGNOSTIC_QUERY_FAILURES,
46};
47use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
48#[cfg(feature = "bench-internals")]
49use crate::bench::{Phase, Recorder};
50use crate::previous_version::{
51    capture_previous_version_from_replica, first_incompatibility,
52    reconcile_previous_version_at_boot, set_local_schema_descriptor,
53    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
54    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
55};
56use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
57use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
58use crate::values::{
59    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
60    encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
61    json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
62    sort_scope_map,
63};
64
65/// §4.2 default: rows and SQLite images, both applied in committed chunks.
66/// Bit 3 (signed URLs, §5.4) is
67/// added per transport capability at request-build time.
68const DEFAULT_ACCEPT: u8 = 0b0111;
69const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
70const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
71const ACCEPT_SQLITE: u8 = 1 << 2;
72const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
73const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
74
75/// §10.1 `recommendedAction` carried by a server HTTP error body.
76fn recommended_action_of(details: &Option<Value>) -> Option<String> {
77    details
78        .as_ref()?
79        .get("recommendedAction")?
80        .as_str()
81        .map(str::to_owned)
82}
83
84/// §2.2 scope migration: one commit carries the delete under the old scope and
85/// the upsert under the new one. A reader that holds both scopes receives the
86/// two changes in separate subscription sections, so section order would
87/// otherwise decide whether the row survives. Collect the `(commitSeq, table,
88/// rowId)` keys whose delete must be skipped because the same commit delivers
89/// the row's upsert in another applied section. A section that carries both
90/// changes itself keeps its own operation order.
91fn coalesced_delete_keys(
92    frames: &[Frame],
93    mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
94) -> HashSet<(i64, String, String)> {
95    let mut sections = Vec::new();
96    let mut open: Option<(usize, String, SubStatus)> = None;
97    for (index, frame) in frames.iter().enumerate() {
98        match frame {
99            Frame::SubStart { id, status, .. } => {
100                open = Some((index, id.clone(), *status));
101            }
102            Frame::SubEnd { next_cursor, .. } => {
103                if let Some((start, id, status)) = open.take() {
104                    sections.push((start, index, id, status, Some(*next_cursor)));
105                }
106            }
107            _ => {}
108        }
109    }
110    type Key = (i64, String, String);
111    let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
112    let mut both_in_frame: HashSet<Key> = HashSet::new();
113    let mut frame_ordinal = 0usize;
114    for (start, end, id, status, next_cursor) in sections {
115        if !applies(&id, status, next_cursor) {
116            continue;
117        }
118        for frame in &frames[start + 1..end] {
119            let Frame::Commit {
120                commit_seq,
121                tables,
122                changes,
123                ..
124            } = frame
125            else {
126                continue;
127            };
128            frame_ordinal += 1;
129            let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
130            for change in changes {
131                let Some(table) = tables.get(change.table_index as usize) else {
132                    continue;
133                };
134                let key = (*commit_seq, table.clone(), change.row_id.clone());
135                let entry = ops.entry(key).or_default();
136                let local_entry = local
137                    .entry((table.clone(), change.row_id.clone()))
138                    .or_default();
139                match change.op {
140                    Op::Upsert => {
141                        entry.0.insert(frame_ordinal);
142                        local_entry.0 = true;
143                    }
144                    Op::Delete => {
145                        entry.1.insert(frame_ordinal);
146                        local_entry.1 = true;
147                    }
148                }
149            }
150            for ((table, row_id), (has_upsert, has_delete)) in local {
151                if has_upsert && has_delete {
152                    both_in_frame.insert((*commit_seq, table, row_id));
153                }
154            }
155        }
156    }
157    ops.into_iter()
158        .filter_map(|(key, (upserts, deletes))| {
159            (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
160                .then_some(key)
161        })
162        .collect()
163}
164
165/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
166pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
167const LOCAL_REVISION_KEY: &str = "localRevision";
168const CLIENT_ID_KEY: &str = "clientId";
169const LEASE_STATE_KEY: &str = "leaseState";
170const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
171pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
172/// Persisted fail-closed quarantine marker: present while a security preflight
173/// is pending and `activateSecurity` has yet to run. Storing it in the replica
174/// keeps the gate with the data it protects, so a rebuilt host handle reopening
175/// the same file returns in preflight even though its in-memory state is fresh.
176const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
177const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
178const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
179/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
180/// the new schema after a bump. Never a wire code (§10.3).
181const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
182/// §5.11 client-local code: an encode at the push seam resolved no usable key
183/// id or named an unknown key. Never a wire code (§10.3).
184const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
185const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
186/// §7.4.4: static text for a persisted commit the current codec refuses.
187const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
188    "the persisted commit carries values the current codec refuses";
189/// §7.4.4: bounded reason on every intrinsic value drop.
190const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
191
192#[derive(Debug, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194struct PersistedLocalDataRebootstrapReceipt {
195    version: u8,
196    retained_commits: u64,
197    reset_subscriptions: u64,
198}
199
200fn invalid_local_rebootstrap_receipt() -> String {
201    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
202}
203
204fn encode_local_rebootstrap_receipt(
205    retained_commits: usize,
206    reset_subscriptions: usize,
207) -> Result<String, String> {
208    let retained_commits =
209        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
210    let reset_subscriptions =
211        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
212    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
213        return Err(invalid_local_rebootstrap_receipt());
214    }
215    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
216        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
217        retained_commits,
218        reset_subscriptions,
219    })
220    .map_err(|_| invalid_local_rebootstrap_receipt())
221}
222
223fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
224    // Pre-0.15.36 markers proved application but did not retain the receipt.
225    if value == "v1" {
226        return Ok((0, 0));
227    }
228    let receipt: PersistedLocalDataRebootstrapReceipt =
229        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
230    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
231        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
232        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
233    {
234        return Err(invalid_local_rebootstrap_receipt());
235    }
236    Ok((
237        usize::try_from(receipt.retained_commits)
238            .map_err(|_| invalid_local_rebootstrap_receipt())?,
239        usize::try_from(receipt.reset_subscriptions)
240            .map_err(|_| invalid_local_rebootstrap_receipt())?,
241    ))
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum SubState {
246    Active,
247    Revoked,
248    Failed,
249}
250
251#[cfg(test)]
252mod observation_tests {
253    use super::*;
254    use crate::native_transport::HostTransport;
255    use crate::values::encode_row_json;
256    use crate::BlobUploadGrant;
257    use serde_json::json;
258
259    #[test]
260    fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
261        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
262            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
263        ]}]});
264        for name in [
265            "secret",
266            "missing",
267            "*",
268            "id FROM authority; DELETE FROM authority",
269            "_syncular_version",
270        ] {
271            let limits = ClientLimits {
272                authority_reads: vec![crate::api::AuthorityReadDeclaration {
273                    table: "authority".into(),
274                    columns: vec!["id".into(), "actor_id".into(), name.into()],
275                    scopes: [("actor_id".into(), vec!["a".into()])].into(),
276                }],
277                ..Default::default()
278            };
279            let error = SyncClient::new("authority".into(), &schema, limits)
280                .err()
281                .expect("forbidden policy");
282            assert!(
283                error.starts_with("client.authority_read_forbidden:"),
284                "{error}"
285            );
286        }
287    }
288
289    #[test]
290    fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
291        let path = std::env::temp_dir().join(format!(
292            "syncular-authority-snapshot-{}.db",
293            uuid::Uuid::new_v4()
294        ));
295        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
296            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
297        ]}]});
298        let limits = ClientLimits {
299            authority_reads: vec![crate::api::AuthorityReadDeclaration {
300                table: "authority".into(),
301                columns: vec!["id".into(), "actor_id".into(), "revision".into()],
302                scopes: [("actor_id".into(), vec!["a".into()])].into(),
303            }],
304            ..Default::default()
305        };
306        let mut client =
307            SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
308                .unwrap();
309        client.begin_security_preflight();
310        let update = |conn: &Connection, revision: i64| {
311            conn.execute_batch("BEGIN IMMEDIATE").unwrap();
312            conn.execute(
313                "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
314                [revision],
315            )
316            .unwrap();
317            conn.execute(
318                "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
319                [revision.to_string()],
320            )
321            .unwrap();
322            conn.execute("INSERT OR REPLACE INTO _syncular_subscriptions VALUES('s','authority',?1)", [json!({"requested": {"actor_id": ["a"]}, "effectiveScopes": {"actor_id": ["a"]}, "status": "active", "cursor": revision}).to_string()]).unwrap();
323            conn.execute_batch("COMMIT").unwrap();
324        };
325        update(&client.conn, 1);
326        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
327        let writer_barrier = barrier.clone();
328        let writer_path = path.clone();
329        let writer = std::thread::spawn(move || {
330            let conn = Connection::open(writer_path).unwrap();
331            conn.busy_timeout(std::time::Duration::from_secs(5))
332                .unwrap();
333            writer_barrier.wait();
334            for revision in 2..=100 {
335                update(&conn, revision);
336            }
337        });
338        barrier.wait();
339        for _ in 0..100 {
340            let snapshot = client.authority_snapshot().unwrap();
341            let revision = snapshot["revision"]
342                .as_str()
343                .unwrap()
344                .parse::<i64>()
345                .unwrap();
346            assert_eq!(
347                snapshot["tables"][0]["rows"][0]["values"]["revision"],
348                revision
349            );
350            assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
351            assert_eq!(snapshot["complete"], true);
352        }
353        writer.join().unwrap();
354        assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
355        drop(client);
356        let _ = std::fs::remove_file(path);
357    }
358
359    fn client() -> SyncClient {
360        SyncClient::new(
361            "retry-test".to_owned(),
362            &json!({
363                "version": 1,
364                "tables": [{
365                    "name": "tasks",
366                    "primaryKey": "id",
367                    "columns": [
368                        { "name": "id", "type": "string", "nullable": false },
369                        { "name": "project_id", "type": "string", "nullable": false }
370                    ],
371                    "scopes": [{ "pattern": "project:{project_id}" }]
372                }]
373            }),
374            ClientLimits::default(),
375        )
376        .expect("test client")
377    }
378
379    fn round_error(outcome: SyncOutcome) -> TransportError {
380        match outcome {
381            SyncOutcome::Failed {
382                error_code,
383                message,
384                details,
385                ..
386            } => TransportError {
387                code: error_code,
388                message,
389                details,
390            },
391            other => TransportError::new(
392                "client.failed",
393                format!("unexpected round outcome: {other:?}"),
394            ),
395        }
396    }
397
398    /// Upload every pinned blob through the split round API and stop before the
399    /// metadata request.
400    fn flush_blob_uploads(
401        client: &mut SyncClient,
402        transport: &mut dyn Transport,
403    ) -> Result<(), TransportError> {
404        let mut prepared = match client.prepare_sync_round(false) {
405            Ok(prepared) => prepared,
406            Err(outcome) => return Err(round_error(*outcome)),
407        };
408        loop {
409            if prepared.uploads.is_empty() {
410                client.cancel_sync_round();
411                return Ok(());
412            }
413            let completed = crate::round::test_support::drive_round(prepared, transport);
414            prepared = match client.apply_sync_round(completed) {
415                crate::AppliedSyncRound::Continue(prepared) => prepared,
416                crate::AppliedSyncRound::Complete { outcome, .. } => {
417                    return match outcome {
418                        SyncOutcome::Ok(_) => Ok(()),
419                        other => Err(round_error(other)),
420                    };
421                }
422            };
423        }
424    }
425
426    #[test]
427    fn subscribe_many_registers_all_or_nothing() {
428        let request = |id: &str, project: &str| SubscriptionRequest {
429            id: id.to_owned(),
430            table: "tasks".to_owned(),
431            scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
432            params: None,
433        };
434        let mut client = client();
435        client
436            .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
437            .unwrap();
438        let persisted = |client: &SyncClient| -> i64 {
439            client
440                .conn
441                .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
442                    row.get(0)
443                })
444                .unwrap()
445        };
446        assert_eq!(persisted(&client), 2);
447        // `b` names another project: the whole call fails, `c` stays out.
448        let error = client
449            .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
450            .unwrap_err();
451        assert!(error.starts_with("client.subscription_intent_mismatch:"));
452        assert!(client.subscription_state("c").is_none());
453        assert_eq!(persisted(&client), 2);
454        assert!(client.conn.is_autocommit());
455    }
456
457    #[cfg(feature = "e2ee")]
458    #[test]
459    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
460        let schema = json!({
461            "version": 1,
462            "tables": [{
463                "name": "secrets",
464                "primaryKey": "id",
465                "columns": [
466                    { "name": "id", "type": "string", "nullable": false },
467                    { "name": "encryption_key_id", "type": "string", "nullable": true },
468                    { "name": "note", "type": "bytes", "nullable": true,
469                      "encrypted": true, "declaredType": "string" }
470                ],
471                "scopes": []
472            }]
473        });
474        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
475            .expect("test client");
476        let mut config = crate::values::EncryptionConfig::default();
477        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
478        config
479            .key_id_columns
480            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
481        client.set_encryption(config);
482        // The row exists, but its key selector is absent. The encrypted edit
483        // authors locally and fails at the push seam.
484        client
485            .write_base_row(
486                "secrets",
487                &vec![Some(ColumnValue::String("ghost".into())), None, None],
488                1,
489            )
490            .unwrap();
491        client.rebuild_overlay().expect("rebuild overlay");
492        let commit_id = client
493            .patch(
494                "secrets",
495                "ghost",
496                Map::from_iter([("note".to_owned(), json!("x"))]),
497                None,
498            )
499            .expect("patch records without an author-time encrypt failure");
500        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
501        // ...and the push seam raises a durable local rejection, not a throw.
502        assert!(client.drop_unencodable_outbox(true, None).unwrap());
503        assert!(client.pending_commit_ids().is_empty());
504        let rejections = client.rejections();
505        assert_eq!(rejections.len(), 1);
506        assert_eq!(rejections[0].code, "client.encrypt_failed");
507        assert_eq!(rejections[0].client_commit_id, commit_id);
508        assert_eq!(
509            client
510                .commit_outcome(&commit_id)
511                .unwrap()
512                .map(|outcome| outcome.status),
513            Some(CommitOutcomeStatus::Rejected)
514        );
515    }
516
517    #[test]
518    fn patch_scope_read_preserves_sqlite_failure() {
519        let mut client = client();
520        client
521            .conn
522            .execute_batch(
523                "DROP TABLE tasks;
524            CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
525            )
526            .unwrap();
527        let error = client
528            .patch(
529                "tasks",
530                "t1",
531                Map::from_iter([("project_id".to_owned(), json!("p1"))]),
532                None,
533            )
534            .expect_err("the scope read must fail");
535        assert_ne!(error.code, "sync.invalid_request");
536        assert_eq!(error.code, "client.failed");
537        assert!(error.details.as_ref().unwrap()["legacyCause"]
538            .as_str()
539            .unwrap()
540            .contains("integer overflow"));
541        assert!(client.pending_commit_ids().is_empty());
542        assert!(client.conn.is_autocommit());
543    }
544
545    #[test]
546    fn patch_with_a_scope_column_matches_the_stored_local_row() {
547        let mut client = client();
548        client.create_synced_tables().unwrap();
549        client
550            .conn
551            .execute(
552                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
553                [],
554            )
555            .unwrap();
556
557        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
558        // the scope column leaves the presence set.
559        client
560            .patch(
561                "tasks",
562                "t1",
563                Map::from_iter([
564                    ("project_id".to_owned(), json!("p1")),
565                    ("id".to_owned(), json!("t1")),
566                ]),
567                None,
568            )
569            .expect("an equal scope column is a no-op");
570        let values = client.outbox[0].ops[0]
571            .values
572            .clone()
573            .expect("the patch records values");
574        assert_eq!(values.get("id"), Some(&json!("t1")));
575        assert!(!values.contains_key("project_id"));
576
577        // A differing value stays rejected.
578        let differing = client.patch(
579            "tasks",
580            "t1",
581            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
582            None,
583        );
584        let differing = differing.expect_err("a differing scope column is rejected");
585        assert_eq!(differing.code, "sync.invalid_request");
586        assert_eq!(differing.message, "the authoring request is invalid");
587        assert!(
588            differing
589                .details
590                .as_ref()
591                .and_then(|details| details["legacyCause"].as_str())
592                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
593            "{differing:?}"
594        );
595
596        // An absent local row leaves nothing to prove equality against, so
597        // the patch fails closed.
598        let absent = client.patch(
599            "tasks",
600            "ghost",
601            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
602            None,
603        );
604        let absent = absent.expect_err("an absent local row is rejected");
605        assert_eq!(absent.code, "sync.row_missing");
606        assert!(absent.message.contains("requires a local row"));
607        assert_eq!(client.pending_commit_ids().len(), 1);
608    }
609
610    #[test]
611    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
612        let schema = json!({
613            "version": 1,
614            "tables": [
615                {
616                    "name": "tenants",
617                    "primaryKey": "tenant_id",
618                    "columns": [
619                        { "name": "tenant_id", "type": "string", "nullable": false },
620                        { "name": "body", "type": "string", "nullable": false }
621                    ],
622                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
623                },
624                {
625                    "name": "buckets",
626                    "primaryKey": "id",
627                    "columns": [
628                        { "name": "id", "type": "string", "nullable": false },
629                        { "name": "bucket", "type": "float", "nullable": false },
630                        { "name": "body", "type": "string", "nullable": false }
631                    ],
632                    "scopes": [{ "pattern": "bucket:{bucket}" }]
633                }
634            ]
635        });
636        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
637            .expect("test client");
638        client.create_synced_tables().unwrap();
639
640        // A primary key that is also a scope column still needs a local base.
641        client
642            .conn
643            .execute(
644                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
645                [],
646            )
647            .unwrap();
648        client
649            .patch(
650                "tenants",
651                "t1",
652                Map::from_iter([
653                    ("tenant_id".to_owned(), json!("t1")),
654                    ("body".to_owned(), json!("created")),
655                ]),
656                None,
657            )
658            .expect("the primary-key scope column is proven equal by construction");
659
660        // A stored REAL scope value coerces from a supplied integer.
661        client
662            .conn
663            .execute(
664                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
665                [],
666            )
667            .unwrap();
668        client
669            .patch(
670                "buckets",
671                "b1",
672                Map::from_iter([
673                    ("bucket".to_owned(), json!(2)),
674                    ("body".to_owned(), json!("coerced")),
675                ]),
676                None,
677            )
678            .expect("the stored REAL value coerces from a supplied integer");
679        let values = client.outbox[1].ops[0]
680            .values
681            .clone()
682            .expect("the patch records values");
683        assert!(!values.contains_key("bucket"));
684
685        // A differing value stays rejected.
686        let differing = client.patch(
687            "buckets",
688            "b1",
689            Map::from_iter([("bucket".to_owned(), json!(3))]),
690            None,
691        );
692        let differing = differing.expect_err("a differing scope value is rejected");
693        assert_eq!(differing.code, "sync.invalid_request");
694        assert_eq!(differing.message, "the authoring request is invalid");
695        assert!(
696            differing
697                .details
698                .as_ref()
699                .and_then(|details| details["legacyCause"].as_str())
700                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
701            "{differing:?}"
702        );
703    }
704
705    #[cfg(feature = "e2ee")]
706    #[test]
707    fn stored_key_fallback_resolves_an_integer_primary_key() {
708        let schema = json!({
709            "version": 1,
710            "tables": [{
711                "name": "patients",
712                "primaryKey": "id",
713                "columns": [
714                    { "name": "id", "type": "integer", "nullable": false },
715                    { "name": "encryption_key_id", "type": "string", "nullable": true },
716                    { "name": "note", "type": "bytes", "nullable": true,
717                      "encrypted": true, "declaredType": "string" }
718                ],
719                "scopes": []
720            }]
721        });
722        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
723            .expect("test client");
724        client.create_synced_tables().unwrap();
725        client
726            .conn
727            .execute(
728                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
729                 VALUES (5, 'k1', NULL, 1)",
730                [],
731            )
732            .unwrap();
733        let mut config = crate::values::EncryptionConfig::default();
734        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
735        config
736            .key_id_columns
737            .insert("patients".to_owned(), "encryption_key_id".to_owned());
738        client.set_encryption(config);
739        let table = client.schema.table("patients").unwrap();
740        let fallback = client
741            .stored_key_fallback(table, "5")
742            .expect("the stored integer key resolves the fallback");
743        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
744    }
745
746    #[test]
747    fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
748        let schema = json!({
749            "version": 1,
750            "tables": [{
751                "name": "counters",
752                "primaryKey": "id",
753                "columns": [
754                    { "name": "id", "type": "integer", "nullable": false },
755                    { "name": "title", "type": "string", "nullable": false }
756                ],
757                "scopes": []
758            }]
759        });
760        let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
761            .expect("test client");
762        client
763            .write_base_row(
764                "counters",
765                &vec![
766                    Some(ColumnValue::Integer(42)),
767                    Some(ColumnValue::String("before".into())),
768                ],
769                1,
770            )
771            .unwrap();
772        client.rebuild_overlay().expect("rebuild overlay");
773        client
774            .patch(
775                "counters",
776                "42",
777                Map::from_iter([
778                    ("id".to_owned(), json!(42)),
779                    ("title".to_owned(), json!("after")),
780                ]),
781                None,
782            )
783            .expect("an integer key that renders as the rowId is not a change");
784        let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
785        assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
786        for row_id in ["042", "+42", "42.0"] {
787            let error = client
788                .patch(
789                    "counters",
790                    row_id,
791                    Map::from_iter([("title".to_owned(), json!("x"))]),
792                    None,
793                )
794                .expect_err("a non-canonical integer rowId names no row");
795            assert_eq!(error.code, "sync.row_missing", "{row_id}");
796        }
797    }
798
799    #[test]
800    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
801        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
802            {"name":"id","type":"string","nullable":false},
803            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
804        for state in ["absent", "cached", "pinned"] {
805            let path = std::env::temp_dir().join(format!(
806                "syncular-blob-stage-{}.sqlite",
807                uuid::Uuid::new_v4()
808            ));
809            let mut client = SyncClient::open_path(
810                "stage-test".into(),
811                &schema,
812                Default::default(),
813                path.to_str().unwrap(),
814            )
815            .unwrap();
816            let bytes = b"atomic stage";
817            if state != "absent" {
818                client
819                    .upload_blob(bytes, Some("text/plain".into()), None)
820                    .unwrap();
821                if state == "cached" {
822                    client
823                        .conn
824                        .execute("DELETE FROM _syncular_blob_uploads", [])
825                        .unwrap();
826                }
827            }
828            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
829            client
830                .conn
831                .execute_batch(
832                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
833                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
834                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
835                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
836                )
837                .unwrap();
838            let error = client
839                .upload_blob(bytes, Some("application/octet-stream".into()), None)
840                .unwrap_err();
841            assert!(error.contains("injected stage failure"), "{state}: {error}");
842            assert!(client.conn.is_autocommit());
843            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
844            drop(client);
845            let mut reopened = SyncClient::open_path(
846                "stage-test".into(),
847                &schema,
848                Default::default(),
849                path.to_str().unwrap(),
850            )
851            .unwrap();
852            let durable = reopened
853                .query("SELECT * FROM _syncular_blobs", &[])
854                .unwrap();
855            reopened
856                .conn
857                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
858                .unwrap();
859            let retried = reopened.upload_blob(bytes, None, None).unwrap();
860            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
861            assert_eq!(
862                reopened
863                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
864                    .unwrap(),
865                vec![Map::from_iter([(
866                    "blob_id".into(),
867                    Value::from(blob_id_for(bytes)),
868                )])]
869            );
870            drop(reopened);
871            std::fs::remove_file(&path).unwrap();
872            assert_eq!(durable, bodies, "{state}: reopened body state");
873            assert_eq!(immediate, bodies, "{state}: immediate body state");
874        }
875    }
876
877    #[test]
878    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
879        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
880            {"name":"id","type":"string","nullable":false},
881            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
882        for fault in [
883            "missing",
884            "body-type",
885            "length",
886            "hash",
887            "pin-metadata",
888            "read",
889            "pin-delete",
890        ] {
891            let path = std::env::temp_dir().join(format!(
892                "syncular-blob-upload-{}.sqlite",
893                uuid::Uuid::new_v4()
894            ));
895            let mut client = SyncClient::open_path(
896                "upload-test".into(),
897                &schema,
898                Default::default(),
899                path.to_str().unwrap(),
900            )
901            .unwrap();
902            let bytes = b"durable upload";
903            let ref_value = client
904                .upload_blob(bytes, Some("text/plain".into()), None)
905                .unwrap();
906            let commit = client
907                .mutate(vec![Mutation::Upsert {
908                    table: "attachments".into(),
909                    values: Map::from_iter([
910                        ("id".into(), Value::from("pending")),
911                        (
912                            "file".into(),
913                            Value::from(format!(
914                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
915                                blob_id_for(bytes),
916                                bytes.len()
917                            )),
918                        ),
919                    ]),
920                    base_version: None,
921                }])
922                .unwrap();
923            client
924                .conn
925                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
926                .unwrap();
927            client.conn.execute_batch(match fault {
928                "missing" => "DELETE FROM _syncular_blobs",
929                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
930                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
931                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
932                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
933                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
934                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
935            }).unwrap();
936            let state = if fault == "read" {
937                Vec::new()
938            } else {
939                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
940            };
941            client.set_meta(LOG_EPOCH_KEY, "epoch");
942            let mut transport = CountingRealtimeTransport::default();
943            let error = flush_blob_uploads(&mut client, &mut transport).unwrap_err();
944            assert_eq!(
945                error.code,
946                if matches!(fault, "read" | "pin-delete") {
947                    "client.failed"
948                } else {
949                    "sync.local_corrupt"
950                },
951                "{fault}"
952            );
953            assert_eq!(
954                transport.blob_uploads.len(),
955                usize::from(fault == "pin-delete"),
956                "{fault}: corrupt bytes must never be uploaded"
957            );
958            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
959            if fault != "read" {
960                assert_eq!(
961                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
962                    state
963                );
964            } else {
965                client
966                    .conn
967                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
968                    .unwrap();
969            }
970            drop(client);
971            let mut reopened = SyncClient::open_path(
972                "upload-test".into(),
973                &schema,
974                Default::default(),
975                path.to_str().unwrap(),
976            )
977            .unwrap();
978            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
979            if fault != "read" {
980                assert_eq!(
981                    reopened
982                        .query("SELECT * FROM _syncular_blobs", &[])
983                        .unwrap(),
984                    state
985                );
986            }
987            if fault == "pin-delete" {
988                reopened
989                    .conn
990                    .execute_batch("DROP TRIGGER fail_pin_delete")
991                    .unwrap();
992            }
993            if fault == "pin-metadata" {
994                reopened
995                    .conn
996                    .execute(
997                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
998                        [],
999                    )
1000                    .unwrap();
1001            }
1002            reopened
1003                .conn
1004                .execute_batch(
1005                    "DELETE FROM _syncular_blobs;
1006                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
1007                )
1008                .unwrap();
1009            transport.blob_uploads.clear();
1010            flush_blob_uploads(&mut reopened, &mut transport).unwrap();
1011            assert_eq!(
1012                transport.blob_uploads,
1013                [(blob_id_for(bytes), bytes.to_vec())]
1014            );
1015            assert_eq!(
1016                reopened
1017                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
1018                    .unwrap(),
1019                Vec::<Map<String, Value>>::new()
1020            );
1021            assert_eq!(reopened.pending_commit_ids(), [commit]);
1022            assert_eq!(
1023                reopened
1024                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
1025                    .unwrap()
1026                    .unwrap()
1027                    .bytes,
1028                bytes
1029            );
1030            drop(reopened);
1031            std::fs::remove_file(path).unwrap();
1032        }
1033    }
1034
1035    /// §1.4 rule 4: `sections` subscriptions, each with `commits` COMMIT
1036    /// frames of `rows` rows. Returns the client with its subscriptions
1037    /// registered and the response.
1038    fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
1039        let mut client = client();
1040        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1041        let mut response = Message {
1042            wire_version: WIRE_VERSION,
1043            msg_kind: MsgKind::Response,
1044            frames: vec![Frame::RespHeader {
1045                required_schema_version: None,
1046                latest_schema_version: None,
1047                log_epoch: Some("epoch-1".into()),
1048                reset_required: Some(false),
1049            }],
1050        };
1051        let mut seq = 0;
1052        for section in 0..sections {
1053            let project = format!("p{section}");
1054            let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1055            client
1056                .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1057                .unwrap();
1058            let sub = client.subs.last_mut().unwrap();
1059            sub.cursor = 0;
1060            sub.synced_once = true;
1061            sub.effective = Some(scopes.clone());
1062            let sub = sub.clone();
1063            client.persist_sub(&sub).unwrap();
1064            response.frames.push(Frame::SubStart {
1065                id: format!("s{section}"),
1066                status: SubStatus::Active,
1067                reason_code: "".into(),
1068                effective_scopes: scopes,
1069                bootstrap: false,
1070            });
1071            for _ in 0..commits {
1072                seq += 1;
1073                let changes = (0..rows)
1074                    .map(|row| {
1075                        let id = format!("{project}-{seq}-{row}");
1076                        let values = Map::from_iter([
1077                            ("id".into(), Value::from(id.clone())),
1078                            ("project_id".into(), Value::from(project.clone())),
1079                        ]);
1080                        ssp2::model::Change {
1081                            table_index: 0,
1082                            row_id: id.clone(),
1083                            op: Op::Upsert,
1084                            row_version: Some(seq),
1085                            scopes: vec![("project_id".into(), project.clone())],
1086                            row: Some(
1087                                encode_row_json(
1088                                    client.schema.table("tasks").unwrap(),
1089                                    &id,
1090                                    &values,
1091                                    &client.encryption,
1092                                )
1093                                .unwrap(),
1094                            ),
1095                        }
1096                    })
1097                    .collect();
1098                response.frames.push(Frame::Commit {
1099                    commit_seq: seq,
1100                    actor_id: "writer".into(),
1101                    created_at_ms: 1,
1102                    tables: vec!["tasks".into()],
1103                    changes,
1104                });
1105            }
1106            response.frames.push(Frame::SubEnd {
1107                next_cursor: seq,
1108                bootstrap_state: None,
1109            });
1110        }
1111        client.drain_change_batches();
1112        (client, response)
1113    }
1114
1115    fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1116        let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1117        let counter = commits.clone();
1118        client
1119            .conn
1120            .commit_hook(Some(move || {
1121                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1122                false
1123            }))
1124            .expect("commit hook");
1125        commits
1126    }
1127
1128    #[test]
1129    fn a_response_of_many_small_sections_commits_once() {
1130        for realtime in [false, true] {
1131            let (mut client, response) = coalesced_fixture(70, 1, 1);
1132            client.realtime_state = RealtimeState::Connected;
1133            client.realtime_reason_code = None;
1134            let mut transport = CountingRealtimeTransport::default();
1135            let (_, meta) = client.build_request(false).unwrap();
1136            let commits = count_commits(&client);
1137            if realtime {
1138                client.on_realtime_binary(&mut transport, &encode_message(&response));
1139            } else {
1140                assert!(matches!(
1141                    client.process_response(&mut transport, response, &meta),
1142                    SyncOutcome::Ok(_)
1143                ));
1144            }
1145            assert_eq!(
1146                commits.load(std::sync::atomic::Ordering::SeqCst),
1147                1,
1148                "{realtime}"
1149            );
1150            assert!(client.conn.is_autocommit());
1151            assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1152            // Every frame keeps its own revision, published in order.
1153            let revisions = client
1154                .drain_change_batches()
1155                .into_iter()
1156                .filter(|batch| !batch.tables.is_empty())
1157                .map(|batch| batch.revision)
1158                .collect::<Vec<_>>();
1159            assert_eq!(
1160                revisions,
1161                (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1162            );
1163            assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1164            // The ack follows the COMMIT.
1165            assert_eq!(
1166                transport
1167                    .messages
1168                    .iter()
1169                    .filter(|m| m.contains("\"ack\""))
1170                    .count(),
1171                1,
1172                "{realtime}"
1173            );
1174        }
1175    }
1176
1177    #[test]
1178    fn a_response_past_the_row_bound_commits_per_bound() {
1179        // 3 frames of 600 rows: no two fit one 1,024-row transaction.
1180        let (mut client, response) = coalesced_fixture(1, 3, 600);
1181        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1182        let (_, meta) = client.build_request(false).unwrap();
1183        let commits = count_commits(&client);
1184        assert!(matches!(
1185            client.process_response(&mut transport, response, &meta),
1186            SyncOutcome::Ok(_)
1187        ));
1188        assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1189        assert_eq!(
1190            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1191            1800
1192        );
1193        assert_eq!(client.subs[0].cursor, 3);
1194    }
1195
1196    #[test]
1197    fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1198        // Two sections of two frames of 600 rows: the bound commits the first
1199        // frame alone, so the deferred violation it carries fails that
1200        // mid-response COMMIT, and every later frame of the response fails.
1201        let (mut client, response) = coalesced_fixture(2, 2, 600);
1202        client
1203            .conn
1204            .execute_batch(
1205                "PRAGMA foreign_keys = ON; CREATE TABLE fail_parent(id INTEGER PRIMARY KEY); CREATE TABLE fail_child(id INTEGER REFERENCES fail_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_commit AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'p0-1-0' BEGIN INSERT INTO fail_child VALUES(1); END",
1206            )
1207            .unwrap();
1208        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1209        let (_, meta) = client.build_request(false).unwrap();
1210        let subs = client.subs.clone();
1211        let revision = client.local_revision();
1212        let outcome = client.process_response(&mut transport, response.clone(), &meta);
1213        assert!(
1214            matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1215            "{outcome:?}"
1216        );
1217        assert!(client.conn.is_autocommit());
1218        assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1219        assert_eq!(client.local_revision(), revision);
1220        assert!(client
1221            .drain_change_batches()
1222            .iter()
1223            .all(|batch| batch.tables.is_empty()));
1224        assert!(client
1225            .query("SELECT id FROM tasks", &[])
1226            .unwrap()
1227            .is_empty());
1228        assert_eq!(
1229            client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1230            subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1231        );
1232        // The next response applies in full.
1233        client
1234            .conn
1235            .execute_batch("DROP TRIGGER fail_commit")
1236            .unwrap();
1237        let (_, meta) = client.build_request(false).unwrap();
1238        assert!(matches!(
1239            client.process_response(&mut transport, response, &meta),
1240            SyncOutcome::Ok(_)
1241        ));
1242        assert_eq!(
1243            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1244            2400
1245        );
1246    }
1247
1248    #[test]
1249    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1250        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1251            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1252            "scopes":[{"pattern":"project:{project_id}"}]}]});
1253        for realtime in [false, true] {
1254            for fault in [
1255                "none",
1256                "row",
1257                "revision",
1258                "commit",
1259                "cursor",
1260                "error",
1261                "missing-end",
1262            ] {
1263                if realtime && fault == "missing-end" {
1264                    continue;
1265                }
1266                let path = std::env::temp_dir()
1267                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1268                let mut client = SyncClient::open_path(
1269                    "frame-test".into(),
1270                    &schema,
1271                    Default::default(),
1272                    path.to_str().unwrap(),
1273                )
1274                .unwrap();
1275                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1276                client
1277                    .subscribe(
1278                        "tasks".into(),
1279                        "tasks".into(),
1280                        vec![("project_id".into(), vec!["p1".into()])],
1281                        None,
1282                    )
1283                    .unwrap();
1284                client.subs[0].cursor = 0;
1285                client.subs[0].synced_once = true;
1286                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1287                client.persist_sub(&client.subs[0]).unwrap();
1288                client.realtime_state = RealtimeState::Connected;
1289                client.realtime_reason_code = None;
1290                client.drain_change_batches();
1291                let mut transport = CountingRealtimeTransport::default();
1292                let (_, meta) = client.build_request(false).unwrap();
1293                let mut response = Message {
1294                    wire_version: WIRE_VERSION,
1295                    msg_kind: MsgKind::Response,
1296                    frames: vec![
1297                        Frame::RespHeader {
1298                            required_schema_version: None,
1299                            latest_schema_version: None,
1300                            log_epoch: Some("epoch-1".into()),
1301                            reset_required: Some(false),
1302                        },
1303                        Frame::SubStart {
1304                            id: "tasks".into(),
1305                            status: SubStatus::Active,
1306                            reason_code: "".into(),
1307                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1308                            bootstrap: false,
1309                        },
1310                    ],
1311                };
1312                for (index, id) in ["first", "second"].iter().enumerate() {
1313                    let values = Map::from_iter([
1314                        ("id".into(), Value::from(*id)),
1315                        ("project_id".into(), Value::from("p1")),
1316                    ]);
1317                    response.frames.push(Frame::Commit {
1318                        commit_seq: index as i64 + 1,
1319                        actor_id: "writer".into(),
1320                        created_at_ms: 1,
1321                        tables: vec!["tasks".into()],
1322                        changes: vec![ssp2::model::Change {
1323                            table_index: 0,
1324                            row_id: (*id).into(),
1325                            op: Op::Upsert,
1326                            row_version: Some(index as i64 + 1),
1327                            scopes: vec![("project_id".into(), "p1".into())],
1328                            row: Some(
1329                                encode_row_json(
1330                                    client.schema.table("tasks").unwrap(),
1331                                    id,
1332                                    &values,
1333                                    &client.encryption,
1334                                )
1335                                .unwrap(),
1336                            ),
1337                        }],
1338                    });
1339                }
1340                response.frames.push(Frame::SubEnd {
1341                    next_cursor: 2,
1342                    bootstrap_state: None,
1343                });
1344                let valid = response.clone();
1345                match fault {
1346                    "row" => {
1347                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1348                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1349                    },
1350                    "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
1351                    "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
1352                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1353                    "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
1354                    "missing-end" => {response.frames.truncate(3);},
1355                    _ => {}
1356                }
1357                if realtime {
1358                    client.on_realtime_binary(&mut transport, &encode_message(&response));
1359                } else {
1360                    assert_eq!(
1361                        matches!(
1362                            client.process_response(&mut transport, response, &meta),
1363                            SyncOutcome::Ok(_)
1364                        ),
1365                        fault == "none",
1366                        "{fault}"
1367                    );
1368                }
1369                assert!(
1370                    client.conn.is_autocommit(),
1371                    "no open transaction after {realtime}/{fault}"
1372                );
1373                // §1.4 rule 4: the deferred foreign key of the "commit" fault
1374                // fails the response's coalesced COMMIT, which rolls back both
1375                // frames; the other faults roll back only the failing frame.
1376                let count = match fault {
1377                    "none" | "cursor" => 2,
1378                    "commit" => 0,
1379                    _ => 1,
1380                };
1381                let expected_revision = count + u64::from(realtime && fault != "none");
1382                assert_eq!(
1383                    client.local_revision(),
1384                    expected_revision,
1385                    "{realtime}/{fault}"
1386                );
1387                let batches = client.drain_change_batches();
1388                assert_eq!(
1389                    batches.len(),
1390                    expected_revision as usize,
1391                    "{realtime}/{fault}"
1392                );
1393                let row_batches: Vec<_> = batches
1394                    .iter()
1395                    .filter(|batch| !batch.tables.is_empty())
1396                    .collect();
1397                assert_eq!(row_batches.len(), count as usize);
1398                assert_eq!(
1399                    row_batches
1400                        .iter()
1401                        .map(|b| b.revision.clone())
1402                        .collect::<Vec<_>>(),
1403                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1404                );
1405                for table in ["tasks", "_syncular_base_tasks"] {
1406                    assert_eq!(
1407                        client
1408                            .conn
1409                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1410                                .get::<_, u64>(0))
1411                            .unwrap(),
1412                        count,
1413                        "{realtime}/{fault}/{table}"
1414                    );
1415                }
1416                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1417                assert_eq!(
1418                    transport
1419                        .messages
1420                        .iter()
1421                        .filter(|m| m.contains("\"ack\""))
1422                        .count(),
1423                    usize::from(fault == "none")
1424                );
1425                if matches!(fault, "revision" | "commit" | "cursor") {
1426                    client
1427                        .conn
1428                        .execute_batch("DROP TRIGGER fail_frame")
1429                        .unwrap();
1430                }
1431                drop(client);
1432                let mut reopened = SyncClient::open_path(
1433                    "frame-test".into(),
1434                    &schema,
1435                    Default::default(),
1436                    path.to_str().unwrap(),
1437                )
1438                .unwrap();
1439                assert_eq!(reopened.local_revision(), expected_revision);
1440                assert_eq!(
1441                    reopened
1442                        .query("SELECT id FROM tasks ORDER BY id", &[])
1443                        .unwrap()
1444                        .len(),
1445                    count as usize
1446                );
1447                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1448                let (_, meta) = reopened.build_request(false).unwrap();
1449                assert!(matches!(
1450                    reopened.process_response(&mut transport, valid, &meta),
1451                    SyncOutcome::Ok(_)
1452                ));
1453                assert_eq!(
1454                    reopened
1455                        .query("SELECT id FROM tasks ORDER BY id", &[])
1456                        .unwrap()
1457                        .len(),
1458                    2
1459                );
1460                assert_eq!(reopened.subs[0].cursor, 2);
1461                drop(reopened);
1462                std::fs::remove_file(path).unwrap();
1463            }
1464        }
1465    }
1466
1467    #[test]
1468    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1469        for status in [SubStatus::Reset, SubStatus::Revoked] {
1470            for fault in ["subscription", "revision"] {
1471                let mut client = client();
1472                client
1473                    .subscribe(
1474                        "tasks".into(),
1475                        "tasks".into(),
1476                        vec![("project_id".into(), vec!["p1".into()])],
1477                        None,
1478                    )
1479                    .unwrap();
1480                client.subs[0].cursor = 41;
1481                client.subs[0].synced_once = true;
1482                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1483                client.persist_sub(&client.subs[0]).unwrap();
1484                client
1485                    .mutate(vec![Mutation::Upsert {
1486                        table: "tasks".into(),
1487                        values: Map::from_iter([
1488                            ("id".into(), Value::from("pending")),
1489                            ("project_id".into(), Value::from("p1")),
1490                        ]),
1491                        base_version: None,
1492                    }])
1493                    .unwrap();
1494                let ids = client.pending_commit_ids();
1495                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1496                let revision = client.local_revision();
1497                client.drain_change_batches();
1498                let (_, meta) = client.build_request(false).unwrap();
1499                client.conn.execute_batch(if fault == "subscription" {
1500                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1501                } else {
1502                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1503                }).unwrap();
1504                let mut transport = CountingRealtimeTransport::default();
1505                let mut report = SyncReport::default();
1506                // Reset changes window completeness and therefore needs a revision.
1507                if status == SubStatus::Reset {
1508                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1509                }
1510                let result = client.process_section(
1511                    &mut transport,
1512                    "tasks",
1513                    status,
1514                    "sync.scope_revoked",
1515                    vec![],
1516                    vec![],
1517                    Some((42, None)),
1518                    &meta,
1519                    &mut report,
1520                    &HashSet::new(),
1521                );
1522                assert!(result.is_err(), "{status:?}/{fault}");
1523                assert!(client.conn.is_autocommit());
1524                assert_eq!(client.subs[0].cursor, 41);
1525                assert_eq!(client.subs[0].state, SubState::Active);
1526                assert_eq!(client.pending_commit_ids(), ids);
1527                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1528                assert!(client.rejections.is_empty());
1529                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1530                assert_eq!(client.local_revision(), revision);
1531                assert!(client.drain_change_batches().is_empty());
1532                assert!(report.revoked.is_empty() && report.resets.is_empty());
1533            }
1534        }
1535    }
1536
1537    #[test]
1538    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1539        let mut client = client();
1540        client
1541            .subscribe(
1542                "tasks".into(),
1543                "tasks".into(),
1544                vec![("project_id".into(), vec!["p1".into()])],
1545                None,
1546            )
1547            .unwrap();
1548        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1549        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1550            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1551        let row = |id: &str| ssp2::segment::SegmentRow {
1552            server_version: 1,
1553            values: vec![
1554                Some(ColumnValue::String(id.into())),
1555                Some(ColumnValue::String("p1".into())),
1556            ],
1557        };
1558        let segment = RowsSegment {
1559            table: "tasks".into(),
1560            schema_version: 1,
1561            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1562            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1563        };
1564        assert!(client.apply_segment(0, &segment, true).is_err());
1565        assert!(client.conn.is_autocommit());
1566        assert_eq!(
1567            client
1568                .query("SELECT id FROM tasks ORDER BY id", &[])
1569                .unwrap(),
1570            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1571        );
1572        assert_eq!(client.local_revision(), 1);
1573        assert_eq!(client.drain_change_batches().len(), 1);
1574        assert_eq!(client.subs[0].cursor, -1);
1575        client
1576            .conn
1577            .execute_batch("DROP TRIGGER fail_block")
1578            .unwrap();
1579        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1580        assert_eq!(client.local_revision(), 3);
1581        assert_eq!(client.drain_change_batches().len(), 2);
1582    }
1583
1584    /// Serves one response and records the segment batch it causes. A single
1585    /// `download_segment` call means the core fell back to per-descriptor
1586    /// fetches.
1587    struct SegmentBatchTransport {
1588        response: Message,
1589        bodies: BTreeMap<String, Vec<u8>>,
1590        batches: Vec<Vec<String>>,
1591        single_fetches: usize,
1592    }
1593
1594    impl Transport for SegmentBatchTransport {
1595        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1596            Ok(encode_message(&self.response))
1597        }
1598
1599        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1600            self.sync(request)
1601        }
1602
1603        fn download_segment(
1604            &mut self,
1605            _request: &SegmentRequest,
1606            _on_progress: &mut dyn FnMut(u64),
1607        ) -> Result<Vec<u8>, TransportError> {
1608            self.single_fetches += 1;
1609            Err(TransportError::new(
1610                "sync.transport_failed",
1611                "per-descriptor fetch",
1612            ))
1613        }
1614
1615        fn download_segments(
1616            &mut self,
1617            fetches: &[crate::SegmentFetch],
1618            on_progress: &mut (dyn FnMut(usize, u64) + Send),
1619        ) -> Vec<Result<Vec<u8>, TransportError>> {
1620            self.batches.push(
1621                fetches
1622                    .iter()
1623                    .map(|fetch| match fetch {
1624                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1625                        crate::SegmentFetch::Url(url) => url.clone(),
1626                    })
1627                    .collect(),
1628            );
1629            fetches
1630                .iter()
1631                .enumerate()
1632                .map(|(index, fetch)| {
1633                    let crate::SegmentFetch::Direct(request) = fetch else {
1634                        panic!("the descriptors are direct-endpoint requests")
1635                    };
1636                    let bytes = self.bodies[&request.segment_id].clone();
1637                    on_progress(index, bytes.len() as u64);
1638                    Ok(bytes)
1639                })
1640                .collect()
1641        }
1642
1643        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1644            Ok(())
1645        }
1646
1647        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1648            Ok(())
1649        }
1650
1651        fn realtime_close(&mut self) -> Result<(), TransportError> {
1652            Ok(())
1653        }
1654    }
1655
1656    #[test]
1657    fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1658        let mut client = client();
1659        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1660        client
1661            .subscribe(
1662                "tasks".into(),
1663                "tasks".into(),
1664                vec![("project_id".into(), vec!["p1".into()])],
1665                None,
1666            )
1667            .unwrap();
1668        client.subs[0].cursor = 0;
1669        client.subs[0].synced_once = true;
1670        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1671        client.persist_sub(&client.subs[0]).unwrap();
1672        client.drain_change_batches();
1673        let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1674
1675        // Four pages, one row each. Applying a page under another page's
1676        // descriptor fails §5.1 content-address verification, so a green round
1677        // proves the results kept the wire order.
1678        let pages = ["one", "two", "three", "four"].map(|id| {
1679            let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1680                table: "tasks".into(),
1681                schema_version: 1,
1682                columns: columns.clone(),
1683                blocks: vec![vec![ssp2::segment::SegmentRow {
1684                    server_version: 1,
1685                    values: vec![
1686                        Some(ColumnValue::String(id.into())),
1687                        Some(ColumnValue::String("p1".into())),
1688                    ],
1689                }]],
1690            });
1691            (
1692                format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1693                bytes,
1694            )
1695        });
1696
1697        let mut frames = vec![
1698            Frame::RespHeader {
1699                required_schema_version: None,
1700                latest_schema_version: None,
1701                log_epoch: Some("epoch-1".into()),
1702                reset_required: Some(false),
1703            },
1704            Frame::SubStart {
1705                id: "tasks".into(),
1706                status: SubStatus::Active,
1707                reason_code: String::new(),
1708                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1709                bootstrap: false,
1710            },
1711        ];
1712        for (segment_id, bytes) in &pages {
1713            frames.push(Frame::SegmentRef {
1714                segment_id: segment_id.clone(),
1715                media_type: MediaType::Rows,
1716                table: "tasks".into(),
1717                byte_length: bytes.len() as i64,
1718                row_count: 1,
1719                as_of_commit_seq: 1,
1720                scope_digest: "digest".into(),
1721                row_cursor: None,
1722                next_row_cursor: None,
1723                url: None,
1724                url_expires_at_ms: None,
1725            });
1726        }
1727        frames.push(Frame::SubEnd {
1728            next_cursor: 1,
1729            bootstrap_state: None,
1730        });
1731        let mut transport = SegmentBatchTransport {
1732            response: Message {
1733                wire_version: WIRE_VERSION,
1734                msg_kind: MsgKind::Response,
1735                frames,
1736            },
1737            bodies: pages
1738                .iter()
1739                .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1740                .collect(),
1741            batches: Vec::new(),
1742            single_fetches: 0,
1743        };
1744
1745        let outcome = client.sync(&mut transport);
1746        assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1747        assert_eq!(
1748            transport.batches,
1749            vec![pages
1750                .iter()
1751                .map(|(segment_id, _)| segment_id.clone())
1752                .collect::<Vec<_>>()],
1753            "one batch per response, in wire order"
1754        );
1755        assert_eq!(
1756            transport.single_fetches, 0,
1757            "the batch seam replaces the per-descriptor calls"
1758        );
1759        assert_eq!(
1760            client
1761                .query("SELECT id FROM tasks ORDER BY id", &[])
1762                .unwrap(),
1763            ["four", "one", "three", "two"]
1764                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1765                .to_vec()
1766        );
1767        assert_eq!(client.subs[0].cursor, 1);
1768    }
1769
1770    #[test]
1771    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1772        assert_eq!(
1773            client().diagnostics_storage().blob_cache_bytes_approx,
1774            Some(0)
1775        );
1776        let client = SyncClient::new(
1777            "diagnostic-storage".to_owned(),
1778            &json!({
1779                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1780                    "columns": [{"name": "id", "type": "string", "nullable": false},
1781                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1782            }),
1783            ClientLimits {
1784                blob_cache_max_bytes: Some(5),
1785                ..Default::default()
1786            },
1787        )
1788        .unwrap();
1789        let empty = client.diagnostics_storage();
1790        assert_eq!(empty.status, "healthy");
1791        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1792        assert_eq!(empty.retained_outcome_entries, Some(0));
1793        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1794        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1795        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1796            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1797                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1798            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1799                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1800        let written = client.diagnostics_storage();
1801        assert_eq!(written.status, "pressure");
1802        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1803        assert_eq!(written.retained_outcome_entries, Some(2));
1804        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1805        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1806        assert_eq!(
1807            written.pressure_reason_code.as_deref(),
1808            Some("client.blob_cache_over_limit")
1809        );
1810        client
1811            .conn
1812            .execute_batch(
1813                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1814            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1815            )
1816            .unwrap();
1817        let staged = client.diagnostics_storage();
1818        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1819        assert_eq!(staged.retained_outcome_entries, Some(0));
1820        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1821        assert_eq!(staged.status, "healthy");
1822        client.conn.execute_batch("ROLLBACK").unwrap();
1823        assert_eq!(client.diagnostics_storage(), written);
1824        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1825        let unreadable = client.diagnostics_storage();
1826        assert_eq!(unreadable.status, "unreadable");
1827        assert!(unreadable.database_bytes_approx.is_none());
1828        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1829        assert!(unreadable.retained_outcome_entries.is_none());
1830        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1831        assert!(unreadable.blob_cache_bytes_approx.is_none());
1832        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1833        let restored = client.diagnostics_storage();
1834        assert_eq!(restored.status, written.status);
1835        assert_eq!(
1836            restored.retained_outcome_bytes_approx,
1837            written.retained_outcome_bytes_approx
1838        );
1839        client.conn.set_prepared_statement_cache_capacity(0);
1840        assert_eq!(client.diagnostics_storage(), restored);
1841    }
1842
1843    /// Answers every round with one server `ERROR` frame (§1.6).
1844    struct ServerErrorTransport {
1845        code: &'static str,
1846        retryable: bool,
1847    }
1848
1849    impl Transport for ServerErrorTransport {
1850        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1851            Ok(encode_message(&Message {
1852                wire_version: WIRE_VERSION,
1853                msg_kind: MsgKind::Response,
1854                frames: vec![
1855                    Frame::RespHeader {
1856                        required_schema_version: None,
1857                        latest_schema_version: None,
1858                        log_epoch: Some("epoch-1".to_owned()),
1859                        reset_required: Some(false),
1860                    },
1861                    Frame::Error {
1862                        code: self.code.to_owned(),
1863                        message: "server error".to_owned(),
1864                        category: "internal".to_owned(),
1865                        retryable: self.retryable,
1866                        recommended_action: "retryLater".to_owned(),
1867                        details: None,
1868                    },
1869                ],
1870            }))
1871        }
1872
1873        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1874            self.sync(request)
1875        }
1876
1877        fn download_segment(
1878            &mut self,
1879            _request: &SegmentRequest,
1880            _on_progress: &mut dyn FnMut(u64),
1881        ) -> Result<Vec<u8>, TransportError> {
1882            Err(TransportError::new("sync.transport_failed", "offline"))
1883        }
1884
1885        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1886            Ok(())
1887        }
1888
1889        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1890            Ok(())
1891        }
1892
1893        fn realtime_close(&mut self) -> Result<(), TransportError> {
1894            Ok(())
1895        }
1896    }
1897
1898    #[test]
1899    fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1900        let mut client = client();
1901        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1902        let mut transport = ServerErrorTransport {
1903            code: "sync.schema_not_ready",
1904            retryable: true,
1905        };
1906        client.connect_realtime(&mut transport).expect("connect");
1907        assert!(matches!(
1908            client.sync(&mut transport),
1909            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1910        ));
1911        assert_eq!(
1912            client.realtime_state(),
1913            RealtimeState::Connected,
1914            "the socket delivered the answer, so it is not lost"
1915        );
1916        assert!(
1917            matches!(
1918                client.drain_sync_intents().as_slice(),
1919                [SyncIntent::Background { delay_ms: 250 }]
1920            ),
1921            "the retry deadline is a sync round and no reconnect satisfies it"
1922        );
1923        assert_eq!(
1924            client.progress().snapshot().unwrap().retry_delay_ms,
1925            Some(250)
1926        );
1927    }
1928
1929    #[test]
1930    fn retryable_server_error_schedules_a_background_retry() {
1931        let mut client = client();
1932        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1933        let mut transport = ServerErrorTransport {
1934            code: "sync.internal_error",
1935            retryable: true,
1936        };
1937        assert!(matches!(
1938            client.sync(&mut transport),
1939            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1940        ));
1941        assert!(matches!(
1942            client.drain_sync_intents().as_slice(),
1943            [SyncIntent::Background { delay_ms: 250 }]
1944        ));
1945        assert_eq!(
1946            client.progress().snapshot().unwrap().retry_delay_ms,
1947            Some(250)
1948        );
1949        let mut transport = ServerErrorTransport {
1950            code: "sync.invalid_request",
1951            retryable: false,
1952        };
1953        assert!(matches!(
1954            client.sync(&mut transport),
1955            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1956        ));
1957        assert!(client.drain_sync_intents().is_empty());
1958        let progress = client.progress().snapshot().unwrap();
1959        assert_eq!(progress.state, ProgressState::Failed);
1960        assert_eq!(progress.retry_delay_ms, None);
1961    }
1962
1963    #[test]
1964    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1965        // §7.7: the exhausted outcome is a partial success, not a failure,
1966        // and serializes with an explicit flag.
1967        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1968        assert_eq!(exhausted["ok"], json!(true));
1969        assert_eq!(exhausted["budgetExhausted"], json!(true));
1970        assert!(exhausted["report"].is_object());
1971
1972        let mut client = client();
1973        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1974        // Zero explicit budget is a caller error, not an exhaustion.
1975        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1976        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1977        assert!(
1978            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1979            "{zero:?}"
1980        );
1981        // A real server failure stays a failure across the budget loop.
1982        let mut erroring = ServerErrorTransport {
1983            code: "sync.internal_error",
1984            retryable: false,
1985        };
1986        let failed = client.sync_until_idle(&mut erroring, Some(3));
1987        assert!(
1988            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1989            "{failed:?}"
1990        );
1991    }
1992
1993    /// A transport that fails every round with one `causeKind`.
1994    struct CauseTransport(&'static str);
1995
1996    impl Transport for CauseTransport {
1997        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1998            let mut error = TransportError::new("transport.failed", "no route");
1999            error.details = Some(serde_json::json!({ "causeKind": self.0 }));
2000            Err(error)
2001        }
2002
2003        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2004            self.sync(request)
2005        }
2006
2007        fn download_segment(
2008            &mut self,
2009            _request: &SegmentRequest,
2010            _on_progress: &mut dyn FnMut(u64),
2011        ) -> Result<Vec<u8>, TransportError> {
2012            Err(TransportError::new("sync.transport_failed", "offline"))
2013        }
2014
2015        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2016            Ok(())
2017        }
2018
2019        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2020            Ok(())
2021        }
2022
2023        fn realtime_close(&mut self) -> Result<(), TransportError> {
2024            Ok(())
2025        }
2026    }
2027
2028    #[test]
2029    fn an_unreachable_transport_schedules_no_retry() {
2030        // §1.8: `connect` (native) and `network` (browser fetch) describe a
2031        // refused or unreachable endpoint; neither earns another backoff.
2032        for cause in ["connect", "network"] {
2033            let mut client = client();
2034            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2035            let mut transport = CauseTransport(cause);
2036            assert!(matches!(
2037                client.sync(&mut transport),
2038                SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2039            ));
2040            assert!(
2041                client.drain_sync_intents().is_empty(),
2042                "{cause} scheduled a retry"
2043            );
2044        }
2045
2046        // Every other retryable transport cause keeps the background retry.
2047        let mut client = client();
2048        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2049        let mut transport = CauseTransport("timeout");
2050        assert!(matches!(
2051            client.sync(&mut transport),
2052            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2053        ));
2054        assert!(matches!(
2055            client.drain_sync_intents().as_slice(),
2056            [SyncIntent::Background { delay_ms: 250 }]
2057        ));
2058    }
2059
2060    #[test]
2061    fn sync_until_idle_reports_its_total_deadline() {
2062        assert_eq!(SyncClient::SYNC_IDLE_TIMEOUT_MS, 60_000);
2063        let mut client = client();
2064        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2065        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2066
2067        // A zero deadline is a caller error, like a zero round budget.
2068        let invalid = client.sync_until_idle_with_deadline(&mut transport, None, Some(0));
2069        assert!(
2070            matches!(invalid, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
2071            "{invalid:?}"
2072        );
2073
2074        // The expired deadline stops before the first round and reports how
2075        // many rounds ran.
2076        let now = client.clock_now_ms();
2077        let outcome = client.sync_until_idle_bounded(&mut transport, None, now, Some(now));
2078        let SyncOutcome::Failed {
2079            error_code,
2080            details,
2081            recommended_action,
2082            ..
2083        } = outcome
2084        else {
2085            panic!("expected the idle deadline, got {outcome:?}");
2086        };
2087        assert_eq!(error_code, "sync.idle_timeout");
2088        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2089        let details = details.expect("deadline details");
2090        assert_eq!(details["timeoutMs"], json!(0));
2091        assert_eq!(details["rounds"], json!(0));
2092    }
2093
2094    /// Returns one pre-built response per round so a test can pin a successful
2095    /// work-bearing round followed by a failure or a schema floor.
2096    struct ScriptedSyncTransport {
2097        responses: std::collections::VecDeque<Message>,
2098        calls: usize,
2099    }
2100
2101    impl Transport for ScriptedSyncTransport {
2102        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2103            self.calls += 1;
2104            let response = self.responses.pop_front().expect("scripted response");
2105            Ok(encode_message(&response))
2106        }
2107
2108        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2109            self.sync(request)
2110        }
2111
2112        fn download_segment(
2113            &mut self,
2114            _request: &SegmentRequest,
2115            _on_progress: &mut dyn FnMut(u64),
2116        ) -> Result<Vec<u8>, TransportError> {
2117            Err(TransportError::new("sync.transport_failed", "offline"))
2118        }
2119
2120        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2121            Ok(())
2122        }
2123
2124        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2125            Ok(())
2126        }
2127
2128        fn realtime_close(&mut self) -> Result<(), TransportError> {
2129            Ok(())
2130        }
2131    }
2132
2133    #[test]
2134    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2135        let path = std::env::temp_dir().join(format!(
2136            "syncular-budget-script-{}.sqlite",
2137            uuid::Uuid::new_v4()
2138        ));
2139        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2140            {"name":"id","type":"string","nullable":false},
2141            {"name":"project_id","type":"string","nullable":false}],
2142            "scopes":[{"pattern":"project:{project_id}"}]}]});
2143        let mut client = SyncClient::open_path(
2144            "budget-script".into(),
2145            &schema,
2146            ClientLimits::default(),
2147            path.to_str().unwrap(),
2148        )
2149        .expect("open client");
2150        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2151        client
2152            .subscribe(
2153                "tasks".into(),
2154                "tasks".into(),
2155                vec![("project_id".into(), vec!["p1".into()])],
2156                None,
2157            )
2158            .unwrap();
2159        client.subs[0].cursor = 0;
2160        client.subs[0].synced_once = true;
2161        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2162        client.persist_sub(&client.subs[0]).unwrap();
2163        client.drain_change_batches();
2164
2165        let row = encode_row_json(
2166            client.schema.table("tasks").unwrap(),
2167            "kept",
2168            &Map::from_iter([
2169                ("id".into(), json!("kept")),
2170                ("project_id".into(), json!("p1")),
2171            ]),
2172            &client.encryption,
2173        )
2174        .unwrap();
2175        let success = Message {
2176            wire_version: WIRE_VERSION,
2177            msg_kind: MsgKind::Response,
2178            frames: vec![
2179                Frame::RespHeader {
2180                    required_schema_version: None,
2181                    latest_schema_version: None,
2182                    log_epoch: Some("epoch-1".into()),
2183                    reset_required: Some(false),
2184                },
2185                Frame::SubStart {
2186                    id: "tasks".into(),
2187                    status: SubStatus::Active,
2188                    reason_code: String::new(),
2189                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2190                    bootstrap: false,
2191                },
2192                Frame::Commit {
2193                    commit_seq: 1,
2194                    created_at_ms: 1,
2195                    actor_id: "writer".into(),
2196                    tables: vec!["tasks".into()],
2197                    changes: vec![ssp2::model::Change {
2198                        table_index: 0,
2199                        row_id: "kept".into(),
2200                        op: Op::Upsert,
2201                        row_version: Some(1),
2202                        scopes: vec![("project_id".into(), "p1".into())],
2203                        row: Some(row),
2204                    }],
2205                },
2206                Frame::SubEnd {
2207                    next_cursor: 1,
2208                    bootstrap_state: None,
2209                },
2210            ],
2211        };
2212        let failure = Message {
2213            wire_version: WIRE_VERSION,
2214            msg_kind: MsgKind::Response,
2215            frames: vec![
2216                Frame::RespHeader {
2217                    required_schema_version: None,
2218                    latest_schema_version: None,
2219                    log_epoch: Some("epoch-1".into()),
2220                    reset_required: Some(false),
2221                },
2222                Frame::Error {
2223                    code: "sync.internal_error".into(),
2224                    message: "second round failed".into(),
2225                    category: "internal".into(),
2226                    retryable: false,
2227                    recommended_action: "retryLater".into(),
2228                    details: None,
2229                },
2230            ],
2231        };
2232        let mut transport = ScriptedSyncTransport {
2233            responses: std::collections::VecDeque::from([success, failure]),
2234            calls: 0,
2235        };
2236        let outcome = client.sync_until_idle(&mut transport, Some(2));
2237        let SyncOutcome::Failed {
2238            error_code,
2239            recommended_action,
2240            ..
2241        } = outcome
2242        else {
2243            panic!("expected the in-band failure");
2244        };
2245        assert_eq!(error_code, "sync.internal_error");
2246        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2247        assert_eq!(
2248            transport.calls, 2,
2249            "one successful round then the failing round"
2250        );
2251        assert_eq!(
2252            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2253            json!("kept"),
2254            "the earlier round's durable work survives the later failure"
2255        );
2256        drop(client);
2257        let _ = std::fs::remove_file(&path);
2258    }
2259
2260    #[test]
2261    fn sync_until_idle_stops_on_a_schema_floor() {
2262        let mut client = client();
2263        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2264        let floor = Message {
2265            wire_version: WIRE_VERSION,
2266            msg_kind: MsgKind::Response,
2267            frames: vec![Frame::RespHeader {
2268                required_schema_version: Some(2),
2269                latest_schema_version: Some(2),
2270                log_epoch: Some("epoch-1".into()),
2271                reset_required: Some(false),
2272            }],
2273        };
2274        let mut transport = ScriptedSyncTransport {
2275            responses: std::collections::VecDeque::from([floor.clone(), floor]),
2276            calls: 0,
2277        };
2278        let outcome = client.sync_until_idle(&mut transport, Some(3));
2279        match outcome {
2280            SyncOutcome::Ok(report) => assert_eq!(
2281                report
2282                    .schema_floor
2283                    .as_ref()
2284                    .and_then(|floor| floor.required_schema_version),
2285                Some(2),
2286                "the stop retains the latest schema floor"
2287            ),
2288            other => panic!("expected a schema-floor stop, got {other:?}"),
2289        }
2290        assert_eq!(
2291            transport.calls, 1,
2292            "a schema floor stops the budget loop after one round"
2293        );
2294    }
2295
2296    /// Records the decoded request of every round and answers from a script of
2297    /// round results, so a test can pin what the following request carries.
2298    struct FailureScriptTransport {
2299        rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2300        requests: Vec<Message>,
2301    }
2302
2303    impl Transport for FailureScriptTransport {
2304        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2305            self.requests
2306                .push(decode_message(request).expect("the client encodes a legal request"));
2307            self.rounds.pop_front().expect("scripted round")
2308        }
2309
2310        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2311            self.sync(request)
2312        }
2313
2314        fn download_segment(
2315            &mut self,
2316            _request: &SegmentRequest,
2317            _on_progress: &mut dyn FnMut(u64),
2318        ) -> Result<Vec<u8>, TransportError> {
2319            Err(TransportError::new("transport.timeout", "segment stalled"))
2320        }
2321
2322        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2323            Ok(())
2324        }
2325
2326        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2327            Ok(())
2328        }
2329
2330        fn realtime_close(&mut self) -> Result<(), TransportError> {
2331            Ok(())
2332        }
2333    }
2334
2335    /// A complete response: RESP_HEADER for the client's epoch plus `frames`.
2336    fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2337        let mut all = vec![Frame::RespHeader {
2338            required_schema_version: None,
2339            latest_schema_version: None,
2340            log_epoch: Some("epoch-1".into()),
2341            reset_required: Some(false),
2342        }];
2343        all.extend(frames);
2344        encode_message(&Message {
2345            wire_version: WIRE_VERSION,
2346            msg_kind: MsgKind::Response,
2347            frames: all,
2348        })
2349    }
2350
2351    fn carried_round_failures(request: &Message) -> Vec<String> {
2352        request
2353            .frames
2354            .iter()
2355            .filter_map(|frame| match frame {
2356                Frame::RoundFailure { code } => Some(code.clone()),
2357                _ => None,
2358            })
2359            .collect()
2360    }
2361
2362    fn failure_transport(
2363        rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2364    ) -> FailureScriptTransport {
2365        FailureScriptTransport {
2366            rounds: rounds.into_iter().collect(),
2367            requests: Vec::new(),
2368        }
2369    }
2370
2371    #[test]
2372    fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2373        let mut client = client();
2374        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2375        let mut transport = failure_transport([
2376            Err(TransportError::new("transport.timeout", "round timed out")),
2377            Ok(completed_response(Vec::new())),
2378            Ok(completed_response(Vec::new())),
2379        ]);
2380        for _ in 0..3 {
2381            client.sync(&mut transport);
2382        }
2383        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2384        assert_eq!(
2385            carried_round_failures(&transport.requests[1]),
2386            vec!["transport.timeout"]
2387        );
2388        assert!(
2389            matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2390                && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2391            "the report sits immediately after REQ_HEADER"
2392        );
2393        assert!(
2394            carried_round_failures(&transport.requests[2]).is_empty(),
2395            "a complete response ends the report"
2396        );
2397    }
2398
2399    #[test]
2400    fn a_newer_round_failure_replaces_the_kept_code() {
2401        let mut client = client();
2402        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2403        let mut transport = failure_transport([
2404            Err(TransportError::new("transport.timeout", "round timed out")),
2405            Err(TransportError::new("transport.failed", "socket closed")),
2406            Ok(completed_response(Vec::new())),
2407            Ok(completed_response(Vec::new())),
2408        ]);
2409        for _ in 0..4 {
2410            client.sync(&mut transport);
2411        }
2412        assert_eq!(
2413            carried_round_failures(&transport.requests[1]),
2414            vec!["transport.timeout"]
2415        );
2416        assert_eq!(
2417            carried_round_failures(&transport.requests[2]),
2418            vec!["transport.failed"]
2419        );
2420        assert!(carried_round_failures(&transport.requests[3]).is_empty());
2421    }
2422
2423    #[test]
2424    fn an_in_band_error_completes_the_round_and_is_never_reported() {
2425        let mut client = client();
2426        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2427        let mut transport = failure_transport([
2428            Err(TransportError::new("transport.timeout", "round timed out")),
2429            Ok(completed_response(vec![Frame::Error {
2430                code: "sync.internal_error".into(),
2431                message: "server error".into(),
2432                category: "internal".into(),
2433                retryable: false,
2434                recommended_action: "retryLater".into(),
2435                details: None,
2436            }])),
2437            Ok(completed_response(Vec::new())),
2438        ]);
2439        client.sync(&mut transport);
2440        assert!(matches!(
2441            client.sync(&mut transport),
2442            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2443        ));
2444        client.sync(&mut transport);
2445        assert_eq!(
2446            carried_round_failures(&transport.requests[1]),
2447            vec!["transport.timeout"]
2448        );
2449        assert!(
2450            carried_round_failures(&transport.requests[2]).is_empty(),
2451            "the server already recorded an in-band ERROR"
2452        );
2453    }
2454
2455    #[test]
2456    fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2457        let mut client = client();
2458        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2459        client
2460            .subscribe(
2461                "tasks".into(),
2462                "tasks".into(),
2463                vec![("project_id".into(), vec!["p1".into()])],
2464                None,
2465            )
2466            .unwrap();
2467        client.subs[0].cursor = 0;
2468        client.subs[0].synced_once = true;
2469        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2470        client.persist_sub(&client.subs[0]).unwrap();
2471        client.drain_change_batches();
2472        client.drain_sync_intents();
2473        let mut transport = failure_transport([
2474            Ok(completed_response(vec![
2475                Frame::SubStart {
2476                    id: "tasks".into(),
2477                    status: SubStatus::Active,
2478                    reason_code: String::new(),
2479                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2480                    bootstrap: false,
2481                },
2482                Frame::SegmentRef {
2483                    segment_id: "sha256:stalled".into(),
2484                    media_type: MediaType::Rows,
2485                    table: "tasks".into(),
2486                    byte_length: 1,
2487                    row_count: 1,
2488                    as_of_commit_seq: 1,
2489                    scope_digest: "digest".into(),
2490                    row_cursor: None,
2491                    next_row_cursor: None,
2492                    url: None,
2493                    url_expires_at_ms: None,
2494                },
2495                Frame::SubEnd {
2496                    next_cursor: 1,
2497                    bootstrap_state: None,
2498                },
2499            ])),
2500            Ok(completed_response(Vec::new())),
2501            Ok(completed_response(Vec::new())),
2502        ]);
2503
2504        assert!(matches!(
2505            client.sync(&mut transport),
2506            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2507        ));
2508        assert!(
2509            matches!(
2510                client.drain_sync_intents().as_slice(),
2511                [SyncIntent::Background { delay_ms: 250 }]
2512            ),
2513            "a silent transfer asks the host for a Background retry"
2514        );
2515        client.sync(&mut transport);
2516        client.sync(&mut transport);
2517        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2518        assert_eq!(
2519            carried_round_failures(&transport.requests[1]),
2520            vec!["transport.timeout"],
2521            "the round's own response was complete, so the fetch failure rides the next request"
2522        );
2523        assert!(
2524            carried_round_failures(&transport.requests[2]).is_empty(),
2525            "a complete response ends the report"
2526        );
2527    }
2528
2529    #[test]
2530    fn a_code_the_frame_grammar_rejects_is_not_attached() {
2531        let mut client = client();
2532        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2533        let mut transport = failure_transport([
2534            Err(TransportError::new("offline", "no dotted code")),
2535            Ok(completed_response(Vec::new())),
2536        ]);
2537        client.sync(&mut transport);
2538        client.sync(&mut transport);
2539        assert!(carried_round_failures(&transport.requests[1]).is_empty());
2540    }
2541
2542    #[test]
2543    fn sync_report_merge_schema_floor_is_latest_state() {
2544        let mut aggregate = SyncReport {
2545            schema_floor: Some(SchemaFloor {
2546                required_schema_version: Some(2),
2547                latest_schema_version: Some(2),
2548            }),
2549            ..SyncReport::default()
2550        };
2551        aggregate.merge(&SyncReport::default());
2552        assert!(
2553            aggregate.schema_floor.is_none(),
2554            "a later round without a floor clears it"
2555        );
2556    }
2557
2558    #[test]
2559    fn push_capacity_limits_are_hard_and_keep_intent() {
2560        // The configured operation cap is hard for the first commit.
2561        let mut c = client();
2562        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2563        c.limits.max_push_operations_per_request = Some(1);
2564        let id = c
2565            .mutate(vec![
2566                Mutation::Upsert {
2567                    table: "tasks".into(),
2568                    values: Map::from_iter([
2569                        ("id".into(), json!("a")),
2570                        ("project_id".into(), json!("p1")),
2571                    ]),
2572                    base_version: None,
2573                },
2574                Mutation::Upsert {
2575                    table: "tasks".into(),
2576                    values: Map::from_iter([
2577                        ("id".into(), json!("b")),
2578                        ("project_id".into(), json!("p1")),
2579                    ]),
2580                    base_version: None,
2581                },
2582            ])
2583            .unwrap();
2584        let error = c.build_request(false).unwrap_err();
2585        let SyncOutcome::Failed {
2586            error_code,
2587            details,
2588            ..
2589        } = *error
2590        else {
2591            panic!("expected a capacity failure");
2592        };
2593        assert_eq!(error_code, "client.push_request_too_large");
2594        let details = details.expect("capacity details");
2595        assert_eq!(details["kind"], json!("operations"));
2596        assert_eq!(details["limit"], json!(1));
2597        assert_eq!(details["size"], json!(2));
2598        assert_eq!(details["clientCommitId"], json!(id));
2599        assert_eq!(c.pending_commit_ids(), vec![id]);
2600
2601        // The commit-count cap defers whole later commits.
2602        let mut c = client();
2603        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2604        c.limits.max_push_commits_per_request = Some(1);
2605        let first = c
2606            .mutate(vec![Mutation::Upsert {
2607                table: "tasks".into(),
2608                values: Map::from_iter([
2609                    ("id".into(), json!("one")),
2610                    ("project_id".into(), json!("p1")),
2611                ]),
2612                base_version: None,
2613            }])
2614            .unwrap();
2615        let second = c
2616            .mutate(vec![Mutation::Upsert {
2617                table: "tasks".into(),
2618                values: Map::from_iter([
2619                    ("id".into(), json!("two")),
2620                    ("project_id".into(), json!("p1")),
2621                ]),
2622                base_version: None,
2623            }])
2624            .unwrap();
2625        let (_, meta) = c.build_request(false).unwrap();
2626        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2627        assert_eq!(meta.deferred_commits, 1);
2628        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2629    }
2630
2631    #[test]
2632    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2633        // Two small commits: a cap just below the full request defers the
2634        // last commit and sends the fitting prefix (header + first commit).
2635        let mut c = client();
2636        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2637        let first = c
2638            .mutate(vec![Mutation::Upsert {
2639                table: "tasks".into(),
2640                values: Map::from_iter([
2641                    ("id".into(), json!("one")),
2642                    ("project_id".into(), json!("p1")),
2643                ]),
2644                base_version: None,
2645            }])
2646            .unwrap();
2647        let second = c
2648            .mutate(vec![Mutation::Upsert {
2649                table: "tasks".into(),
2650                values: Map::from_iter([
2651                    ("id".into(), json!("two")),
2652                    ("project_id".into(), json!("p1")),
2653                ]),
2654                base_version: None,
2655            }])
2656            .unwrap();
2657        let (message, meta) = c.build_request(false).unwrap();
2658        assert_eq!(meta.pushed_ids.len(), 2);
2659        let full = encode_message(&message).len();
2660        c.limits.max_push_request_bytes = Some(full - 1);
2661        let (_, meta) = c.build_request(false).unwrap();
2662        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2663        assert_eq!(meta.deferred_commits, 1);
2664        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2665
2666        // A single commit larger than the budget is a typed capacity error and
2667        // stays queued, reporting the full projected request size.
2668        let mut c = client();
2669        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2670        let huge = c
2671            .mutate(vec![Mutation::Upsert {
2672                table: "tasks".into(),
2673                values: Map::from_iter([
2674                    ("id".into(), json!("x".repeat(20_000))),
2675                    ("project_id".into(), json!("p1")),
2676                ]),
2677                base_version: None,
2678            }])
2679            .unwrap();
2680        let (message, meta) = c.build_request(false).unwrap();
2681        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2682        let with_huge = encode_message(&message).len();
2683        c.limits.max_push_request_bytes = Some(with_huge - 1);
2684        let error = c.build_request(false).unwrap_err();
2685        let SyncOutcome::Failed {
2686            error_code,
2687            details,
2688            ..
2689        } = *error
2690        else {
2691            panic!("expected a capacity failure");
2692        };
2693        assert_eq!(error_code, "client.push_request_too_large");
2694        let details = details.expect("capacity details");
2695        assert_eq!(details["kind"], json!("bytes"));
2696        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2697        assert_eq!(details["size"], json!(with_huge as u64));
2698        assert_eq!(details["clientCommitId"], json!(huge));
2699        assert_eq!(c.pending_commit_ids(), vec![huge]);
2700
2701        // A cap smaller than the fixed frames reports no blocked commit.
2702        let mut c = client();
2703        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2704        c.limits.max_push_request_bytes = Some(1);
2705        let error = c.build_request(false).unwrap_err();
2706        let SyncOutcome::Failed { details, .. } = *error else {
2707            panic!("expected a capacity failure");
2708        };
2709        let details = details.expect("capacity details");
2710        assert_eq!(details["kind"], json!("bytes"));
2711        assert!(details.get("clientCommitId").is_none(), "{details}");
2712    }
2713
2714    #[test]
2715    fn push_limits_validate_at_construction() {
2716        for limits in [
2717            ClientLimits {
2718                max_push_commits_per_request: Some(0),
2719                ..Default::default()
2720            },
2721            ClientLimits {
2722                max_push_operations_per_request: Some(0),
2723                ..Default::default()
2724            },
2725            ClientLimits {
2726                max_push_request_bytes: Some(0),
2727                ..Default::default()
2728            },
2729            ClientLimits {
2730                max_push_request_bytes: Some(u32::MAX as usize + 1),
2731                ..Default::default()
2732            },
2733        ] {
2734            let schema = json!({"version":1,"tables":[]});
2735            let error = SyncClient::with_connection(
2736                "bad-limits".into(),
2737                &schema,
2738                limits,
2739                Connection::open_in_memory().unwrap(),
2740            )
2741            .err();
2742            assert_eq!(
2743                error.as_deref(),
2744                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2745            );
2746        }
2747    }
2748
2749    #[test]
2750    fn open_path_rejects_invalid_push_limits_before_storage() {
2751        for identity in [false, true] {
2752            let path = std::env::temp_dir().join(format!(
2753                "syncular-bad-limits-{}.sqlite",
2754                uuid::Uuid::new_v4()
2755            ));
2756            let schema = json!({"version":1,"tables":[]});
2757            let limits = ClientLimits {
2758                max_push_request_bytes: Some(0),
2759                ..Default::default()
2760            };
2761            let error = if identity {
2762                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2763            } else {
2764                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2765            }
2766            .err();
2767            assert_eq!(
2768                error.as_deref(),
2769                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2770            );
2771            assert!(
2772                !path.exists(),
2773                "an invalid limit is rejected before the database file is created"
2774            );
2775        }
2776    }
2777
2778    #[test]
2779    fn failed_progress_retry_delay_doubles_to_the_cap() {
2780        let mut client = client();
2781        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2782        let mut transport = ServerErrorTransport {
2783            code: "sync.internal_error",
2784            retryable: true,
2785        };
2786        let delays: Vec<Option<u64>> = (0..9)
2787            .map(|_| {
2788                client.sync(&mut transport);
2789                client.progress().snapshot().unwrap().retry_delay_ms
2790            })
2791            .collect();
2792        assert_eq!(
2793            delays,
2794            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2795        );
2796    }
2797
2798    #[test]
2799    fn background_retry_deadlines_back_off_and_reset() {
2800        let mut client = client();
2801        client.schedule_background_retry();
2802        assert!(matches!(
2803            client.drain_sync_intents().as_slice(),
2804            [SyncIntent::Background { delay_ms: 250 }]
2805        ));
2806        client.schedule_background_retry();
2807        assert!(matches!(
2808            client.drain_sync_intents().as_slice(),
2809            [SyncIntent::Background { delay_ms: 500 }]
2810        ));
2811        client.reset_background_retry();
2812        client.schedule_background_retry();
2813        assert!(matches!(
2814            client.drain_sync_intents().as_slice(),
2815            [SyncIntent::Background { delay_ms: 250 }]
2816        ));
2817    }
2818
2819    #[test]
2820    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2821        let mut client = client();
2822        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2823        client
2824            .subscribe(
2825                "epoch-tasks".to_owned(),
2826                "tasks".to_owned(),
2827                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2828                None,
2829            )
2830            .expect("subscribe");
2831        client.drain_sync_intents();
2832
2833        client
2834            .run_log_epoch_reset("epoch-2")
2835            .expect("reset partition log epoch");
2836
2837        assert!(matches!(
2838            client.drain_sync_intents().as_slice(),
2839            [SyncIntent::Interactive]
2840        ));
2841    }
2842
2843    #[derive(Default)]
2844    struct CountingRealtimeTransport {
2845        connects: usize,
2846        closes: usize,
2847        messages: Vec<String>,
2848        blob_uploads: Vec<(String, Vec<u8>)>,
2849        blob_download: Option<Vec<u8>>,
2850        sync_calls: usize,
2851        realtime_sync_calls: usize,
2852        refuse_connect: bool,
2853    }
2854
2855    impl Transport for CountingRealtimeTransport {
2856        fn blob_upload(
2857            &mut self,
2858            blob_id: &str,
2859            bytes: &[u8],
2860            _media_type: Option<&str>,
2861        ) -> Result<(), TransportError> {
2862            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2863            Ok(())
2864        }
2865
2866        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2867            self.blob_download
2868                .clone()
2869                .map(BlobDownload::Bytes)
2870                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2871        }
2872
2873        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2874            self.sync_calls += 1;
2875            Err(TransportError::new("sync.transport_failed", "offline"))
2876        }
2877
2878        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2879            self.realtime_sync_calls += 1;
2880            Err(TransportError::new("sync.transport_failed", "offline"))
2881        }
2882
2883        fn download_segment(
2884            &mut self,
2885            _request: &SegmentRequest,
2886            _on_progress: &mut dyn FnMut(u64),
2887        ) -> Result<Vec<u8>, TransportError> {
2888            Err(TransportError::new("sync.transport_failed", "offline"))
2889        }
2890
2891        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2892            self.connects += 1;
2893            if self.refuse_connect {
2894                return Err(TransportError::new("transport.failed", "refused"));
2895            }
2896            Ok(())
2897        }
2898
2899        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2900            self.messages.push(text.to_owned());
2901            Ok(())
2902        }
2903
2904        fn realtime_close(&mut self) -> Result<(), TransportError> {
2905            self.closes += 1;
2906            Ok(())
2907        }
2908    }
2909
2910    #[test]
2911    fn blob_results_own_downloaded_bytes() {
2912        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2913            {"name":"id","type":"string","nullable":false},
2914            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2915        let bytes = b"owned typed result".to_vec();
2916        let blob_id = blob_id_for(&bytes);
2917        let mut transport = CountingRealtimeTransport {
2918            blob_download: Some(bytes.clone()),
2919            ..Default::default()
2920        };
2921        let result = {
2922            let mut client =
2923                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2924                    .unwrap();
2925            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2926            assert_eq!(result.blob_id, blob_id);
2927            assert_eq!(result.byte_length, bytes.len() as i64);
2928            assert_eq!(result.bytes, bytes);
2929            assert_eq!(result.media_type, None);
2930            assert_eq!(
2931                client
2932                    .conn
2933                    .query_row(
2934                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2935                        rusqlite::params![blob_id],
2936                        |row| row.get::<_, i64>(0),
2937                    )
2938                    .unwrap(),
2939                0
2940            );
2941            result
2942        };
2943        assert_eq!(result.bytes, bytes);
2944
2945        let mut client =
2946            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2947        assert_eq!(
2948            client
2949                .fetch_blob_bytes(&mut transport, "not json")
2950                .unwrap_err(),
2951            (
2952                "client.failed".to_owned(),
2953                "blob ref is not JSON".to_owned()
2954            )
2955        );
2956    }
2957
2958    #[test]
2959    fn visible_blob_references_survive_cache_trimming() {
2960        let schema = json!({"version":1,"tables":[
2961            {"name":"attachments","primaryKey":"id","columns":[
2962                {"name":"id","type":"string","nullable":false},
2963                {"name":"body","type":"blob_ref","nullable":true},
2964                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2965            {"name":"avatars","primaryKey":"id","columns":[
2966                {"name":"id","type":"string","nullable":false},
2967                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2968        ]});
2969        let bytes = b"referenced above cap".to_vec();
2970        let blob_id = blob_id_for(&bytes);
2971        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2972        let mut client = SyncClient::new(
2973            "visible-blob-refs".to_owned(),
2974            &schema,
2975            ClientLimits {
2976                blob_cache_max_bytes: Some(1),
2977                ..Default::default()
2978            },
2979        )
2980        .unwrap();
2981        client.conn.execute(
2982            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2983            rusqlite::params![reference, reference, reference, reference],
2984        ).unwrap();
2985        client
2986            .conn
2987            .execute(
2988                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2989                rusqlite::params![reference],
2990            )
2991            .unwrap();
2992        client.conn.execute(
2993            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2994            [],
2995        ).unwrap();
2996        let mut transport = CountingRealtimeTransport {
2997            blob_download: Some(bytes.clone()),
2998            ..Default::default()
2999        };
3000
3001        assert_eq!(
3002            client
3003                .fetch_blob_bytes(&mut transport, &blob_id)
3004                .unwrap()
3005                .bytes,
3006            bytes
3007        );
3008        let rows = client
3009            .conn
3010            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
3011            .unwrap()
3012            .query_map([], |row| row.get::<_, String>(0))
3013            .unwrap()
3014            .collect::<Result<Vec<_>, _>>()
3015            .unwrap();
3016        assert_eq!(rows, vec![blob_id]);
3017    }
3018
3019    #[test]
3020    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
3021        let mut transport = CountingRealtimeTransport::default();
3022        let path = std::env::temp_dir().join(format!(
3023            "syncular-transport-gate-{}.db",
3024            uuid::Uuid::new_v4()
3025        ));
3026        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
3027            {"name":"id","type":"string","nullable":false},
3028            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
3029        let mut client = SyncClient::open_path(
3030            "gate".into(),
3031            &schema,
3032            ClientLimits::default(),
3033            path.to_str().unwrap(),
3034        )
3035        .unwrap();
3036        client.connect_realtime(&mut transport).unwrap();
3037        client.set_transport_enabled(&mut transport, false);
3038        assert_eq!(transport.closes, 1);
3039        client.begin_security_preflight();
3040        client.activate_security(Default::default()).unwrap();
3041        client
3042            .subscribe(
3043                "tasks".into(),
3044                "tasks".into(),
3045                vec![("project_id".into(), vec!["p1".into()])],
3046                None,
3047            )
3048            .unwrap();
3049        for id in ["one", "two"] {
3050            client
3051                .mutate(vec![Mutation::Upsert {
3052                    table: "tasks".into(),
3053                    values: Map::from_iter([
3054                        ("id".into(), json!(id)),
3055                        ("project_id".into(), json!("p1")),
3056                    ]),
3057                    base_version: None,
3058                }])
3059                .unwrap();
3060        }
3061        assert_eq!(
3062            client
3063                .query("SELECT id FROM tasks ORDER BY id", &[])
3064                .unwrap()
3065                .len(),
3066            2
3067        );
3068        assert!(
3069            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
3070        );
3071        assert!(client
3072            .connect_realtime(&mut transport)
3073            .unwrap_err()
3074            .starts_with("sync.offline:"));
3075        assert!(client
3076            .set_presence(&mut transport, "project:p1", None)
3077            .unwrap_err()
3078            .starts_with("sync.offline:"));
3079        assert_eq!(transport.sync_calls, 0);
3080        assert_eq!(transport.realtime_sync_calls, 0);
3081        assert_eq!(transport.connects, 1);
3082        assert!(transport.messages.is_empty());
3083        assert!(matches!(
3084            client.drain_sync_intents().as_slice(),
3085            [SyncIntent::None]
3086        ));
3087        let pending = client.pending_commit_ids();
3088        drop(client);
3089        let mut reopened = SyncClient::open_path(
3090            "gate".into(),
3091            &schema,
3092            ClientLimits::default(),
3093            path.to_str().unwrap(),
3094        )
3095        .unwrap();
3096        assert!(reopened.transport_enabled());
3097        reopened.set_transport_enabled(&mut transport, false);
3098        assert_eq!(reopened.pending_commit_ids(), pending);
3099        assert_eq!(
3100            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
3101            2
3102        );
3103        reopened.set_transport_enabled(&mut transport, true);
3104        assert!(matches!(
3105            reopened.drain_sync_intents().as_slice(),
3106            [SyncIntent::Interactive]
3107        ));
3108        reopened.set_transport_enabled(&mut transport, true);
3109        assert!(reopened.drain_sync_intents().is_empty());
3110        drop(reopened);
3111        std::fs::remove_file(path).unwrap();
3112    }
3113
3114    #[test]
3115    fn realtime_connection_ownership_is_idempotent() {
3116        let mut client = client();
3117        let mut transport = CountingRealtimeTransport {
3118            connects: 0,
3119            closes: 0,
3120            ..Default::default()
3121        };
3122        client
3123            .connect_realtime(&mut transport)
3124            .expect("first connect");
3125        client
3126            .connect_realtime(&mut transport)
3127            .expect("idempotent connect");
3128        assert_eq!(transport.connects, 1);
3129        client.disconnect_realtime(&mut transport);
3130        client.disconnect_realtime(&mut transport);
3131        assert_eq!(transport.closes, 1);
3132        client
3133            .connect_realtime(&mut transport)
3134            .expect("deliberate reconnect");
3135        assert_eq!(transport.connects, 2);
3136    }
3137
3138    #[test]
3139    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3140        let mut client = client();
3141        client
3142            .set_realtime_policy(RealtimePolicy::Required)
3143            .expect("set policy");
3144        let mut transport = CountingRealtimeTransport::default();
3145        match client.sync(&mut transport) {
3146            SyncOutcome::RealtimeUnavailable {
3147                state,
3148                reason_code,
3149                retry_delay_ms,
3150            } => {
3151                assert_eq!(state, RealtimeState::Disconnected);
3152                assert_eq!(reason_code, None);
3153                assert_eq!(retry_delay_ms, 250);
3154            }
3155            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3156        }
3157        assert_eq!(transport.sync_calls, 0);
3158        assert!(matches!(
3159            client.drain_sync_intents().as_slice(),
3160            [SyncIntent::Background { delay_ms: 250 }]
3161        ));
3162        let snapshot = client
3163            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3164            .expect("diagnostics");
3165        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3166        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3167        assert_eq!(snapshot.host.realtime_reason_code, None);
3168        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3169        assert_eq!(
3170            snapshot
3171                .last_round
3172                .as_ref()
3173                .and_then(|round| round.error_code.clone()),
3174            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3175        );
3176    }
3177
3178    #[test]
3179    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3180        let mut client = client();
3181        client
3182            .set_realtime_policy(RealtimePolicy::Required)
3183            .expect("set policy");
3184        let mut transport = CountingRealtimeTransport {
3185            refuse_connect: true,
3186            ..Default::default()
3187        };
3188        assert!(client.connect_realtime(&mut transport).is_err());
3189        assert_eq!(client.realtime_state(), RealtimeState::Refused);
3190        let snapshot = client
3191            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3192            .expect("diagnostics");
3193        assert_eq!(
3194            snapshot.host.realtime_reason_code.as_deref(),
3195            Some("transport.failed")
3196        );
3197        match client.sync(&mut transport) {
3198            SyncOutcome::RealtimeUnavailable {
3199                state, reason_code, ..
3200            } => {
3201                assert_eq!(state, RealtimeState::Refused);
3202                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3203            }
3204            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3205        }
3206        assert_eq!(transport.sync_calls, 0);
3207        assert_eq!(transport.realtime_sync_calls, 0);
3208    }
3209
3210    #[test]
3211    fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3212        let mut client = client();
3213        let mut transport = CountingRealtimeTransport::default();
3214        let ended = TransportError::new("transport.failed", "realtime connection closed");
3215        client.realtime_lost(&mut transport, &ended);
3216        assert_eq!(
3217            client.realtime_state(),
3218            RealtimeState::Disconnected,
3219            "a socket that was never connected has nothing to lose"
3220        );
3221        assert!(client.drain_sync_intents().is_empty());
3222
3223        client.connect_realtime(&mut transport).expect("connect");
3224        client.realtime_lost(&mut transport, &ended);
3225        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3226        assert_eq!(transport.closes, 1);
3227        assert!(matches!(
3228            client.drain_sync_intents().as_slice(),
3229            [SyncIntent::Background { delay_ms: 250 }]
3230        ));
3231        let snapshot = client
3232            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3233            .expect("diagnostics");
3234        assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3235        assert_eq!(
3236            snapshot.host.realtime_reason_code.as_deref(),
3237            Some("transport.failed")
3238        );
3239        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3240
3241        client.realtime_lost(&mut transport, &ended);
3242        assert!(
3243            client.drain_sync_intents().is_empty(),
3244            "a second report for the same loss schedules nothing"
3245        );
3246        client.connect_realtime(&mut transport).expect("reconnect");
3247        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3248    }
3249
3250    #[test]
3251    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3252        let mut client = client();
3253        client
3254            .set_realtime_policy(RealtimePolicy::Required)
3255            .expect("set policy");
3256        let mut transport = CountingRealtimeTransport::default();
3257        client.connect_realtime(&mut transport).expect("connect");
3258        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3259        assert!(
3260            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3261            "a policy change cannot silently re-route a connected socket"
3262        );
3263        assert!(matches!(
3264            client.sync(&mut transport),
3265            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3266        ));
3267        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3268        assert_eq!(transport.realtime_sync_calls, 1);
3269        assert_eq!(transport.sync_calls, 0);
3270        let snapshot = client
3271            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3272            .expect("diagnostics");
3273        assert_eq!(
3274            snapshot.host.realtime_reason_code.as_deref(),
3275            Some("sync.transport_failed")
3276        );
3277        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3278        match client.sync(&mut transport) {
3279            SyncOutcome::RealtimeUnavailable {
3280                state, reason_code, ..
3281            } => {
3282                assert_eq!(state, RealtimeState::Lost);
3283                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3284            }
3285            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3286        }
3287        assert_eq!(transport.sync_calls, 0);
3288        client.disconnect_realtime(&mut transport);
3289        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3290        assert_eq!(transport.closes, 1);
3291        client.connect_realtime(&mut transport).expect("reconnect");
3292        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3293        assert_eq!(transport.connects, 2);
3294    }
3295
3296    #[test]
3297    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3298        let mut client = client();
3299        client
3300            .set_realtime_policy(RealtimePolicy::Off)
3301            .expect("set policy");
3302        let mut transport = CountingRealtimeTransport::default();
3303        assert!(client.connect_realtime(&mut transport).is_err());
3304        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3305        assert!(matches!(
3306            client.sync(&mut transport),
3307            SyncOutcome::Failed { .. }
3308        ));
3309        assert_eq!(transport.sync_calls, 1);
3310        assert_eq!(transport.realtime_sync_calls, 0);
3311        let snapshot = client
3312            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3313            .expect("diagnostics");
3314        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3315        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3316    }
3317
3318    #[test]
3319    fn optional_policy_keeps_the_http_round() {
3320        let mut client = client();
3321        let mut transport = CountingRealtimeTransport::default();
3322        assert!(matches!(
3323            client.sync(&mut transport),
3324            SyncOutcome::Failed { .. }
3325        ));
3326        assert_eq!(transport.sync_calls, 1);
3327        assert_eq!(transport.realtime_sync_calls, 0);
3328        let snapshot = client
3329            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3330            .expect("diagnostics");
3331        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3332        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3333    }
3334
3335    #[test]
3336    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3337        let mut client = client();
3338        client
3339            .subscribe(
3340                "repair-tasks".to_owned(),
3341                "tasks".to_owned(),
3342                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3343                None,
3344            )
3345            .expect("subscribe");
3346        {
3347            let sub = client
3348                .subs
3349                .iter_mut()
3350                .find(|sub| sub.id == "repair-tasks")
3351                .expect("subscription");
3352            sub.cursor = 42;
3353            sub.synced_once = true;
3354            let persisted = sub.clone();
3355            client.persist_sub(&persisted).unwrap();
3356        }
3357        for table in ["_syncular_base_tasks", "tasks"] {
3358            client
3359                .conn
3360                .execute(
3361                    &format!(
3362                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3363                    ),
3364                    rusqlite::params!["server-row", "p1"],
3365                )
3366                .expect("seed server row");
3367        }
3368        let pending = client
3369            .mutate(vec![Mutation::Upsert {
3370                table: "tasks".to_owned(),
3371                values: Map::from_iter([
3372                    ("id".to_owned(), Value::from("offline-row")),
3373                    ("project_id".to_owned(), Value::from("p1")),
3374                ]),
3375                base_version: None,
3376            }])
3377            .expect("queue offline work");
3378        client.drain_change_batches();
3379        client.drain_sync_intents();
3380
3381        assert_eq!(
3382            client
3383                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3384                    rebootstrap_id: "support-case-001".to_owned(),
3385                })
3386                .expect("rebootstrap"),
3387            LocalDataRebootstrapResult {
3388                already_applied: false,
3389                retained_commits: 1,
3390                reset_subscriptions: 1,
3391            }
3392        );
3393        let visible_ids = client
3394            .conn
3395            .prepare("SELECT id FROM tasks ORDER BY id")
3396            .expect("prepare visible ids")
3397            .query_map([], |row| row.get::<_, String>(0))
3398            .expect("query visible ids")
3399            .collect::<Result<Vec<_>, _>>()
3400            .expect("collect visible ids");
3401        assert_eq!(visible_ids, vec!["offline-row"]);
3402        assert_eq!(client.pending_commit_ids(), vec![pending]);
3403        assert_eq!(
3404            client
3405                .subscription_state("repair-tasks")
3406                .expect("subscription")
3407                .cursor,
3408            -1
3409        );
3410        assert!(client.upgrading());
3411        assert!(client.sync_needed());
3412        assert!(matches!(
3413            client.drain_sync_intents().as_slice(),
3414            [SyncIntent::Interactive]
3415        ));
3416        assert_eq!(client.drain_change_batches().len(), 1);
3417
3418        assert_eq!(
3419            client
3420                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3421                    rebootstrap_id: "support-case-001".to_owned(),
3422                })
3423                .expect("idempotent retry"),
3424            LocalDataRebootstrapResult {
3425                already_applied: true,
3426                retained_commits: 1,
3427                reset_subscriptions: 1,
3428            }
3429        );
3430        assert!(client.drain_change_batches().is_empty());
3431    }
3432
3433    #[test]
3434    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3435        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3436        assert_eq!(
3437            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3438            (3, 4)
3439        );
3440        assert_eq!(
3441            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3442            (0, 0)
3443        );
3444        for malformed in [
3445            "",
3446            "{}",
3447            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3448            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3449            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3450        ] {
3451            assert_eq!(
3452                decode_local_rebootstrap_receipt(malformed)
3453                    .expect_err("malformed receipt must fail"),
3454                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3455            );
3456        }
3457    }
3458
3459    #[test]
3460    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3461        let path = std::env::temp_dir().join(format!(
3462            "syncular-rebootstrap-receipt-{}.db",
3463            uuid::Uuid::new_v4()
3464        ));
3465        let schema = json!({
3466            "version": 1,
3467            "tables": [{
3468                "name": "tasks",
3469                "primaryKey": "id",
3470                "columns": [
3471                    { "name": "id", "type": "string", "nullable": false },
3472                    { "name": "project_id", "type": "string", "nullable": false }
3473                ],
3474                "scopes": [{ "pattern": "project:{project_id}" }]
3475            }]
3476        });
3477        let path_string = path.to_str().expect("UTF-8 temp path");
3478
3479        {
3480            let mut first = SyncClient::open_path(
3481                "repair-restart-client".to_owned(),
3482                &schema,
3483                ClientLimits::default(),
3484                path_string,
3485            )
3486            .expect("first open");
3487            first
3488                .subscribe(
3489                    "repair-tasks".to_owned(),
3490                    "tasks".to_owned(),
3491                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3492                    None,
3493                )
3494                .expect("subscribe");
3495            first
3496                .mutate(vec![Mutation::Upsert {
3497                    table: "tasks".to_owned(),
3498                    values: Map::from_iter([
3499                        ("id".to_owned(), Value::from("offline-row")),
3500                        ("project_id".to_owned(), Value::from("p1")),
3501                    ]),
3502                    base_version: None,
3503                }])
3504                .expect("queue offline work");
3505            assert_eq!(
3506                first
3507                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
3508                        rebootstrap_id: "restart-receipt".to_owned(),
3509                    })
3510                    .expect("first rebootstrap"),
3511                LocalDataRebootstrapResult {
3512                    already_applied: false,
3513                    retained_commits: 1,
3514                    reset_subscriptions: 1,
3515                }
3516            );
3517        }
3518
3519        let mut reopened = SyncClient::open_path(
3520            "repair-restart-client".to_owned(),
3521            &schema,
3522            ClientLimits::default(),
3523            path_string,
3524        )
3525        .expect("reopen");
3526        reopened.drain_change_batches();
3527        reopened.drain_sync_intents();
3528        assert_eq!(
3529            reopened
3530                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3531                    rebootstrap_id: "restart-receipt".to_owned(),
3532                })
3533                .expect("receipt replay"),
3534            LocalDataRebootstrapResult {
3535                already_applied: true,
3536                retained_commits: 1,
3537                reset_subscriptions: 1,
3538            }
3539        );
3540        assert!(reopened.drain_change_batches().is_empty());
3541        assert!(reopened.drain_sync_intents().is_empty());
3542        drop(reopened);
3543        std::fs::remove_file(path).expect("remove temp database");
3544    }
3545
3546    #[test]
3547    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3548        let mut malformed = client();
3549        malformed
3550            .subscribe(
3551                "repair-tasks".to_owned(),
3552                "tasks".to_owned(),
3553                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3554                None,
3555            )
3556            .expect("subscribe");
3557        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3558        malformed.drain_change_batches();
3559        malformed.drain_sync_intents();
3560        let malformed_error = malformed
3561            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3562                rebootstrap_id: "malformed".to_owned(),
3563            })
3564            .expect_err("malformed receipt must fail closed");
3565        assert_eq!(
3566            malformed_error,
3567            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3568        );
3569        assert!(!malformed.upgrading());
3570        assert_eq!(
3571            malformed
3572                .subscription_state("repair-tasks")
3573                .expect("unchanged subscription")
3574                .cursor,
3575            -1
3576        );
3577        assert!(malformed.drain_change_batches().is_empty());
3578        assert!(malformed.drain_sync_intents().is_empty());
3579
3580        let mut unreadable = client();
3581        unreadable
3582            .conn
3583            .execute(
3584                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3585                rusqlite::params!["server-row", "p1"],
3586            )
3587            .expect("seed visible row");
3588        unreadable
3589            .conn
3590            .execute("DROP TABLE _syncular_meta", [])
3591            .expect("break marker storage");
3592        let unreadable_error = unreadable
3593            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3594                rebootstrap_id: "unreadable".to_owned(),
3595            })
3596            .expect_err("unreadable marker storage must fail closed");
3597        assert_eq!(
3598            unreadable_error,
3599            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3600        );
3601        let visible_rows = unreadable
3602            .conn
3603            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3604            .expect("visible projection remains");
3605        assert_eq!(visible_rows, 1);
3606        assert!(!unreadable.upgrading());
3607        assert!(unreadable.drain_change_batches().is_empty());
3608        assert!(unreadable.drain_sync_intents().is_empty());
3609    }
3610
3611    #[test]
3612    fn local_rebootstrap_cannot_bypass_schema_floor() {
3613        let mut client = client();
3614        client.set_schema_floor(Some(SchemaFloor {
3615            required_schema_version: Some(2),
3616            latest_schema_version: Some(2),
3617        }));
3618        let error = client
3619            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3620                rebootstrap_id: "blocked-floor".to_owned(),
3621            })
3622            .expect_err("schema floor must block repair");
3623        assert!(error.contains("cannot bypass an active schema-floor stop"));
3624    }
3625
3626    #[test]
3627    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3628        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3629            "columns":[{"name":"id","type":"string","nullable":false},
3630                {"name":"project_id","type":"string","nullable":false},
3631                {"name":"starts_at_ms","type":"integer","nullable":false},
3632                {"name":"version","type":"integer","nullable":false}],
3633            "scopes":[{"pattern":"project:{project_id}"}]}]});
3634        let mut client =
3635            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3636        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3637        client
3638            .write_base_row(
3639                "tasks",
3640                &vec![
3641                    Some(ColumnValue::String("t1".into())),
3642                    Some(ColumnValue::String("p1".into())),
3643                    Some(ColumnValue::Integer(100)),
3644                    Some(ColumnValue::Integer(1)),
3645                ],
3646                1,
3647            )
3648            .unwrap();
3649        client.rebuild_overlay().expect("rebuild overlay");
3650        let first = client
3651            .patch(
3652                "tasks",
3653                "t1",
3654                Map::from_iter([
3655                    ("starts_at_ms".into(), json!(200)),
3656                    ("version".into(), json!(2)),
3657                ]),
3658                None,
3659            )
3660            .unwrap();
3661        let (_, first_request) = client.build_request(false).unwrap();
3662        // This second edit happens while the first request is in flight.
3663        let second = client
3664            .patch(
3665                "tasks",
3666                "t1",
3667                Map::from_iter([
3668                    ("starts_at_ms".into(), json!(300)),
3669                    ("version".into(), json!(3)),
3670                ]),
3671                None,
3672            )
3673            .unwrap();
3674        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3675        for (id, seq, meta) in [
3676            (first, 2, first_request),
3677            (second, 3, client.build_request(false).unwrap().1),
3678        ] {
3679            let response = Message {
3680                wire_version: WIRE_VERSION,
3681                msg_kind: MsgKind::Response,
3682                frames: vec![
3683                    Frame::RespHeader {
3684                        required_schema_version: None,
3685                        latest_schema_version: None,
3686                        log_epoch: Some("epoch-1".into()),
3687                        reset_required: Some(false),
3688                    },
3689                    Frame::PushResult {
3690                        client_commit_id: id,
3691                        status: PushStatus::Applied,
3692                        commit_seq: Some(seq),
3693                        results: vec![OpResult::Applied { op_index: 0 }],
3694                    },
3695                ],
3696            };
3697            assert!(matches!(
3698                client.process_response(&mut transport, response, &meta),
3699                SyncOutcome::Ok(_)
3700            ));
3701            let row = &client
3702                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3703                .unwrap()[0];
3704            assert_eq!(
3705                row["starts_at_ms"],
3706                json!(300),
3707                "an applied ACK without a pull image must not expose the old base"
3708            );
3709            assert_eq!(
3710                row["version"],
3711                json!(3),
3712                "the queued domain version must remain monotonic"
3713            );
3714        }
3715    }
3716
3717    #[test]
3718    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3719        for stored in [None, Some("epoch-old")] {
3720            let mut client = client();
3721            client
3722                .subscribe(
3723                    "tasks".into(),
3724                    "tasks".into(),
3725                    vec![("project_id".into(), vec!["p1".into()])],
3726                    None,
3727                )
3728                .unwrap();
3729            client
3730                .conn
3731                .execute_batch(
3732                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3733                )
3734                .unwrap();
3735            let id = client
3736                .mutate(vec![Mutation::Upsert {
3737                    table: "tasks".into(),
3738                    values: Map::from_iter([
3739                        ("id".into(), json!("offline")),
3740                        ("project_id".into(), json!("p1")),
3741                    ]),
3742                    base_version: None,
3743                }])
3744                .unwrap();
3745            if let Some(epoch) = stored {
3746                client.set_meta(LOG_EPOCH_KEY, epoch);
3747            }
3748            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3749            let (_, meta) = client.build_request(false).unwrap();
3750            client.drain_change_batches();
3751            client.drain_sync_intents();
3752            let response = Message {
3753                wire_version: WIRE_VERSION,
3754                msg_kind: MsgKind::Response,
3755                frames: vec![Frame::RespHeader {
3756                    required_schema_version: None,
3757                    latest_schema_version: None,
3758                    log_epoch: Some("epoch-new".into()),
3759                    reset_required: Some(true),
3760                }],
3761            };
3762            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3763            let outcome = client.process_response(&mut transport, response, &meta);
3764            let SyncOutcome::Ok(report) = outcome else {
3765                panic!("epoch round failed: {outcome:?}");
3766            };
3767            assert_eq!(
3768                report.resets,
3769                if stored.is_none() {
3770                    Vec::<String>::new()
3771                } else {
3772                    vec!["tasks".into()]
3773                }
3774            );
3775            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3776            assert_eq!(client.pending_commit_ids(), vec![id]);
3777            assert_eq!(
3778                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3779                json!("offline")
3780            );
3781            let trigger: i64 = client
3782                .conn
3783                .query_row(
3784                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3785                    [],
3786                    |row| row.get(0),
3787                )
3788                .unwrap();
3789            if stored.is_none() {
3790                assert!(!client.upgrading);
3791                assert!(client
3792                    .drain_change_batches()
3793                    .iter()
3794                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3795                assert_eq!(
3796                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3797                    before
3798                );
3799                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3800            } else {
3801                assert!(client.upgrading);
3802                assert_eq!(trigger, 0, "an epoch change must drop the old table");
3803            }
3804            assert!(matches!(
3805                client.drain_sync_intents().as_slice(),
3806                [SyncIntent::Interactive]
3807            ));
3808        }
3809    }
3810
3811    #[test]
3812    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3813        for key in ["logEpoch", "localRevision"] {
3814            let mut client = client();
3815            let id = client
3816                .mutate(vec![Mutation::Upsert {
3817                    table: "tasks".into(),
3818                    values: Map::from_iter([
3819                        ("id".into(), json!("offline")),
3820                        ("project_id".into(), json!("p1")),
3821                    ]),
3822                    base_version: None,
3823                }])
3824                .unwrap();
3825            let revision = client.local_revision();
3826            let needed = client.sync_needed;
3827            client.drain_change_batches();
3828            client.drain_sync_intents();
3829            client.conn.execute_batch(&format!("CREATE TRIGGER fail_acquisition BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected acquisition failure'); END")).unwrap();
3830            assert!(client.run_log_epoch_reset("epoch-new").is_err());
3831            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3832            assert!(!client.upgrading);
3833            assert_eq!(client.sync_needed, needed);
3834            assert_eq!(client.local_revision(), revision);
3835            assert_eq!(client.pending_commit_ids(), [id]);
3836            assert!(client.drain_change_batches().is_empty());
3837            assert!(client.drain_sync_intents().is_empty());
3838        }
3839    }
3840
3841    #[test]
3842    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3843        for key in [
3844            "localSchemaVersion",
3845            "localSchemaDescriptor",
3846            "logEpoch",
3847            "localRevision",
3848        ] {
3849            let mut client = client();
3850            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3851            let commit = client
3852                .mutate(vec![Mutation::Upsert {
3853                    table: "tasks".into(),
3854                    values: Map::from_iter([
3855                        ("id".into(), json!("offline")),
3856                        ("project_id".into(), json!("p1")),
3857                    ]),
3858                    base_version: None,
3859                }])
3860                .unwrap();
3861            client.active_round = Some(uuid::Uuid::new_v4());
3862            client.overlay_dirty.set(false);
3863            let round = client.active_round;
3864            let needed = client.sync_needed;
3865            let revision = client.local_revision();
3866            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3867            client.drain_change_batches();
3868            client.drain_sync_intents();
3869            client.conn.execute_batch(&format!("CREATE TRIGGER fail_reset BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected reset failure'); END")).unwrap();
3870            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3871            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3872            assert!(!client.upgrading);
3873            assert_eq!(client.active_round, round);
3874            assert!(!client.overlay_dirty.get());
3875            assert_eq!(client.sync_needed, needed);
3876            assert_eq!(client.local_revision(), revision);
3877            assert_eq!(
3878                client.pending_commit_ids().as_slice(),
3879                std::slice::from_ref(&commit)
3880            );
3881            assert_eq!(
3882                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3883                rows
3884            );
3885            assert!(client.drain_change_batches().is_empty());
3886            assert!(client.drain_sync_intents().is_empty());
3887            assert!(client.conn.is_autocommit());
3888            client
3889                .conn
3890                .execute_batch("DROP TRIGGER fail_reset")
3891                .unwrap();
3892            client.run_log_epoch_reset("epoch-new").unwrap();
3893            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3894            assert_eq!(client.pending_commit_ids(), [commit]);
3895        }
3896    }
3897
3898    #[test]
3899    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3900        for subscriptions in [0, 2, 6, 12] {
3901            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3902                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3903                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3904            ] }), ClientLimits::default()).unwrap();
3905            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3906            client.conn.execute_batch("CREATE TABLE ack_writes(kind TEXT); CREATE TRIGGER count_ack_restore AFTER DELETE ON tasks BEGIN INSERT INTO ack_writes VALUES('restore'); END; CREATE TRIGGER count_ack_replay AFTER INSERT ON tasks BEGIN INSERT INTO ack_writes VALUES('replay'); END;").unwrap();
3907            for index in 0..32 {
3908                client
3909                    .mutate(vec![Mutation::Upsert {
3910                        table: "tasks".into(),
3911                        values: Map::from_iter([
3912                            ("id".into(), json!(format!("task-{index}"))),
3913                            ("project_id".into(), json!("p1")),
3914                        ]),
3915                        base_version: None,
3916                    }])
3917                    .unwrap();
3918            }
3919            let (_, meta) = client.build_request(false).unwrap();
3920            let mut frames = vec![Frame::RespHeader {
3921                required_schema_version: None,
3922                latest_schema_version: None,
3923                log_epoch: Some("epoch-1".into()),
3924                reset_required: Some(false),
3925            }];
3926            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3927                Frame::PushResult {
3928                    client_commit_id: id.clone(),
3929                    status: PushStatus::Applied,
3930                    commit_seq: Some(index as i64 + 1),
3931                    results: vec![OpResult::Applied { op_index: 0 }],
3932                }
3933            }));
3934            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3935            client.acknowledged_replay_count.set(0);
3936            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3937            assert!(matches!(
3938                client.process_response(
3939                    &mut transport,
3940                    Message {
3941                        wire_version: WIRE_VERSION,
3942                        msg_kind: MsgKind::Response,
3943                        frames
3944                    },
3945                    &meta
3946                ),
3947                SyncOutcome::Ok(_)
3948            ));
3949            assert_eq!(client.acknowledged_replay_count.get(), 32);
3950            assert_eq!(
3951                client
3952                    .query(
3953                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3954                        &[]
3955                    )
3956                    .unwrap()[0]["n"],
3957                json!(32)
3958            );
3959            assert_eq!(
3960                client
3961                    .query(
3962                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3963                        &[]
3964                    )
3965                    .unwrap()[0]["n"],
3966                json!(32)
3967            );
3968            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3969            client.acknowledged_replay_count.set(0);
3970            for index in 0..subscriptions {
3971                client
3972                    .subscribe(
3973                        format!("docs-{index}"),
3974                        "docs".into(),
3975                        vec![("project_id".into(), vec![format!("scope-{index}")])],
3976                        None,
3977                    )
3978                    .unwrap();
3979                let sub = client.subs.len() - 1;
3980                client.subs[sub].effective =
3981                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3982                let segment = RowsSegment {
3983                    table: "docs".into(),
3984                    schema_version: 1,
3985                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3986                    blocks: if index % 2 == 0 {
3987                        vec![vec![ssp2::segment::SegmentRow {
3988                            values: vec![
3989                                Some(ColumnValue::String(format!("doc-{index}"))),
3990                                Some(ColumnValue::String(format!("scope-{index}"))),
3991                            ],
3992                            server_version: 1,
3993                        }]]
3994                    } else {
3995                        vec![]
3996                    },
3997                };
3998                assert!(client.apply_segment(sub, &segment, true).is_ok());
3999            }
4000            assert_eq!(
4001                client
4002                    .query("SELECT count(*) AS n FROM ack_writes", &[])
4003                    .unwrap()[0]["n"],
4004                json!(0),
4005                "unrelated bootstrap must not restore/replay tasks"
4006            );
4007            assert_eq!(
4008                client.acknowledged_replay_count.get(),
4009                0,
4010                "unrelated bootstrap must not decode task ACKs"
4011            );
4012            assert_eq!(
4013                client
4014                    .query("SELECT count(*) AS n FROM tasks", &[])
4015                    .unwrap()[0]["n"],
4016                json!(32)
4017            );
4018            assert_eq!(
4019                client
4020                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
4021                    .unwrap()[0]["n"],
4022                json!(32)
4023            );
4024        }
4025    }
4026
4027    #[test]
4028    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
4029        let mut client = client();
4030        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4031        const COMMIT_COUNT: usize = 32;
4032
4033        for index in 0..COMMIT_COUNT {
4034            client
4035                .mutate(vec![Mutation::Upsert {
4036                    table: "tasks".to_owned(),
4037                    values: Map::from_iter([
4038                        ("id".to_owned(), Value::from(format!("task-{index}"))),
4039                        ("project_id".to_owned(), Value::from("project-1")),
4040                    ]),
4041                    base_version: None,
4042                }])
4043                .expect("queue commit");
4044        }
4045
4046        let (_, request_meta) = client.build_request(false).unwrap();
4047        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
4048        let mut frames = vec![Frame::RespHeader {
4049            required_schema_version: None,
4050            latest_schema_version: None,
4051            log_epoch: Some("epoch-1".to_owned()),
4052            reset_required: Some(false),
4053        }];
4054        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
4055            |(index, client_commit_id)| Frame::PushResult {
4056                client_commit_id: client_commit_id.clone(),
4057                status: PushStatus::Applied,
4058                commit_seq: Some(index as i64 + 1),
4059                results: vec![OpResult::Applied { op_index: 0 }],
4060            },
4061        ));
4062        let response = Message {
4063            wire_version: WIRE_VERSION,
4064            msg_kind: MsgKind::Response,
4065            frames,
4066        };
4067        let mut transport =
4068            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4069
4070        client.overlay_rebuild_count.set(0);
4071        client.outcome_prune_count.set(0);
4072        let outcome = client.process_response(&mut transport, response, &request_meta);
4073        assert!(matches!(outcome, SyncOutcome::Ok(_)));
4074        assert!(client.pending_commit_ids().is_empty());
4075        assert_eq!(
4076            client.overlay_rebuild_count.get(),
4077            1,
4078            "one response must reconcile its acknowledged commits with one overlay rebuild"
4079        );
4080        assert_eq!(
4081            client.outcome_prune_count.get(),
4082            1,
4083            "one response must enforce outcome retention once"
4084        );
4085    }
4086
4087    #[test]
4088    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
4089        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
4090            "columns": [
4091                {"name":"id", "type":"string", "nullable":false},
4092                {"name":"project_id", "type":"string", "nullable":false},
4093                {"name":"title", "type":"string", "nullable":false}
4094            ], "scopes": [{"pattern":"project:{project_id}"}],
4095            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4096            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
4097        }] });
4098        let mut incremental =
4099            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4100        let mut reference =
4101            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4102        let tables = vec!["tasks".to_owned()];
4103        for index in 0..32 {
4104            let id = format!("task-{}", index % 8);
4105            let values = Map::from_iter([
4106                ("id".to_owned(), Value::from(id.clone())),
4107                (
4108                    "project_id".to_owned(),
4109                    Value::from(format!("p{}", index % 2)),
4110                ),
4111                ("title".to_owned(), Value::from(format!("needle{index}"))),
4112            ]);
4113            let payload = encode_row_json(
4114                incremental.schema.table("tasks").unwrap(),
4115                &id,
4116                &values,
4117                &incremental.encryption,
4118            )
4119            .unwrap();
4120            let change = ssp2::model::Change {
4121                table_index: 0,
4122                row_id: id,
4123                op: if index % 5 == 0 {
4124                    Op::Delete
4125                } else {
4126                    Op::Upsert
4127                },
4128                row_version: Some(index + 1),
4129                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4130                row: Some(payload),
4131            };
4132            reference.overlay_dirty.set(true);
4133            for instance in [&mut incremental, &mut reference] {
4134                instance.begin_observation("mirror_test").unwrap();
4135                let mut batch = ChangeAccumulator::default();
4136                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4137                instance
4138                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
4139                    .unwrap();
4140                instance
4141                    .rebuild_overlay_if_dirty()
4142                    .expect("rebuild overlay");
4143                instance.finish_observation("mirror_test", batch).unwrap();
4144            }
4145            for sql in [
4146                "SELECT * FROM tasks ORDER BY id",
4147                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4148            ] {
4149                assert_eq!(
4150                    incremental.query(sql, &[]).unwrap(),
4151                    reference.query(sql, &[]).unwrap()
4152                );
4153            }
4154            assert_eq!(incremental.local_revision(), reference.local_revision());
4155            assert_eq!(
4156                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4157                serde_json::to_value(reference.drain_change_batches()).unwrap()
4158            );
4159        }
4160        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4161        assert_eq!(reference.overlay_rebuild_count.get(), 32);
4162    }
4163
4164    #[test]
4165    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4166        let cases = [
4167            (
4168                "string",
4169                vec![
4170                    json!("1"),
4171                    json!("01"),
4172                    json!(""),
4173                    json!("nul\u{0}id"),
4174                    json!("é"),
4175                ],
4176            ),
4177            (
4178                "integer",
4179                vec![
4180                    json!(i64::MIN),
4181                    json!(-1),
4182                    json!(0),
4183                    json!(1),
4184                    json!(i64::MAX),
4185                ],
4186            ),
4187            ("boolean", vec![json!(false), json!(true)]),
4188            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4189        ];
4190        for (kind, ids) in cases {
4191            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4192                "columns":[{"name":"id","type":kind,"nullable":false},
4193                    {"name":"project_id","type":"string","nullable":false}],
4194                "scopes":[{"pattern":"project:{project_id}"}]}]});
4195            let mut client =
4196                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4197            for (index, id) in ids.iter().enumerate() {
4198                let table = client.schema.table("tasks").unwrap();
4199                client
4200                    .write_base_row(
4201                        "tasks",
4202                        &vec![
4203                            if kind == "integer" {
4204                                // Exercise local SQLite key lookup across its
4205                                // full i64 range independently of authoring.
4206                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
4207                            } else {
4208                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
4209                            },
4210                            Some(ColumnValue::String(format!("p{index}"))),
4211                        ],
4212                        1,
4213                    )
4214                    .unwrap();
4215            }
4216            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4217            for base in [true, false] {
4218                let full = if base {
4219                    base_table("tasks")
4220                } else {
4221                    visible_table("tasks")
4222                };
4223                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4224                for row_id in [
4225                    "1",
4226                    "01",
4227                    "1.0",
4228                    "1.0000000000000002",
4229                    "+1",
4230                    "1e0",
4231                    " 1",
4232                    "-1",
4233                    "0",
4234                    "9223372036854775807",
4235                    "-9223372036854775808",
4236                    "true",
4237                    "false",
4238                    "",
4239                    "nul\u{0}id",
4240                    "é",
4241                    "null",
4242                    "{\"a\":1}",
4243                ] {
4244                    let old_sql =
4245                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4246                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4247                    let old = client
4248                        .conn
4249                        .prepare(&old_sql)
4250                        .unwrap()
4251                        .query_map([row_id], |row| row.get::<_, String>(0))
4252                        .unwrap()
4253                        .collect::<Result<Vec<_>, _>>()
4254                        .unwrap();
4255                    let new = client
4256                        .conn
4257                        .prepare_cached(&new_sql)
4258                        .unwrap()
4259                        .query_map([row_id], |row| row.get::<_, String>(0))
4260                        .unwrap()
4261                        .collect::<Result<Vec<_>, _>>()
4262                        .unwrap();
4263                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4264                    let mut batch = ChangeAccumulator::default();
4265                    assert_eq!(
4266                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
4267                        !old.is_empty()
4268                    );
4269                    let expected = old.first().map(|scope| {
4270                        BTreeMap::from([(
4271                            "tasks".to_owned(),
4272                            Some(BTreeSet::from([format!("project:{scope}")])),
4273                        )])
4274                    });
4275                    assert_eq!(batch.tables, expected.unwrap_or_default());
4276                    client
4277                        .conn
4278                        .execute_batch("SAVEPOINT lookup_delete")
4279                        .unwrap();
4280                    if base {
4281                        client.delete_base_row("tasks", row_id).unwrap();
4282                    } else {
4283                        client
4284                            .apply_outbox_ops(&[OutboxOp {
4285                                upsert: false,
4286                                insert: false,
4287                                table: "tasks".into(),
4288                                row_id: row_id.into(),
4289                                base_version: None,
4290                                values: None,
4291                            }])
4292                            .expect("replay delete");
4293                    }
4294                    let remaining: i64 = client
4295                        .conn
4296                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4297                            row.get(0)
4298                        })
4299                        .unwrap();
4300                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4301                    client
4302                        .conn
4303                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4304                        .unwrap();
4305                    client.overlay_dirty.set(false);
4306                }
4307                for (predicate, expected) in [
4308                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4309                    (predicate, "SEARCH"),
4310                ] {
4311                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4312                    let details = client
4313                        .conn
4314                        .prepare(&sql)
4315                        .unwrap()
4316                        .query_map(["1"], |row| row.get::<_, String>(3))
4317                        .unwrap()
4318                        .collect::<Result<Vec<_>, _>>()
4319                        .unwrap();
4320                    assert!(
4321                        details.iter().any(|detail| detail.contains(expected)),
4322                        "{kind}: {details:?}"
4323                    );
4324                }
4325            }
4326        }
4327    }
4328
4329    #[test]
4330    fn primary_keys_reject_every_ineligible_column_type() {
4331        // §2.4: a `rowId` is a string, so a primary key type whose string form
4332        // differs per renderer, or cannot be reproduced by local storage
4333        // comparison, is a schema error rather than a silent lookup miss.
4334        for ty in ["float", "bytes", "crdt", "blob_ref"] {
4335            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4336                "columns":[{"name":"id","type":ty,"nullable":false},
4337                    {"name":"project_id","type":"string","nullable":false}],
4338                "scopes":[{"pattern":"project:{project_id}"}]}]});
4339            let error =
4340                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4341                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4342                    Err(error) => error,
4343                };
4344            assert!(
4345                error.contains("has an ineligible column type (§2.4)"),
4346                "{ty}: {error}"
4347            );
4348        }
4349        for ty in ["string", "integer", "boolean", "json"] {
4350            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4351                "columns":[{"name":"id","type":ty,"nullable":false},
4352                    {"name":"project_id","type":"string","nullable":false}],
4353                "scopes":[{"pattern":"project:{project_id}"}]}]});
4354            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4355                .unwrap_or_else(|error| panic!("{ty}: {error}"));
4356        }
4357    }
4358
4359    #[test]
4360    fn pending_row_reconciliation_matches_full_fifo_replay() {
4361        for unique in [false, true] {
4362            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4363                "name": name, "primaryKey": "id", "columns": [
4364                    {"name":"id", "type":"string", "nullable":false},
4365                    {"name":"project_id", "type":"string", "nullable":false},
4366                    {"name":"title", "type":"string", "nullable":false}],
4367                "scopes": [{"pattern":"project:{project_id}"}],
4368                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4369                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4370            })))});
4371            let mut incremental =
4372                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4373            let mut reference =
4374                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4375            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4376            for instance in [&mut incremental, &mut reference] {
4377                for table in &tables {
4378                    for index in 0..12 {
4379                        instance
4380                            .write_base_row(
4381                                table,
4382                                &vec![
4383                                    Some(ColumnValue::String(format!("row{index}"))),
4384                                    Some(ColumnValue::String("p0".into())),
4385                                    Some(ColumnValue::String(format!("base{index}"))),
4386                                ],
4387                                1,
4388                            )
4389                            .unwrap();
4390                    }
4391                }
4392                instance
4393                    .rebuild_overlay_if_dirty()
4394                    .expect("rebuild overlay");
4395            }
4396            for index in 0..80 {
4397                let mutations = (0..3)
4398                    .map(|offset| {
4399                        let table = tables[(index + offset) % 2].clone();
4400                        let id = format!("row{}", (index + offset) % 16);
4401                        if (index + offset) % 7 == 0 {
4402                            Mutation::Delete {
4403                                table,
4404                                row_id: id,
4405                                base_version: None,
4406                            }
4407                        } else {
4408                            Mutation::Upsert {
4409                                table,
4410                                values: Map::from_iter([
4411                                    ("id".into(), Value::from(id)),
4412                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
4413                                    (
4414                                        "title".into(),
4415                                        Value::from(format!("needle{}", (index + offset) % 16)),
4416                                    ),
4417                                ]),
4418                                base_version: None,
4419                            }
4420                        }
4421                    })
4422                    .collect::<Vec<_>>();
4423                for instance in [&mut incremental, &mut reference] {
4424                    instance.mutate(mutations.clone()).unwrap();
4425                }
4426                // Most frames touch only the table without unique constraints.
4427                // Mixed frames must retain full replay for both tables.
4428                let changes = (0..3)
4429                    .map(|offset| {
4430                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4431                        let id = format!("row{}", (index + offset) % 16);
4432                        let values = Map::from_iter([
4433                            ("id".into(), Value::from(id.clone())),
4434                            (
4435                                "project_id".into(),
4436                                Value::from(format!("p{}", (index + 1) % 3)),
4437                            ),
4438                            (
4439                                "title".into(),
4440                                Value::from(format!("remote{index}_{offset}")),
4441                            ),
4442                        ]);
4443                        ssp2::model::Change {
4444                            table_index: table_index as u16,
4445                            row_id: id.clone(),
4446                            op: if (index + offset) % 5 == 0 {
4447                                Op::Delete
4448                            } else {
4449                                Op::Upsert
4450                            },
4451                            row_version: Some(index as i64 + 2),
4452                            scopes: vec![],
4453                            row: Some(
4454                                encode_row_json(
4455                                    incremental.schema.table(&tables[table_index]).unwrap(),
4456                                    &id,
4457                                    &values,
4458                                    &incremental.encryption,
4459                                )
4460                                .unwrap(),
4461                            ),
4462                        }
4463                    })
4464                    .collect::<Vec<_>>();
4465                reference.overlay_dirty.set(true);
4466                incremental
4467                    .apply_commit_frame(&tables, &changes, None)
4468                    .unwrap();
4469                reference
4470                    .apply_commit_frame(&tables, &changes, None)
4471                    .unwrap();
4472                for table in &tables {
4473                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4474                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4475                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4476                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4477                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4478                    }
4479                }
4480                assert_eq!(incremental.local_revision(), reference.local_revision());
4481                assert_eq!(
4482                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4483                    serde_json::to_value(reference.drain_change_batches()).unwrap()
4484                );
4485            }
4486            assert_eq!(
4487                incremental.overlay_rebuild_count.get(),
4488                if unique { 81 } else { 21 }
4489            );
4490            assert_eq!(reference.overlay_rebuild_count.get(), 81);
4491        }
4492    }
4493
4494    #[test]
4495    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4496        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4497            "columns":[{"name":"id", "type":"string", "nullable":false},
4498                {"name":"title", "type":"string", "nullable":false}],
4499            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4500        let mut client =
4501            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4502        for (id, title) in [("a", "original"), ("b", "occupied")] {
4503            client
4504                .write_base_row(
4505                    "tasks",
4506                    &vec![
4507                        Some(ColumnValue::String(id.into())),
4508                        Some(ColumnValue::String(title.into())),
4509                    ],
4510                    1,
4511                )
4512                .unwrap();
4513        }
4514        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4515        let pending = client
4516            .mutate(vec![Mutation::Upsert {
4517                table: "tasks".into(),
4518                values: Map::from_iter([
4519                    ("id".into(), Value::from("a")),
4520                    ("title".into(), Value::from("available")),
4521                ]),
4522                base_version: None,
4523            }])
4524            .unwrap();
4525        assert_eq!(
4526            client
4527                .conn
4528                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4529                    .get::<_, String>(
4530                    0
4531                ))
4532                .unwrap(),
4533            "available"
4534        );
4535        for (title, expected) in [("available", "original"), ("occupied", "available")] {
4536            let change = ssp2::model::Change {
4537                table_index: 0,
4538                row_id: "b".into(),
4539                op: Op::Upsert,
4540                row_version: Some(2),
4541                scopes: vec![],
4542                row: Some(
4543                    encode_row_json(
4544                        client.schema.table("tasks").unwrap(),
4545                        "b",
4546                        &Map::from_iter([
4547                            ("id".into(), Value::from("b")),
4548                            ("title".into(), Value::from(title)),
4549                        ]),
4550                        &client.encryption,
4551                    )
4552                    .unwrap(),
4553                ),
4554            };
4555            client
4556                .apply_commit_frame(&["tasks".into()], &[change], None)
4557                .unwrap();
4558            assert_eq!(
4559                client
4560                    .conn
4561                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4562                        .get::<_, String>(
4563                        0
4564                    ))
4565                    .unwrap(),
4566                expected
4567            );
4568            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4569        }
4570        assert_eq!(client.overlay_rebuild_count.get(), 3);
4571    }
4572
4573    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4574        let schema = json!({ "version": 1, "tables": [
4575            { "name":"tasks", "primaryKey":"id", "columns":[
4576                {"name":"id","type":"string","nullable":false},
4577                {"name":"project_id","type":"string","nullable":false}],
4578                "scopes":[{"pattern":"project:{project_id}"}] },
4579            { "name":"catalogue", "primaryKey":"id", "columns":[
4580                {"name":"id","type":"string","nullable":false},
4581                {"name":"project_id","type":"string","nullable":false},
4582                {"name":"title","type":"string","nullable":false}],
4583                "scopes":[{"pattern":"project:{project_id}"}],
4584                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4585        ] });
4586        let mut client =
4587            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4588        if unchanged_rows > 0 {
4589            client.conn.execute_batch(&format!("WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<{unchanged_rows}) INSERT INTO _syncular_base_catalogue SELECT 'c'||i,'p1','needle '||i,1 FROM n")).unwrap();
4590        }
4591        client.rebuild_overlay().expect("rebuild overlay");
4592        client.conn.execute_batch("CREATE TABLE audit(tbl TEXT); CREATE TRIGGER catalogue_changed AFTER DELETE ON catalogue BEGIN INSERT INTO audit VALUES ('catalogue'); END;").unwrap();
4593        let mut elapsed = Vec::new();
4594        for round in 1..=rounds {
4595            let pending = client
4596                .mutate(vec![Mutation::Upsert {
4597                    table: "tasks".into(),
4598                    values: Map::from_iter([
4599                        ("id".into(), json!("t1")),
4600                        ("project_id".into(), json!("p1")),
4601                    ]),
4602                    base_version: None,
4603                }])
4604                .unwrap();
4605            let started = std::time::Instant::now();
4606            client
4607                .write_base_row(
4608                    "tasks",
4609                    &vec![
4610                        Some(ColumnValue::String("t1".into())),
4611                        Some(ColumnValue::String("p1".into())),
4612                    ],
4613                    round,
4614                )
4615                .unwrap();
4616            let mut report = SyncReport::default();
4617            client
4618                .handle_push_results(
4619                    &[Frame::PushResult {
4620                        client_commit_id: pending.clone(),
4621                        status: PushStatus::Applied,
4622                        commit_seq: Some(round),
4623                        results: vec![OpResult::Applied { op_index: 0 }],
4624                    }],
4625                    &HashSet::from([pending.as_str()]),
4626                    &HashMap::new(),
4627                    &mut report,
4628                    Some(&pending),
4629                )
4630                .unwrap();
4631            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4632            elapsed.push(started.elapsed().as_nanos());
4633        }
4634        assert_eq!(
4635            client
4636                .conn
4637                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4638                .unwrap(),
4639            0,
4640            "unchanged catalogue must never be copied"
4641        );
4642        assert_eq!(
4643            client
4644                .conn
4645                .query_row(
4646                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4647                    [],
4648                    |r| r.get::<_, i64>(0)
4649                )
4650                .unwrap(),
4651            unchanged_rows
4652        );
4653        assert_eq!(
4654            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4655            json!(rounds)
4656        );
4657        elapsed
4658    }
4659
4660    #[test]
4661    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4662        let elapsed = measure_scoped_overlay(100_000, 1);
4663        eprintln!(
4664            "table-scoped overlay with 100k unchanged rows: {} ns",
4665            elapsed[0]
4666        );
4667    }
4668
4669    #[test]
4670    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4671    fn benchmark_scoped_overlay() {
4672        for rows in [0, 100_000, 200_000] {
4673            let mut elapsed = measure_scoped_overlay(rows, 30);
4674            elapsed.sort_unstable();
4675            eprintln!(
4676                "{}",
4677                json!({"unchangedRows": rows, "rounds": elapsed.len(),
4678                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4679                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4680            );
4681        }
4682    }
4683
4684    #[test]
4685    fn released_context_discards_in_flight_ack_and_server_rows() {
4686        for barrier in [
4687            "preflight",
4688            "purge",
4689            "rebootstrap",
4690            "subscription",
4691            "failed-purge-replay",
4692            "failed-purge-publish",
4693            "failed-rebootstrap",
4694        ] {
4695            let mut client = client();
4696            client.set_meta(LOG_EPOCH_KEY, "epoch");
4697            client
4698                .subscribe(
4699                    "tasks".into(),
4700                    "tasks".into(),
4701                    vec![("project_id".into(), vec!["p1".into()])],
4702                    None,
4703                )
4704                .unwrap();
4705            let commit = client
4706                .mutate(vec![Mutation::Upsert {
4707                    table: "tasks".into(),
4708                    values: Map::from_iter([
4709                        ("id".into(), json!("local")),
4710                        ("project_id".into(), json!("p1")),
4711                    ]),
4712                    base_version: None,
4713                }])
4714                .unwrap();
4715            let prepared = client.prepare_sync_round(false).unwrap();
4716            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4717            let payload = encode_row_json(
4718                &client.schema.tables[0],
4719                "server",
4720                &Map::from_iter([
4721                    ("id".into(), json!("server")),
4722                    ("project_id".into(), json!("p1")),
4723                ]),
4724                &client.encryption,
4725            )
4726            .unwrap();
4727            let completed = crate::CompletedSyncRound {
4728                prepared,
4729                exchange: crate::round::ExchangeResult::Reply {
4730                    transport_failed: false,
4731                    downloads: Default::default(),
4732                    response: Ok(Message {
4733                        wire_version: WIRE_VERSION,
4734                        msg_kind: MsgKind::Response,
4735                        frames: vec![
4736                            Frame::RespHeader {
4737                                required_schema_version: None,
4738                                latest_schema_version: None,
4739                                log_epoch: Some("epoch".into()),
4740                                reset_required: Some(false),
4741                            },
4742                            Frame::PushResult {
4743                                client_commit_id: commit.clone(),
4744                                status: PushStatus::Applied,
4745                                commit_seq: Some(1),
4746                                results: vec![OpResult::Applied { op_index: 0 }],
4747                            },
4748                            Frame::SubStart {
4749                                id: "tasks".into(),
4750                                status: SubStatus::Active,
4751                                reason_code: String::new(),
4752                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4753                                bootstrap: true,
4754                            },
4755                            Frame::Commit {
4756                                commit_seq: 1,
4757                                created_at_ms: 0,
4758                                actor_id: "actor".into(),
4759                                tables: vec!["tasks".into()],
4760                                changes: vec![ssp2::model::Change {
4761                                    table_index: 0,
4762                                    row_id: "server".into(),
4763                                    op: Op::Upsert,
4764                                    row_version: Some(1),
4765                                    scopes: vec![("project_id".into(), "p1".into())],
4766                                    row: Some(payload),
4767                                }],
4768                            },
4769                            Frame::SubEnd {
4770                                next_cursor: 1,
4771                                bootstrap_state: None,
4772                            },
4773                        ],
4774                    }),
4775                },
4776            };
4777            match barrier {
4778                "preflight" => client.begin_security_preflight(),
4779                "purge" => {
4780                    client
4781                        .purge_local_data(&LocalDataPurgeInput {
4782                            purge_id: "pending-round".into(),
4783                            targets: vec![LocalDataPurgeTarget {
4784                                table: "tasks".into(),
4785                                selectors: BTreeMap::from([(
4786                                    "project_id".into(),
4787                                    vec!["p1".into()],
4788                                )]),
4789                            }],
4790                        })
4791                        .unwrap();
4792                }
4793                "rebootstrap" => {
4794                    client
4795                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
4796                            rebootstrap_id: "pending-round".into(),
4797                        })
4798                        .unwrap();
4799                }
4800                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4801                    let trigger = if barrier == "failed-purge-replay" {
4802                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4803                    } else {
4804                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4805                    };
4806                    client.conn.execute_batch(trigger).unwrap();
4807                    let revision = client.local_revision();
4808                    if barrier == "failed-rebootstrap" {
4809                        assert!(client
4810                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
4811                                rebootstrap_id: "failed-round".into(),
4812                            })
4813                            .is_err());
4814                    } else {
4815                        assert!(client
4816                            .purge_local_data(&LocalDataPurgeInput {
4817                                purge_id: "failed-round".into(),
4818                                targets: vec![LocalDataPurgeTarget {
4819                                    table: "tasks".into(),
4820                                    selectors: BTreeMap::from([(
4821                                        "project_id".into(),
4822                                        vec!["p1".into()]
4823                                    )]),
4824                                }],
4825                            })
4826                            .is_err());
4827                    }
4828                    assert_eq!(client.local_revision(), revision);
4829                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4830                    client
4831                        .conn
4832                        .execute_batch("DROP TRIGGER fail_local")
4833                        .unwrap();
4834                    let applied = client.apply_sync_round(completed);
4835                    assert!(
4836                        matches!(
4837                            applied,
4838                            crate::AppliedSyncRound::Complete {
4839                                outcome: SyncOutcome::Ok(_),
4840                                ..
4841                            }
4842                        ),
4843                        "{barrier}"
4844                    );
4845                    assert!(client.pending_commit_ids().is_empty());
4846                    assert_eq!(
4847                        client
4848                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4849                            .unwrap()
4850                            .len(),
4851                        1
4852                    );
4853                    continue;
4854                }
4855                "subscription" => {
4856                    client.unsubscribe("tasks");
4857                    client
4858                        .subscribe(
4859                            "tasks".into(),
4860                            "tasks".into(),
4861                            vec![("project_id".into(), vec!["p2".into()])],
4862                            None,
4863                        )
4864                        .unwrap();
4865                }
4866                _ => unreachable!(),
4867            }
4868            let pending_before_reply = client.pending_commit_ids();
4869            let revision = client.local_revision();
4870            let applied = client.apply_sync_round(completed);
4871            assert!(
4872                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4873            );
4874            assert_eq!(client.local_revision(), revision);
4875            assert!(client
4876                .conn
4877                .query_row(
4878                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4879                    [],
4880                    |r| r.get::<_, bool>(0)
4881                )
4882                .unwrap());
4883            assert_eq!(client.pending_commit_ids(), pending_before_reply);
4884        }
4885    }
4886
4887    #[test]
4888    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4889        struct DeadlineRecording {
4890            budget: std::time::Duration,
4891            seen: Vec<Option<std::time::Instant>>,
4892        }
4893        impl Transport for DeadlineRecording {
4894            fn round_deadline(&self) -> Option<std::time::Duration> {
4895                Some(self.budget)
4896            }
4897            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4898                self.seen.push(deadline);
4899            }
4900            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4901                Err(TransportError::new("transport.failed", "injected"))
4902            }
4903            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4904                self.sync(request)
4905            }
4906            fn download_segment(
4907                &mut self,
4908                _: &SegmentRequest,
4909                _: &mut dyn FnMut(u64),
4910            ) -> Result<Vec<u8>, TransportError> {
4911                Err(TransportError::new("transport.failed", "injected"))
4912            }
4913            fn blob_upload_grant(
4914                &mut self,
4915                _: &str,
4916                _: u64,
4917                _: Option<&str>,
4918            ) -> Result<crate::BlobUploadGrant, TransportError> {
4919                Ok(crate::BlobUploadGrant::Present)
4920            }
4921            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4922                Ok(())
4923            }
4924            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4925                Ok(())
4926            }
4927            fn realtime_close(&mut self) -> Result<(), TransportError> {
4928                Ok(())
4929            }
4930        }
4931
4932        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4933            {"name":"id","type":"string","nullable":false},
4934            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4935        let mut client =
4936            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4937        client.set_meta(LOG_EPOCH_KEY, "epoch");
4938        client
4939            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4940            .unwrap();
4941
4942        let mut transport = DeadlineRecording {
4943            budget: std::time::Duration::from_secs(30),
4944            seen: Vec::new(),
4945        };
4946        let prepared = client.prepare_sync_round(false).unwrap();
4947        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4948        // Upload exchange -> Continue (the pin removal is persisted by apply).
4949        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4950            crate::AppliedSyncRound::Continue(prepared) => prepared,
4951            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4952        };
4953        // Main request exchange -> Complete.
4954        let completed = prepared.exchange(&mut transport);
4955        let anchor = completed
4956            .prepared
4957            .round_deadline_at
4958            .expect("the upload round anchors a deadline");
4959        assert!(matches!(
4960            client.apply_sync_round(completed),
4961            crate::AppliedSyncRound::Complete { .. }
4962        ));
4963        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4964        assert_eq!(
4965            transport.seen[0],
4966            Some(anchor),
4967            "the upload anchors the round"
4968        );
4969        assert_eq!(
4970            transport.seen[1], None,
4971            "the upload exchange clears its scope"
4972        );
4973        assert_eq!(
4974            transport.seen[2],
4975            Some(anchor),
4976            "a continuation reuses the carried anchor instead of refreshing it"
4977        );
4978        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4979
4980        // A fresh round anchors a new deadline from the transport's (now larger)
4981        // budget and never inherits the old one; the clock granule is irrelevant.
4982        transport.budget = std::time::Duration::from_secs(60);
4983        let prepared = client.prepare_sync_round(false).unwrap();
4984        let completed = prepared.exchange(&mut transport);
4985        let fresh = completed
4986            .prepared
4987            .round_deadline_at
4988            .expect("the next round anchors a deadline");
4989        client.apply_sync_round(completed);
4990        assert!(
4991            fresh >= anchor + std::time::Duration::from_secs(30),
4992            "the next round anchors a fresh deadline from the new budget"
4993        );
4994        assert_eq!(transport.seen[4], Some(fresh));
4995        assert_eq!(
4996            transport.seen[5], None,
4997            "the new round clears its scope too"
4998        );
4999    }
5000
5001    /// Records the round-deadline traffic so a test can prove whether a round
5002    /// asked for a budget and whether it ever scoped an anchor.
5003    #[derive(Default)]
5004    struct RoundDeadlineProbe {
5005        budget: Option<std::time::Duration>,
5006        budget_queries: std::cell::Cell<usize>,
5007        scoped: Vec<Option<std::time::Instant>>,
5008    }
5009
5010    impl Transport for RoundDeadlineProbe {
5011        fn round_deadline(&self) -> Option<std::time::Duration> {
5012            self.budget_queries.set(self.budget_queries.get() + 1);
5013            self.budget
5014        }
5015        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
5016            self.scoped.push(deadline);
5017        }
5018        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5019            Err(TransportError::new("transport.failed", "injected"))
5020        }
5021        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5022            self.sync(request)
5023        }
5024        fn download_segment(
5025            &mut self,
5026            _: &SegmentRequest,
5027            _: &mut dyn FnMut(u64),
5028        ) -> Result<Vec<u8>, TransportError> {
5029            Err(TransportError::new("transport.failed", "injected"))
5030        }
5031        fn blob_upload_grant(
5032            &mut self,
5033            _: &str,
5034            _: u64,
5035            _: Option<&str>,
5036        ) -> Result<crate::BlobUploadGrant, TransportError> {
5037            Ok(crate::BlobUploadGrant::Present)
5038        }
5039        fn realtime_connect(&mut self) -> Result<(), TransportError> {
5040            Ok(())
5041        }
5042        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5043            Ok(())
5044        }
5045        fn realtime_close(&mut self) -> Result<(), TransportError> {
5046            Ok(())
5047        }
5048    }
5049
5050    #[test]
5051    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
5052        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5053            {"name":"id","type":"string","nullable":false},
5054            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5055        let mut client =
5056            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
5057        client.set_meta(LOG_EPOCH_KEY, "epoch");
5058        client
5059            .upload_blob(b"pending upload", Some("text/plain".into()), None)
5060            .unwrap();
5061        client
5062            .conn
5063            .execute_batch("DELETE FROM _syncular_blobs")
5064            .unwrap();
5065        let prepared = client.prepare_sync_round(false).unwrap();
5066        assert!(
5067            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
5068            "the staged body must fail locally"
5069        );
5070        let mut transport = RoundDeadlineProbe {
5071            budget: Some(std::time::Duration::from_secs(30)),
5072            ..Default::default()
5073        };
5074        let completed = prepared.exchange(&mut transport);
5075        assert!(matches!(
5076            completed.exchange,
5077            crate::round::ExchangeResult::Upload { result: Err(_), .. }
5078        ));
5079        assert_eq!(
5080            transport.budget_queries.get(),
5081            0,
5082            "a round with no network work never asks for a budget"
5083        );
5084        assert_eq!(transport.scoped, vec![None]);
5085    }
5086
5087    #[test]
5088    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
5089        struct HugeBudget;
5090        impl Transport for HugeBudget {
5091            fn round_deadline(&self) -> Option<std::time::Duration> {
5092                Some(std::time::Duration::MAX)
5093            }
5094            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5095                panic!("no network for an invalid budget")
5096            }
5097            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5098                self.sync(request)
5099            }
5100            fn download_segment(
5101                &mut self,
5102                _: &SegmentRequest,
5103                _: &mut dyn FnMut(u64),
5104            ) -> Result<Vec<u8>, TransportError> {
5105                panic!("no network for an invalid budget")
5106            }
5107            fn realtime_connect(&mut self) -> Result<(), TransportError> {
5108                Ok(())
5109            }
5110            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5111                Ok(())
5112            }
5113            fn realtime_close(&mut self) -> Result<(), TransportError> {
5114                Ok(())
5115            }
5116        }
5117
5118        let mut client = client();
5119        client.set_meta(LOG_EPOCH_KEY, "epoch");
5120        let prepared = client.prepare_sync_round(false).unwrap();
5121        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5122            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5123            crate::AppliedSyncRound::Continue(_) => {
5124                panic!("an unrepresentable budget must not continue")
5125            }
5126        };
5127        match outcome {
5128            SyncOutcome::Failed {
5129                error_code,
5130                message,
5131                ..
5132            } => {
5133                assert_eq!(error_code, "sync.invalid_request");
5134                assert_eq!(message, "transport round deadline is out of range");
5135            }
5136            _ => panic!("an unrepresentable budget must fail synchronously"),
5137        }
5138    }
5139
5140    #[test]
5141    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5142        for failure in ["visible", "revision", "commit"] {
5143            let mut client = client();
5144            let id = client
5145                .mutate(vec![Mutation::Upsert {
5146                    table: "tasks".into(),
5147                    values: Map::from_iter([
5148                        ("id".into(), Value::from("t1")),
5149                        ("project_id".into(), Value::from("pending")),
5150                    ]),
5151                    base_version: None,
5152                }])
5153                .unwrap();
5154            client.drain_change_batches();
5155            let revision = client.local_revision();
5156            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5157            let trigger = match failure {
5158                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5159                "revision" => format!("CREATE TRIGGER fail_pending BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(ABORT, 'injected revision failure'); END"),
5160                _ => "PRAGMA foreign_keys = ON; CREATE TABLE pending_parent(id INTEGER PRIMARY KEY); CREATE TABLE pending_child(id INTEGER REFERENCES pending_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_pending AFTER INSERT ON _syncular_base_tasks BEGIN INSERT INTO pending_child VALUES(1); END".to_owned(),
5161            };
5162            client.conn.execute_batch(&trigger).unwrap();
5163            let change = ssp2::model::Change {
5164                table_index: 0,
5165                row_id: "t1".into(),
5166                op: Op::Upsert,
5167                row_version: Some(2),
5168                scopes: vec![],
5169                row: Some(
5170                    encode_row_json(
5171                        client.schema.table("tasks").unwrap(),
5172                        "t1",
5173                        &Map::from_iter([
5174                            ("id".into(), Value::from("t1")),
5175                            ("project_id".into(), Value::from("remote")),
5176                        ]),
5177                        &client.encryption,
5178                    )
5179                    .unwrap(),
5180                ),
5181            };
5182            assert!(client
5183                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5184                .is_err());
5185            assert!(client.conn.is_autocommit());
5186            assert!(!client.overlay_dirty.get());
5187            assert_eq!(client.local_revision(), revision);
5188            assert!(client.drain_change_batches().is_empty());
5189            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5190            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5191            assert!(client
5192                .query("SELECT * FROM _syncular_base_tasks", &[])
5193                .unwrap()
5194                .is_empty());
5195            client
5196                .conn
5197                .execute_batch("DROP TRIGGER fail_pending")
5198                .unwrap();
5199            client
5200                .apply_commit_frame(&["tasks".into()], &[change], None)
5201                .unwrap();
5202            assert_eq!(client.local_revision(), revision + 1);
5203            assert_eq!(client.pending_commit_ids(), vec![id]);
5204            // The overlay keeps the pending write visible; the row now carries
5205            // the server row's version it is layered on (§7.1).
5206            let mut expected = before[0].clone();
5207            expected.insert("_syncular_version".to_owned(), Value::from(2));
5208            assert_eq!(
5209                client.query("SELECT * FROM tasks", &[]).unwrap(),
5210                vec![expected]
5211            );
5212            assert_eq!(
5213                client
5214                    .conn
5215                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5216                        .get::<_, String>(0))
5217                    .unwrap(),
5218                "remote"
5219            );
5220        }
5221    }
5222
5223    #[test]
5224    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5225        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5226            "columns": [
5227                {"name":"id", "type":"string", "nullable":false},
5228                {"name":"project_id", "type":"string", "nullable":false},
5229                {"name":"title", "type":"string", "nullable":false}],
5230            "scopes": [{"pattern":"project:{project_id}"}],
5231            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5232            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5233        let mut incremental =
5234            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5235        let mut reference =
5236            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5237        for instance in [&mut incremental, &mut reference] {
5238            for index in 0..16 {
5239                instance
5240                    .write_base_row(
5241                        "tasks",
5242                        &vec![
5243                            Some(ColumnValue::String(format!("task{index}"))),
5244                            Some(ColumnValue::String("p0".to_owned())),
5245                            Some(ColumnValue::String(format!("base{index}"))),
5246                        ],
5247                        1,
5248                    )
5249                    .unwrap();
5250            }
5251            instance
5252                .rebuild_overlay_if_dirty()
5253                .expect("rebuild overlay");
5254            instance.overlay_rebuild_count.set(0);
5255        }
5256        for index in 0..100 {
5257            let mutations = (0..1 + index % 4)
5258                .map(|offset| {
5259                    let id = format!("task{}", (index + offset) % 24);
5260                    if (index + offset) % 7 == 0 {
5261                        Mutation::Delete {
5262                            table: "tasks".to_owned(),
5263                            row_id: id,
5264                            base_version: None,
5265                        }
5266                    } else {
5267                        Mutation::Upsert {
5268                            table: "tasks".to_owned(),
5269                            values: Map::from_iter([
5270                                ("id".to_owned(), Value::from(id)),
5271                                (
5272                                    "project_id".to_owned(),
5273                                    Value::from(format!("p{}", index % 2)),
5274                                ),
5275                                (
5276                                    "title".to_owned(),
5277                                    Value::from(format!("needle{}", (index + offset) % 24)),
5278                                ),
5279                            ]),
5280                            base_version: None,
5281                        }
5282                    }
5283                })
5284                .collect::<Vec<_>>();
5285            reference.overlay_dirty.set(true);
5286            incremental.mutate(mutations.clone()).unwrap();
5287            reference.mutate(mutations).unwrap();
5288            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5289                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5290                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5291            }
5292            assert_eq!(incremental.local_revision(), reference.local_revision());
5293            assert_eq!(
5294                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5295                serde_json::to_value(reference.drain_change_batches()).unwrap()
5296            );
5297        }
5298        assert_eq!(incremental.outbox.len(), 100);
5299        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5300        assert_eq!(reference.overlay_rebuild_count.get(), 100);
5301    }
5302
5303    #[test]
5304    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5305        for fail_at in ["outbox", "revision"] {
5306            let mut client = client();
5307            let values = Map::from_iter([
5308                ("id".to_owned(), Value::from("t1")),
5309                ("project_id".to_owned(), Value::from("p1")),
5310            ]);
5311            let mutation = Mutation::Upsert {
5312                table: "tasks".to_owned(),
5313                values,
5314                base_version: None,
5315            };
5316            let trigger = if fail_at == "outbox" {
5317                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5318            } else {
5319                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5320            };
5321            client.conn.execute_batch(&trigger).unwrap();
5322            let revision = client.local_revision();
5323            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5324            assert_eq!(client.local_revision(), revision);
5325            assert!(client.outbox.is_empty());
5326            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5327            assert_eq!(
5328                client
5329                    .conn
5330                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5331                        .get::<_, i64>(0))
5332                    .unwrap(),
5333                0
5334            );
5335            assert!(client.drain_change_batches().is_empty());
5336            assert!(!client.overlay_dirty.get());
5337            client
5338                .conn
5339                .execute_batch("DROP TRIGGER fail_append")
5340                .unwrap();
5341            client.mutate(vec![mutation]).unwrap();
5342            assert_eq!(client.outbox.len(), 1);
5343            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5344        }
5345    }
5346
5347    #[test]
5348    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5349        let mut client = client();
5350        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5351        let values = Map::from_iter([
5352            ("id".to_owned(), Value::from("t1")),
5353            ("project_id".to_owned(), Value::from("p1")),
5354        ]);
5355        let payload = encode_row_json(
5356            client.schema.table("tasks").unwrap(),
5357            "t1",
5358            &values,
5359            &client.encryption,
5360        )
5361        .unwrap();
5362        let first = ssp2::model::Change {
5363            table_index: 0,
5364            row_id: "t1".to_owned(),
5365            op: Op::Upsert,
5366            row_version: Some(1),
5367            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5368            row: Some(payload),
5369        };
5370        let mut invalid = first.clone();
5371        invalid.table_index = 1;
5372        let revision = client.local_revision();
5373        client.begin_observation("failed_mirror").unwrap();
5374        assert!(client
5375            .apply_commit_changes(&tables, &[first, invalid])
5376            .is_err());
5377        client.rollback_observation("failed_mirror");
5378        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5379        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5380        assert_eq!(
5381            client
5382                .conn
5383                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5384                    .get::<_, i64>(
5385                    0
5386                ))
5387                .unwrap(),
5388            0
5389        );
5390        assert_eq!(client.local_revision(), revision);
5391        assert!(client.drain_change_batches().is_empty());
5392    }
5393
5394    #[test]
5395    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5396        for failure in ["journal", "revision", "commit", "later-frame"] {
5397            let mut client = client();
5398            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5399            for id in ["first", "second"] {
5400                client
5401                    .mutate(vec![Mutation::Upsert {
5402                        table: "tasks".to_owned(),
5403                        values: Map::from_iter([
5404                            ("id".to_owned(), Value::from(id)),
5405                            ("project_id".to_owned(), Value::from("p1")),
5406                        ]),
5407                        base_version: None,
5408                    }])
5409                    .unwrap();
5410            }
5411            let (_, meta) = client.build_request(false).unwrap();
5412            // A malicious or stale response must not drain an unsent local commit.
5413            client
5414                .mutate(vec![Mutation::Upsert {
5415                    table: "tasks".to_owned(),
5416                    values: Map::from_iter([
5417                        ("id".to_owned(), Value::from("later")),
5418                        ("project_id".to_owned(), Value::from("p1")),
5419                    ]),
5420                    base_version: None,
5421                }])
5422                .unwrap();
5423            let ids = client.pending_commit_ids();
5424            let rows = client
5425                .query("SELECT * FROM tasks ORDER BY id", &[])
5426                .unwrap();
5427            let revision = client.local_revision();
5428            client.drain_change_batches();
5429            if failure != "later-frame" {
5430                client.conn.execute_batch(match failure {
5431                    "journal" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 1 BEGIN SELECT RAISE(FAIL, 'injected second journal failure'); END",
5432                    "revision" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN SELECT RAISE(FAIL, 'injected run revision failure'); END",
5433                    _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_run AFTER INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN INSERT INTO ack_child VALUES (1); END",
5434                }).unwrap();
5435            }
5436            let mut response = Message {
5437                wire_version: WIRE_VERSION,
5438                msg_kind: MsgKind::Response,
5439                frames: vec![Frame::RespHeader {
5440                    required_schema_version: None,
5441                    latest_schema_version: None,
5442                    log_epoch: Some("epoch-1".to_owned()),
5443                    reset_required: Some(false),
5444                }],
5445            };
5446            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5447                response.frames.push(Frame::PushResult {
5448                    client_commit_id: id.to_owned(),
5449                    status: PushStatus::Applied,
5450                    commit_seq: Some(1),
5451                    results: vec![OpResult::Applied { op_index: 0 }],
5452                });
5453            }
5454            if failure == "later-frame" {
5455                response.frames.push(Frame::Error {
5456                    code: "sync.invalid_request".to_owned(),
5457                    message: "later error".to_owned(),
5458                    category: "protocol".to_owned(),
5459                    retryable: false,
5460                    recommended_action: "retry".to_owned(),
5461                    details: None,
5462                });
5463            }
5464            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5465            let outcome = client.process_response(&mut transport, response.clone(), &meta);
5466            // §1.4 rule 4: the deferred foreign key of "commit" fails the
5467            // response's coalesced COMMIT instead of the run's RELEASE.
5468            let expected = match failure {
5469                "later-frame" => "sync.invalid_request",
5470                "commit" => "storage.failed",
5471                _ => "client.outcome_persistence_failed",
5472            };
5473            assert!(
5474                matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5475                "{failure}: {outcome:?}"
5476            );
5477            if failure == "later-frame" {
5478                assert_eq!(client.pending_commit_ids(), ids[2..]);
5479                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5480                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5481                let batches = client.drain_change_batches();
5482                assert_eq!(batches.len(), 1);
5483                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5484                assert_eq!(client.local_revision(), revision + 1);
5485                response.frames.pop();
5486            } else {
5487                assert_eq!(client.pending_commit_ids(), ids);
5488                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5489                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5490                assert_eq!(client.local_revision(), revision);
5491                assert!(client.drain_change_batches().is_empty());
5492                assert_eq!(
5493                    client
5494                        .query("SELECT * FROM tasks ORDER BY id", &[])
5495                        .unwrap(),
5496                    rows
5497                );
5498                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5499            }
5500            for frame in &mut response.frames {
5501                if let Frame::PushResult { status, .. } = frame {
5502                    *status = PushStatus::Cached;
5503                }
5504            }
5505            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5506            else {
5507                panic!("retry failed");
5508            };
5509            assert_eq!(
5510                report.applied,
5511                if failure == "later-frame" {
5512                    vec![]
5513                } else {
5514                    ids[..2].to_vec()
5515                }
5516            );
5517            assert_eq!(client.pending_commit_ids(), ids[2..]);
5518            let batches = client.drain_change_batches();
5519            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5520            if let Some(batch) = batches.first() {
5521                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5522            }
5523            assert_eq!(client.local_revision(), revision + 1);
5524        }
5525    }
5526
5527    #[test]
5528    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5529        for failure in ["revision", "journal", "commit"] {
5530            for status in ["applied", "cached", "rejected", "conflict"] {
5531                let mut client = client();
5532                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5533                for id in ["first", "later"] {
5534                    client
5535                        .mutate(vec![Mutation::Upsert {
5536                            table: "tasks".to_owned(),
5537                            values: Map::from_iter([
5538                                ("id".to_owned(), Value::from(id)),
5539                                ("project_id".to_owned(), Value::from("p1")),
5540                            ]),
5541                            base_version: None,
5542                        }])
5543                        .unwrap();
5544                }
5545                let (_, meta) = client.build_request(false).unwrap();
5546                let ids = client.pending_commit_ids();
5547                let rows = client
5548                    .query("SELECT * FROM tasks ORDER BY id", &[])
5549                    .unwrap();
5550                let revision = client.local_revision();
5551                client.drain_change_batches();
5552                let (push_status, results) = match status {
5553                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5554                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5555                    "conflict" => (
5556                        PushStatus::Rejected,
5557                        vec![OpResult::Conflict {
5558                            op_index: 0,
5559                            code: "sync.version_conflict".to_owned(),
5560                            message: "conflict".to_owned(),
5561                            server_version: 1,
5562                            server_row: encode_row_json(
5563                                client.schema.table("tasks").unwrap(),
5564                                "first",
5565                                &Map::from_iter([
5566                                    ("id".to_owned(), Value::from("first")),
5567                                    ("project_id".to_owned(), Value::from("p1")),
5568                                ]),
5569                                &client.encryption,
5570                            )
5571                            .unwrap(),
5572                            conflict_columns: Vec::new(),
5573                        }],
5574                    ),
5575                    _ => (
5576                        PushStatus::Rejected,
5577                        vec![OpResult::Error {
5578                            op_index: 0,
5579                            code: "sync.validation_failed".to_owned(),
5580                            message: "rejected".to_owned(),
5581                            retryable: false,
5582                        }],
5583                    ),
5584                };
5585                let response = Message {
5586                    wire_version: WIRE_VERSION,
5587                    msg_kind: MsgKind::Response,
5588                    frames: vec![
5589                        Frame::RespHeader {
5590                            required_schema_version: None,
5591                            latest_schema_version: None,
5592                            log_epoch: Some("epoch-1".to_owned()),
5593                            reset_required: Some(false),
5594                        },
5595                        Frame::PushResult {
5596                            client_commit_id: ids[0].clone(),
5597                            status: push_status,
5598                            commit_seq: Some(1),
5599                            results,
5600                        },
5601                    ],
5602                };
5603                client.conn.execute_batch(match failure {
5604                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5605                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5606                    _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_ack AFTER INSERT ON _syncular_commit_outcomes BEGIN INSERT INTO ack_child VALUES (1); END",
5607                }).unwrap();
5608                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5609                let outcome = client.process_response(&mut transport, response.clone(), &meta);
5610                assert_eq!(
5611                    client.pending_commit_ids(),
5612                    ids,
5613                    "{failure}/{status}: pending identity"
5614                );
5615                assert_eq!(
5616                    client.local_revision(),
5617                    revision,
5618                    "{failure}/{status}: revision"
5619                );
5620                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5621                assert!(client.conflicts().is_empty());
5622                assert!(client.rejections().is_empty());
5623                assert!(client.drain_change_batches().is_empty());
5624                assert_eq!(
5625                    client
5626                        .query("SELECT * FROM tasks ORDER BY id", &[])
5627                        .unwrap(),
5628                    rows
5629                );
5630                // §1.4 rule 4: the deferred foreign key of "commit" fails the
5631                // response's coalesced COMMIT instead of the result's RELEASE.
5632                let expected = if failure == "commit" {
5633                    "storage.failed"
5634                } else {
5635                    "client.outcome_persistence_failed"
5636                };
5637                assert!(
5638                    matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5639                    "{failure}/{status}: failed round"
5640                );
5641                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5642                assert!(matches!(
5643                    client.process_response(&mut transport, response, &meta),
5644                    SyncOutcome::Ok(_)
5645                ));
5646                assert_eq!(client.pending_commit_ids(), ids[1..]);
5647                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5648                assert_eq!(client.drain_change_batches().len(), 1);
5649            }
5650        }
5651    }
5652
5653    #[test]
5654    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5655        let mut client = client();
5656        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5657        for index in 0..4 {
5658            client
5659                .mutate(vec![Mutation::Upsert {
5660                    table: "tasks".to_owned(),
5661                    values: Map::from_iter([
5662                        ("id".to_owned(), Value::from(format!("task-{index}"))),
5663                        ("project_id".to_owned(), Value::from("project-1")),
5664                    ]),
5665                    base_version: None,
5666                }])
5667                .expect("queue commit");
5668        }
5669
5670        let (_, request_meta) = client.build_request(false).unwrap();
5671        let ids = &request_meta.pushed_ids;
5672        assert_eq!(ids.len(), 4);
5673        let response = Message {
5674            wire_version: WIRE_VERSION,
5675            msg_kind: MsgKind::Response,
5676            frames: vec![
5677                Frame::RespHeader {
5678                    required_schema_version: None,
5679                    latest_schema_version: None,
5680                    log_epoch: Some("epoch-1".to_owned()),
5681                    reset_required: Some(false),
5682                },
5683                Frame::PushResult {
5684                    client_commit_id: ids[0].clone(),
5685                    status: PushStatus::Applied,
5686                    commit_seq: Some(1),
5687                    results: vec![OpResult::Applied { op_index: 0 }],
5688                },
5689                Frame::PushResult {
5690                    client_commit_id: ids[1].clone(),
5691                    status: PushStatus::Cached,
5692                    commit_seq: Some(2),
5693                    results: vec![OpResult::Applied { op_index: 0 }],
5694                },
5695                Frame::PushResult {
5696                    client_commit_id: ids[2].clone(),
5697                    status: PushStatus::Rejected,
5698                    commit_seq: None,
5699                    results: vec![OpResult::Error {
5700                        op_index: 0,
5701                        code: "sync.validation_failed".to_owned(),
5702                        message: "rejected".to_owned(),
5703                        retryable: false,
5704                    }],
5705                },
5706                Frame::PushResult {
5707                    client_commit_id: ids[3].clone(),
5708                    status: PushStatus::Rejected,
5709                    commit_seq: None,
5710                    results: vec![OpResult::Error {
5711                        op_index: 0,
5712                        code: "sync.idempotency_cache_miss".to_owned(),
5713                        message: "retry".to_owned(),
5714                        retryable: true,
5715                    }],
5716                },
5717            ],
5718        };
5719        let mut transport =
5720            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5721
5722        client.overlay_rebuild_count.set(0);
5723        client.outcome_prune_count.set(0);
5724        let outcome = client.process_response(&mut transport, response, &request_meta);
5725        let SyncOutcome::Ok(report) = outcome else {
5726            panic!("mixed push-result response failed");
5727        };
5728
5729        assert_eq!(report.applied, ids[..2]);
5730        assert_eq!(report.rejected, ids[2..3]);
5731        assert_eq!(report.retryable, ids[3..4]);
5732        assert_eq!(client.pending_commit_ids(), ids[3..4]);
5733        assert_eq!(
5734            client
5735                .query("SELECT id FROM tasks ORDER BY id", &[])
5736                .expect("query visible overlay"),
5737            ["task-0", "task-1", "task-3"]
5738                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5739                .to_vec()
5740        );
5741        // The successful ACK run and final rejection have distinct durable boundaries.
5742        assert_eq!(client.overlay_rebuild_count.get(), 2);
5743        assert_eq!(client.outcome_prune_count.get(), 1);
5744    }
5745
5746    fn replay_schema(fts: bool) -> Value {
5747        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5748            {"name":"id","type":"string","nullable":false},
5749            {"name":"project_id","type":"string","nullable":false},
5750            {"name":"title","type":"string","nullable":false},
5751            {"name":"done","type":"boolean","nullable":false}
5752        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5753    }
5754
5755    #[test]
5756    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5757        for schema_reset in [false, true] {
5758            let mut schema = replay_schema(false);
5759            let mut client =
5760                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5761            let id = client
5762                .mutate(vec![Mutation::Upsert {
5763                    table: "tasks".into(),
5764                    values: Map::from_iter([
5765                        ("id".into(), json!("queued")),
5766                        ("project_id".into(), json!("p1")),
5767                        ("title".into(), json!("old")),
5768                        ("done".into(), json!(false)),
5769                    ]),
5770                    base_version: None,
5771                }])
5772                .unwrap();
5773            client.set_meta(LOG_EPOCH_KEY, "old");
5774            let revision = client.local_revision();
5775            let needed = client.sync_needed;
5776            client.drain_change_batches();
5777            client.drain_sync_intents();
5778            client.conn.execute_batch("CREATE TRIGGER refuse_revision BEFORE INSERT ON _syncular_meta WHEN NEW.key='localRevision' BEGIN SELECT RAISE(ABORT,'reset completion failed'); END;").unwrap();
5779            if schema_reset {
5780                schema["version"] = json!(2);
5781                schema["tables"][0]["columns"]
5782                    .as_array_mut()
5783                    .unwrap()
5784                    .retain(|column| column["name"] != "title");
5785                client.schema = parse_schema_json(&schema).unwrap();
5786                assert!(client.run_schema_reset().is_err());
5787            } else {
5788                assert!(client.run_log_epoch_reset("new").is_err());
5789            }
5790            assert!(!client.upgrading);
5791            assert_eq!(client.sync_needed, needed);
5792            assert_eq!(client.local_revision(), revision);
5793            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5794            assert!(client.rejections.is_empty());
5795            assert!(client.commit_outcome(&id).unwrap().is_none());
5796            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5797            assert_eq!(
5798                client
5799                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
5800                    .unwrap()[0]["title"],
5801                "old"
5802            );
5803            assert_eq!(
5804                client
5805                    .conn
5806                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5807                        .get::<_, i64>(0))
5808                    .unwrap(),
5809                1
5810            );
5811            assert!(client.drain_change_batches().is_empty());
5812            assert!(client.drain_sync_intents().is_empty());
5813            assert!(client.conn.is_autocommit());
5814        }
5815    }
5816
5817    #[test]
5818    fn cached_blob_cleanup_propagates_delete_failures() {
5819        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5820            {"name":"id","type":"string","nullable":false},
5821            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5822        let mut client =
5823            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5824        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5825        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5826        client
5827            .subscribe("attachments".into(), "attachments".into(), vec![], None)
5828            .unwrap();
5829        client.subs[0].effective = Some(vec![]);
5830        client.persist_sub(&client.subs[0]).unwrap();
5831        let revision = client.local_revision();
5832        let (_, meta) = client.build_request(false).unwrap();
5833        let mut transport = CountingRealtimeTransport::default();
5834        let mut report = SyncReport::default();
5835        assert!(client
5836            .process_section(
5837                &mut transport,
5838                "attachments",
5839                SubStatus::Revoked,
5840                "sync.scope_revoked",
5841                vec![],
5842                vec![],
5843                Some((0, None)),
5844                &meta,
5845                &mut report,
5846                &HashSet::new()
5847            )
5848            .is_err());
5849        assert_eq!(client.subs[0].state, SubState::Active);
5850        assert_eq!(client.local_revision(), revision);
5851        assert!(report.revoked.is_empty());
5852        assert!(client.conn.is_autocommit());
5853        client
5854            .conn
5855            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5856            .unwrap();
5857        assert!(client
5858            .process_section(
5859                &mut transport,
5860                "attachments",
5861                SubStatus::Revoked,
5862                "sync.scope_revoked",
5863                vec![],
5864                vec![],
5865                Some((0, None)),
5866                &meta,
5867                &mut report,
5868                &HashSet::new()
5869            )
5870            .is_ok());
5871        assert_eq!(client.subs[0].state, SubState::Revoked);
5872        assert_eq!(
5873            client
5874                .conn
5875                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5876                    .get::<_, i64>(0))
5877                .unwrap(),
5878            0
5879        );
5880    }
5881
5882    #[test]
5883    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5884        let path =
5885            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5886        let schema = replay_schema(false);
5887        let mut client = SyncClient::open_path(
5888            "replay".into(),
5889            &schema,
5890            ClientLimits::default(),
5891            path.to_str().unwrap(),
5892        )
5893        .unwrap();
5894        let ids = ["early", "bad"].map(|id| {
5895            client
5896                .mutate(vec![Mutation::Upsert {
5897                    table: "tasks".into(),
5898                    values: Map::from_iter([
5899                        ("id".into(), json!(id)),
5900                        ("project_id".into(), json!("p1")),
5901                        ("title".into(), json!(id)),
5902                        ("done".into(), json!(false)),
5903                    ]),
5904                    base_version: None,
5905                }])
5906                .unwrap()
5907        });
5908        client.conn.execute_batch("DELETE FROM tasks; CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.id='bad' BEGIN SELECT RAISE(ABORT,'replay failed'); END;").unwrap();
5909        let revision = client.local_revision();
5910        drop(client);
5911        let error = SyncClient::open_path(
5912            "replay".into(),
5913            &schema,
5914            ClientLimits::default(),
5915            path.to_str().unwrap(),
5916        )
5917        .err()
5918        .expect("reopen must fail");
5919        assert!(error.contains("replay failed"));
5920        let conn = Connection::open(&path).unwrap();
5921        assert_eq!(
5922            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5923                .unwrap(),
5924            0
5925        );
5926        assert_eq!(
5927            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5928                .get::<_, i64>(0))
5929                .unwrap(),
5930            2
5931        );
5932        assert_eq!(
5933            meta_get(&conn, LOCAL_REVISION_KEY),
5934            Some(revision.to_string())
5935        );
5936        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5937        drop(conn);
5938        let reopened = SyncClient::open_path(
5939            "replay".into(),
5940            &schema,
5941            ClientLimits::default(),
5942            path.to_str().unwrap(),
5943        )
5944        .unwrap();
5945        assert_eq!(reopened.pending_commit_ids(), ids);
5946        assert_eq!(
5947            reopened
5948                .query("SELECT id FROM tasks ORDER BY id", &[])
5949                .unwrap()
5950                .len(),
5951            2
5952        );
5953        drop(reopened);
5954        std::fs::remove_file(path).unwrap();
5955    }
5956
5957    #[test]
5958    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5959        let schema = replay_schema(false);
5960        let mut client =
5961            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5962        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5963        client
5964            .write_base_row(
5965                "tasks",
5966                &vec![
5967                    Some(ColumnValue::String("queued".into())),
5968                    Some(ColumnValue::String("p1".into())),
5969                    Some(ColumnValue::String("server".into())),
5970                    Some(ColumnValue::Boolean(false)),
5971                ],
5972                1,
5973            )
5974            .unwrap();
5975        client.rebuild_overlay().unwrap();
5976        let id = client
5977            .patch(
5978                "tasks",
5979                "queued",
5980                Map::from_iter([("title".into(), json!("local"))]),
5981                Some(1),
5982            )
5983            .unwrap();
5984        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5985        let base = client
5986            .query("SELECT * FROM _syncular_base_tasks", &[])
5987            .unwrap();
5988        let revision = client.local_revision();
5989        client.drain_change_batches();
5990        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5991        let remote = vec![
5992            Some(ColumnValue::String("queued".into())),
5993            Some(ColumnValue::String("p1".into())),
5994            Some(ColumnValue::String("remote".into())),
5995            Some(ColumnValue::Boolean(false)),
5996        ];
5997        let bytes = crate::values::encode_row_json(
5998            client.schema.table("tasks").unwrap(),
5999            "queued",
6000            &Map::from_iter(
6001                client
6002                    .schema
6003                    .table("tasks")
6004                    .unwrap()
6005                    .columns
6006                    .iter()
6007                    .zip(&remote)
6008                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
6009            ),
6010            &client.encryption,
6011        )
6012        .unwrap();
6013        let change = ssp2::model::Change {
6014            table_index: 0,
6015            op: Op::Upsert,
6016            row_id: "queued".into(),
6017            row_version: Some(2),
6018            scopes: vec![("project_id".into(), "p1".into())],
6019            row: Some(bytes),
6020        };
6021        assert!(client
6022            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
6023            .is_err());
6024        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6025        assert_eq!(
6026            client
6027                .query("SELECT * FROM _syncular_base_tasks", &[])
6028                .unwrap(),
6029            base
6030        );
6031        assert_eq!(client.local_revision(), revision);
6032        assert!(client.drain_change_batches().is_empty());
6033        let response = Message {
6034            wire_version: 2,
6035            msg_kind: MsgKind::Response,
6036            frames: vec![
6037                Frame::RespHeader {
6038                    required_schema_version: None,
6039                    latest_schema_version: None,
6040                    log_epoch: Some("epoch-1".into()),
6041                    reset_required: Some(false),
6042                },
6043                Frame::PushResult {
6044                    client_commit_id: id.clone(),
6045                    status: PushStatus::Applied,
6046                    commit_seq: Some(2),
6047                    results: vec![OpResult::Applied { op_index: 0 }],
6048                },
6049            ],
6050        };
6051        let (_, meta) = client.build_request(false).unwrap();
6052        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
6053        assert!(
6054            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
6055        );
6056        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6057        assert!(client.commit_outcome(&id).unwrap().is_none());
6058        assert_eq!(
6059            client
6060                .conn
6061                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
6062                    .get::<_, i64>(
6063                    0
6064                ))
6065                .unwrap(),
6066            0
6067        );
6068        assert_eq!(
6069            client
6070                .conn
6071                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
6072                    row.get::<_, i64>(0)
6073                })
6074                .unwrap(),
6075            0
6076        );
6077        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6078        assert_eq!(client.local_revision(), revision);
6079        assert!(client.drain_change_batches().is_empty());
6080        client
6081            .conn
6082            .execute_batch("DROP TRIGGER fail_replay")
6083            .unwrap();
6084        assert!(matches!(
6085            client.process_response(&mut transport, response, &meta),
6086            SyncOutcome::Ok(_)
6087        ));
6088        client
6089            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
6090            .unwrap();
6091        assert!(client.pending_commit_ids().is_empty());
6092        assert_eq!(
6093            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
6094            json!("remote")
6095        );
6096    }
6097
6098    #[test]
6099    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
6100        let client = SyncClient::new(
6101            "replay".into(),
6102            &replay_schema(false),
6103            ClientLimits::default(),
6104        )
6105        .unwrap();
6106        let table = client.schema.table("tasks").unwrap();
6107        let patch = OutboxOp {
6108            table: "tasks".into(),
6109            row_id: "absent".into(),
6110            upsert: true,
6111            insert: false,
6112            base_version: Some(1),
6113            values: Some(Map::from_iter([
6114                ("id".into(), json!("absent")),
6115                ("title".into(), json!("patch")),
6116            ])),
6117        };
6118        assert!(client.visible_row(table, "absent").unwrap().is_none());
6119        client
6120            .apply_outbox_ops([&patch])
6121            .expect("sparse replay over truly absent row");
6122        client
6123            .conn
6124            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6125            .unwrap();
6126        assert!(client
6127            .visible_row(table, "bad")
6128            .unwrap_err()
6129            .starts_with("sync.local_corrupt:"));
6130        let mut invalid = patch.clone();
6131        invalid.values = Some(Map::from_iter([
6132            ("id".into(), json!("bad-value")),
6133            ("project_id".into(), json!("p1")),
6134            ("title".into(), json!("title")),
6135            ("done".into(), json!({"bad":true})),
6136        ]));
6137        assert!(client
6138            .apply_outbox_ops([&invalid])
6139            .unwrap_err()
6140            .starts_with("sync.local_corrupt:"));
6141        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6142        assert!(
6143            client.apply_outbox_ops([&patch]).is_err(),
6144            "SQL failure must not count as absence"
6145        );
6146    }
6147
6148    #[test]
6149    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6150        let mut schema = replay_schema(false);
6151        schema["tables"][0]["indexes"] =
6152            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6153        let mut client =
6154            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6155        let id = client
6156            .mutate(vec![Mutation::Upsert {
6157                table: "tasks".into(),
6158                values: Map::from_iter([
6159                    ("id".into(), json!("queued")),
6160                    ("project_id".into(), json!("p1")),
6161                    ("title".into(), json!("occupied")),
6162                    ("done".into(), json!(false)),
6163                ]),
6164                base_version: None,
6165            }])
6166            .unwrap();
6167        client
6168            .write_base_row(
6169                "tasks",
6170                &vec![
6171                    Some(ColumnValue::String("server".into())),
6172                    Some(ColumnValue::String("p1".into())),
6173                    Some(ColumnValue::String("occupied".into())),
6174                    Some(ColumnValue::Boolean(false)),
6175                ],
6176                1,
6177            )
6178            .unwrap();
6179        client.rebuild_overlay_if_dirty().unwrap();
6180        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6181        assert!(client
6182            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6183            .unwrap()
6184            .is_none());
6185        assert!(client.rejections().is_empty());
6186        client.delete_base_row("tasks", "server").unwrap();
6187        client.rebuild_overlay_if_dirty().unwrap();
6188        assert!(client
6189            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6190            .unwrap()
6191            .is_some());
6192        client
6193            .write_base_row(
6194                "tasks",
6195                &vec![
6196                    Some(ColumnValue::String("base".into())),
6197                    Some(ColumnValue::String("p1".into())),
6198                    Some(ColumnValue::String("base-title".into())),
6199                    Some(ColumnValue::Boolean(false)),
6200                ],
6201                1,
6202            )
6203            .unwrap();
6204        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6205        client
6206            .retain_failed_commit(
6207                "retained",
6208                &[OutboxOp {
6209                    table: "tasks".into(),
6210                    row_id: "base".into(),
6211                    upsert: true,
6212                    insert: false,
6213                    base_version: Some(1),
6214                    values: Some(Map::from_iter([
6215                        ("id".into(), json!("base")),
6216                        ("title".into(), json!("patch")),
6217                    ])),
6218                }],
6219            )
6220            .expect("an existing base must not read the unavailable visible table");
6221    }
6222
6223    #[test]
6224    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6225        for failure in ["fts", "release"] {
6226            let mut client = SyncClient::new(
6227                "replay".into(),
6228                &replay_schema(true),
6229                ClientLimits::default(),
6230            )
6231            .unwrap();
6232            client
6233                .mutate(vec![Mutation::Upsert {
6234                    table: "tasks".into(),
6235                    values: Map::from_iter([
6236                        ("id".into(), json!("queued")),
6237                        ("project_id".into(), json!("p1")),
6238                        ("title".into(), json!("needle")),
6239                        ("done".into(), json!(false)),
6240                    ]),
6241                    base_version: None,
6242                }])
6243                .unwrap();
6244            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6245            let fts_before = client
6246                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6247                .unwrap();
6248            let triggers_before: Vec<String> = client
6249                .conn
6250                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6251                .unwrap()
6252                .query_map([], |row| row.get(0))
6253                .unwrap()
6254                .collect::<Result<_, _>>()
6255                .unwrap();
6256            if failure == "fts" {
6257                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6258            } else {
6259                client.conn.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE parent(id TEXT PRIMARY KEY); CREATE TABLE deferred(id TEXT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_release AFTER INSERT ON tasks BEGIN INSERT INTO deferred VALUES ('missing'); END;").unwrap();
6260            }
6261            client.overlay_dirty.set(true);
6262            assert!(client.rebuild_overlay().is_err());
6263            assert!(client.overlay_dirty.get());
6264            assert!(client.conn.is_autocommit());
6265            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6266            assert_eq!(
6267                client
6268                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6269                    .unwrap(),
6270                fts_before
6271            );
6272            let original_triggers: Vec<String> = client.conn.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name NOT LIKE 'fail_%' ORDER BY name").unwrap().query_map([], |row| row.get(0)).unwrap().collect::<Result<_,_>>().unwrap();
6273            assert_eq!(original_triggers, triggers_before);
6274            assert_eq!(client.pending_commit_ids().len(), 1);
6275            client
6276                .conn
6277                .execute_batch(if failure == "fts" {
6278                    "DROP TRIGGER fail_fts"
6279                } else {
6280                    "DROP TRIGGER fail_release"
6281                })
6282                .unwrap();
6283            client
6284                .rebuild_overlay()
6285                .expect("retry after removing the fault");
6286        }
6287    }
6288
6289    #[test]
6290    fn secondary_unique_collision_preserves_existing_synced_row() {
6291        let client = SyncClient::new(
6292            "unique-upsert-test".to_owned(),
6293            &json!({
6294                "version": 1,
6295                "tables": [{
6296                    "name": "tasks",
6297                    "primaryKey": "id",
6298                    "columns": [
6299                        { "name": "id", "type": "string", "nullable": false },
6300                        { "name": "project_id", "type": "string", "nullable": false },
6301                        { "name": "title", "type": "string", "nullable": false }
6302                    ],
6303                    "scopes": [{ "pattern": "project:{project_id}" }],
6304                    "indexes": [{
6305                        "name": "tasks_by_project_title",
6306                        "columns": ["project_id", "title"],
6307                        "unique": true
6308                    }]
6309                }]
6310            }),
6311            ClientLimits::default(),
6312        )
6313        .expect("test client");
6314        let table = client.schema.table("tasks").expect("tasks table");
6315        let sql = client.insert_row_sql(&base_table("tasks"), table);
6316
6317        client
6318            .conn
6319            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6320            .expect("insert first row");
6321        client
6322            .conn
6323            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6324            .expect("update same primary key");
6325        client
6326            .conn
6327            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6328            .expect("insert second row");
6329        assert!(client
6330            .conn
6331            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6332            .is_err());
6333
6334        let rows = client
6335            .conn
6336            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6337            .expect("prepare rows")
6338            .query_map([], |row| {
6339                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6340            })
6341            .expect("query rows")
6342            .collect::<Result<Vec<_>, _>>()
6343            .expect("collect rows");
6344        assert_eq!(
6345            rows,
6346            vec![
6347                ("t1".to_owned(), "updated".to_owned()),
6348                ("t2".to_owned(), "original".to_owned())
6349            ]
6350        );
6351    }
6352
6353    #[test]
6354    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6355        let path = std::env::temp_dir().join(format!(
6356            "syncular-startup-intent-{}.db",
6357            uuid::Uuid::new_v4()
6358        ));
6359        let schema = json!({
6360            "version": 1,
6361            "tables": [{
6362                "name": "tasks",
6363                "primaryKey": "id",
6364                "columns": [
6365                    { "name": "id", "type": "string", "nullable": false },
6366                    { "name": "project_id", "type": "string", "nullable": false }
6367                ],
6368                "scopes": [{ "pattern": "project:{project_id}" }]
6369            }]
6370        });
6371
6372        {
6373            let mut first = SyncClient::open_path_with_identity(
6374                None,
6375                &schema,
6376                ClientLimits::default(),
6377                path.to_str().expect("UTF-8 temp path"),
6378            )
6379            .expect("first open");
6380            first
6381                .set_window(
6382                    &WindowBase {
6383                        table: "tasks".to_owned(),
6384                        variable: "project_id".to_owned(),
6385                        fixed_scopes: Vec::new(),
6386                        params: None,
6387                    },
6388                    &["persisted".to_owned()],
6389                )
6390                .expect("persist window");
6391        }
6392
6393        let mut reopened = SyncClient::open_path_with_identity(
6394            None,
6395            &schema,
6396            ClientLimits::default(),
6397            path.to_str().expect("UTF-8 temp path"),
6398        )
6399        .expect("reopen");
6400        assert!(reopened.sync_needed());
6401        assert!(matches!(
6402            reopened.drain_sync_intents().as_slice(),
6403            [SyncIntent::Interactive]
6404        ));
6405        drop(reopened);
6406        std::fs::remove_file(path).expect("remove temp database");
6407    }
6408
6409    #[test]
6410    fn reopening_preserves_immutable_subscription_identity_and_progress() {
6411        let path = std::env::temp_dir().join(format!(
6412            "syncular-subscription-identity-{}.db",
6413            uuid::Uuid::new_v4()
6414        ));
6415        let schema = json!({
6416            "version": 1,
6417            "tables": [
6418                {
6419                    "name": "tasks",
6420                    "primaryKey": "id",
6421                    "columns": [
6422                        { "name": "id", "type": "string", "nullable": false },
6423                        { "name": "project_id", "type": "string", "nullable": false }
6424                    ],
6425                    "scopes": [{ "pattern": "project:{project_id}" }]
6426                },
6427                {
6428                    "name": "docs",
6429                    "primaryKey": "id",
6430                    "columns": [
6431                        { "name": "id", "type": "string", "nullable": false },
6432                        { "name": "org_id", "type": "string", "nullable": false },
6433                        { "name": "project_id", "type": "string", "nullable": false }
6434                    ],
6435                    "scopes": [
6436                        { "pattern": "org:{org_id}" },
6437                        { "pattern": "project:{projectId}", "column": "project_id" }
6438                    ]
6439                }
6440            ]
6441        });
6442
6443        {
6444            let mut first = SyncClient::open_path_with_identity(
6445                None,
6446                &schema,
6447                ClientLimits::default(),
6448                path.to_str().expect("UTF-8 temp path"),
6449            )
6450            .expect("first open");
6451            first
6452                .subscribe(
6453                    "stable-subscription".to_owned(),
6454                    "tasks".to_owned(),
6455                    vec![(
6456                        "project_id".to_owned(),
6457                        vec!["p2".to_owned(), "p1".to_owned()],
6458                    )],
6459                    Some(r#"{"view":"v1"}"#.to_owned()),
6460                )
6461                .expect("persist subscription");
6462            let persisted = {
6463                let subscription = first
6464                    .subs
6465                    .iter_mut()
6466                    .find(|subscription| subscription.id == "stable-subscription")
6467                    .expect("subscription");
6468                subscription.cursor = 41;
6469                subscription.bootstrap_state = Some("resume-token".to_owned());
6470                subscription.effective = Some(vec![(
6471                    "project_id".to_owned(),
6472                    vec!["p1".to_owned(), "p2".to_owned()],
6473                )]);
6474                subscription.synced_once = true;
6475                subscription.clone()
6476            };
6477            first.persist_sub(&persisted).unwrap();
6478        }
6479
6480        let mut reopened = SyncClient::open_path_with_identity(
6481            None,
6482            &schema,
6483            ClientLimits::default(),
6484            path.to_str().expect("UTF-8 temp path"),
6485        )
6486        .expect("reopen");
6487        let progress = reopened
6488            .subscription_state("stable-subscription")
6489            .expect("persisted state");
6490        assert_eq!(progress.cursor, 41);
6491        assert!(progress.has_resume_token);
6492
6493        reopened
6494            .subscribe(
6495                "stable-subscription".to_owned(),
6496                "tasks".to_owned(),
6497                vec![(
6498                    "project_id".to_owned(),
6499                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6500                )],
6501                Some(r#"{"view":"v1"}"#.to_owned()),
6502            )
6503            .expect("canonical intent is idempotent");
6504        assert_eq!(
6505            reopened
6506                .subscription_state("stable-subscription")
6507                .expect("unchanged state")
6508                .cursor,
6509            progress.cursor
6510        );
6511
6512        for (table, scopes, params) in [
6513            (
6514                "tasks",
6515                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6516                Some(r#"{"view":"v1"}"#.to_owned()),
6517            ),
6518            (
6519                "tasks",
6520                vec![(
6521                    "project_id".to_owned(),
6522                    vec!["p2".to_owned(), "p1".to_owned()],
6523                )],
6524                Some(r#"{"view":"v2"}"#.to_owned()),
6525            ),
6526            (
6527                "docs",
6528                vec![
6529                    ("org_id".to_owned(), vec!["o1".to_owned()]),
6530                    ("projectId".to_owned(), vec!["p1".to_owned()]),
6531                ],
6532                Some(r#"{"view":"v1"}"#.to_owned()),
6533            ),
6534        ] {
6535            let error = reopened
6536                .subscribe(
6537                    "stable-subscription".to_owned(),
6538                    table.to_owned(),
6539                    scopes,
6540                    params,
6541                )
6542                .expect_err("identity rebind must fail");
6543            assert!(error.starts_with("client.subscription_intent_mismatch:"));
6544            assert_eq!(
6545                reopened
6546                    .subscription_state("stable-subscription")
6547                    .expect("unchanged state")
6548                    .cursor,
6549                progress.cursor
6550            );
6551        }
6552
6553        drop(reopened);
6554        std::fs::remove_file(path).expect("remove temp database");
6555    }
6556
6557    #[test]
6558    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6559        let path = std::env::temp_dir().join(format!(
6560            "syncular-satisfied-schema-floor-{}.db",
6561            uuid::Uuid::new_v4()
6562        ));
6563        let schema = json!({
6564            "version": 23,
6565            "tables": [{
6566                "name": "tasks",
6567                "primaryKey": "id",
6568                "columns": [
6569                    { "name": "id", "type": "string", "nullable": false },
6570                    { "name": "project_id", "type": "string", "nullable": false }
6571                ],
6572                "scopes": [{ "pattern": "project:{project_id}" }]
6573            }]
6574        });
6575
6576        {
6577            let mut first = SyncClient::open_path_with_identity(
6578                None,
6579                &schema,
6580                ClientLimits::default(),
6581                path.to_str().expect("UTF-8 temp path"),
6582            )
6583            .expect("first open");
6584            first
6585                .subscribe(
6586                    "tasks".to_owned(),
6587                    "tasks".to_owned(),
6588                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6589                    None,
6590                )
6591                .expect("persist subscription");
6592            first.set_schema_floor(Some(SchemaFloor {
6593                required_schema_version: Some(22),
6594                latest_schema_version: Some(22),
6595            }));
6596        }
6597
6598        let mut reopened = SyncClient::open_path_with_identity(
6599            None,
6600            &schema,
6601            ClientLimits::default(),
6602            path.to_str().expect("UTF-8 temp path"),
6603        )
6604        .expect("reopen");
6605        assert!(reopened.schema_floor().is_none());
6606        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6607        assert!(reopened.sync_needed());
6608        assert!(matches!(
6609            reopened.drain_sync_intents().as_slice(),
6610            [SyncIntent::Interactive]
6611        ));
6612        drop(reopened);
6613        std::fs::remove_file(path).expect("remove temp database");
6614    }
6615
6616    #[test]
6617    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6618        let path = std::env::temp_dir().join(format!(
6619            "syncular-unsatisfied-schema-floor-{}.db",
6620            uuid::Uuid::new_v4()
6621        ));
6622        let schema = json!({
6623            "version": 1,
6624            "tables": [{
6625                "name": "tasks",
6626                "primaryKey": "id",
6627                "columns": [
6628                    { "name": "id", "type": "string", "nullable": false },
6629                    { "name": "project_id", "type": "string", "nullable": false }
6630                ],
6631                "scopes": [{ "pattern": "project:{project_id}" }]
6632            }]
6633        });
6634
6635        {
6636            let mut first = SyncClient::open_path_with_identity(
6637                None,
6638                &schema,
6639                ClientLimits::default(),
6640                path.to_str().expect("UTF-8 temp path"),
6641            )
6642            .expect("first open");
6643            first
6644                .subscribe(
6645                    "tasks".to_owned(),
6646                    "tasks".to_owned(),
6647                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6648                    None,
6649                )
6650                .expect("persist subscription");
6651            first.set_schema_floor(Some(SchemaFloor {
6652                required_schema_version: Some(2),
6653                latest_schema_version: Some(2),
6654            }));
6655        }
6656
6657        let reopened = SyncClient::open_path_with_identity(
6658            None,
6659            &schema,
6660            ClientLimits::default(),
6661            path.to_str().expect("UTF-8 temp path"),
6662        )
6663        .expect("reopen");
6664        assert_eq!(
6665            reopened.schema_floor(),
6666            Some(&SchemaFloor {
6667                required_schema_version: Some(2),
6668                latest_schema_version: Some(2),
6669            })
6670        );
6671        assert!(!reopened.sync_needed());
6672        drop(reopened);
6673        std::fs::remove_file(path).expect("remove temp database");
6674    }
6675
6676    #[test]
6677    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6678        let path = std::env::temp_dir().join(format!(
6679            "syncular-indexed-column-bump-{}.db",
6680            uuid::Uuid::new_v4()
6681        ));
6682        let old_schema = json!({
6683            "version": 1,
6684            "tables": [
6685                {
6686                    "name": "tasks",
6687                    "primaryKey": "id",
6688                    "columns": [
6689                        { "name": "id", "type": "string", "nullable": false },
6690                        { "name": "project_id", "type": "string", "nullable": false }
6691                    ],
6692                    "scopes": [{ "pattern": "project:{project_id}" }]
6693                },
6694                {
6695                    "name": "legacy",
6696                    "primaryKey": "id",
6697                    "columns": [
6698                        { "name": "id", "type": "string", "nullable": false },
6699                        { "name": "project_id", "type": "string", "nullable": false }
6700                    ],
6701                    "scopes": [{ "pattern": "project:{project_id}" }]
6702                }
6703            ]
6704        });
6705        {
6706            let mut first = SyncClient::open_path_with_identity(
6707                None,
6708                &old_schema,
6709                ClientLimits::default(),
6710                path.to_str().expect("UTF-8 temp path"),
6711            )
6712            .expect("open old schema");
6713            first
6714                .subscribe(
6715                    "legacy-sub".to_owned(),
6716                    "legacy".to_owned(),
6717                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6718                    None,
6719                )
6720                .expect("persist legacy subscription");
6721        }
6722
6723        let new_schema = json!({
6724            "version": 2,
6725            "tables": [{
6726                "name": "tasks",
6727                "primaryKey": "id",
6728                "columns": [
6729                    { "name": "id", "type": "string", "nullable": false },
6730                    { "name": "project_id", "type": "string", "nullable": false },
6731                    { "name": "facility_membership_id", "type": "string", "nullable": true }
6732                ],
6733                "scopes": [{ "pattern": "project:{project_id}" }],
6734                "indexes": [{
6735                    "name": "tasks_by_membership",
6736                    "columns": ["project_id", "facility_membership_id"],
6737                    "unique": false
6738                }]
6739            }]
6740        });
6741        let upgraded = SyncClient::open_path_with_identity(
6742            None,
6743            &new_schema,
6744            ClientLimits::default(),
6745            path.to_str().expect("UTF-8 temp path"),
6746        )
6747        .expect("open upgraded schema");
6748        let columns = upgraded
6749            .conn
6750            .prepare("PRAGMA table_info(tasks)")
6751            .expect("prepare columns")
6752            .query_map([], |row| row.get::<_, String>(1))
6753            .expect("query columns")
6754            .collect::<Result<Vec<_>, _>>()
6755            .expect("collect columns");
6756        assert!(columns.contains(&"facility_membership_id".to_owned()));
6757        let index_count: i64 = upgraded
6758            .conn
6759            .query_row(
6760                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6761                [],
6762                |row| row.get(0),
6763            )
6764            .expect("query index");
6765        assert_eq!(index_count, 1);
6766        assert!(upgraded.subscription_state("legacy-sub").is_none());
6767        drop(upgraded);
6768        std::fs::remove_file(path).expect("remove temp database");
6769    }
6770
6771    #[test]
6772    fn migrates_pre_envelope_outcome_journal_additively() {
6773        let path = std::env::temp_dir().join(format!(
6774            "syncular-outcome-migration-{}.db",
6775            uuid::Uuid::new_v4()
6776        ));
6777        let conn = Connection::open(&path).expect("open legacy database");
6778        conn.execute_batch(
6779            "CREATE TABLE _syncular_commit_outcomes (
6780               seq INTEGER PRIMARY KEY AUTOINCREMENT,
6781               client_commit_id TEXT NOT NULL UNIQUE,
6782               status TEXT NOT NULL,
6783               recorded_at_ms INTEGER NOT NULL,
6784               results_json TEXT NOT NULL,
6785               resolution TEXT NOT NULL DEFAULT 'active',
6786               resolved_at_ms INTEGER,
6787               replacement_client_commit_id TEXT);",
6788        )
6789        .expect("create legacy outcome journal");
6790        drop(conn);
6791        let schema = json!({
6792            "version": 1,
6793            "tables": [{
6794                "name": "tasks",
6795                "primaryKey": "id",
6796                "columns": [
6797                    { "name": "id", "type": "string", "nullable": false },
6798                    { "name": "project_id", "type": "string", "nullable": false }
6799                ],
6800                "scopes": [{ "pattern": "project:{project_id}" }]
6801            }]
6802        });
6803        let client = SyncClient::open_path(
6804            "migration-native".to_owned(),
6805            &schema,
6806            ClientLimits::default(),
6807            path.to_str().expect("UTF-8 temp path"),
6808        )
6809        .expect("migrate database");
6810        let has_operations = client
6811            .conn
6812            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6813            .expect("prepare table info")
6814            .query_map([], |row| row.get::<_, String>(1))
6815            .expect("query table info")
6816            .filter_map(Result::ok)
6817            .any(|column| column == "operations_json");
6818        assert!(has_operations);
6819        drop(client);
6820        std::fs::remove_file(path).expect("remove temp database");
6821    }
6822
6823    #[test]
6824    fn retained_rejection_survives_file_reopen_and_security_purge() {
6825        let path =
6826            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6827        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6828            { "name": "id", "type": "string", "nullable": false },
6829            { "name": "project_id", "type": "string", "nullable": false },
6830            { "name": "title", "type": "string", "nullable": false }
6831        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6832        {
6833            let mut first = SyncClient::open_path(
6834                "retained-native".to_owned(),
6835                &schema,
6836                ClientLimits::default(),
6837                path.to_str().unwrap(),
6838            )
6839            .unwrap();
6840            first
6841                .write_base_row(
6842                    "tasks",
6843                    &vec![
6844                        Some(ColumnValue::String("t1".into())),
6845                        Some(ColumnValue::String("p1".into())),
6846                        Some(ColumnValue::String("server".into())),
6847                    ],
6848                    1,
6849                )
6850                .unwrap();
6851            first.rebuild_overlay().expect("rebuild overlay");
6852            let operation = OutboxOp {
6853                table: "tasks".into(),
6854                row_id: "t1".into(),
6855                upsert: true,
6856                insert: false,
6857                base_version: Some(1),
6858                values: Some(Map::from_iter([
6859                    ("id".into(), json!("t1")),
6860                    ("title".into(), json!("mine")),
6861                ])),
6862            };
6863            first
6864                .retain_failed_commit("retained", std::slice::from_ref(&operation))
6865                .unwrap();
6866            first
6867                .persist_commit_outcome(
6868                    "retained",
6869                    CommitOutcomeStatus::Rejected,
6870                    &[CommitOperationOutcome::Error {
6871                        rejection: RejectionRecord {
6872                            client_commit_id: "retained".into(),
6873                            op_index: 0,
6874                            code: "validation.denied".into(),
6875                            message: "denied".into(),
6876                            retryable: false,
6877                            details: None,
6878                            operation: None,
6879                        },
6880                    }],
6881                    Some(&[operation]),
6882                )
6883                .unwrap();
6884            first.rebuild_overlay().expect("rebuild overlay");
6885        }
6886        {
6887            let mut reopened = SyncClient::open_path(
6888                "retained-native".to_owned(),
6889                &schema,
6890                ClientLimits::default(),
6891                path.to_str().unwrap(),
6892            )
6893            .unwrap();
6894            let row = reopened
6895                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6896                .expect("read visible row")
6897                .unwrap();
6898            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6899            assert_eq!(
6900                reopened
6901                    .commit_outcome("retained")
6902                    .unwrap()
6903                    .unwrap()
6904                    .retained_rows
6905                    .unwrap()[0]
6906                    .server_row
6907                    .as_ref()
6908                    .unwrap()["title"],
6909                json!("server")
6910            );
6911            reopened
6912                .conn
6913                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6914                .unwrap();
6915            reopened.rebuild_overlay().expect("rebuild overlay");
6916            assert!(reopened
6917                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6918                .expect("read visible row")
6919                .is_none());
6920            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6921            assert!(retained.retained_rows.is_some());
6922            assert_eq!(
6923                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6924                json!("mine")
6925            );
6926            reopened
6927                .purge_local_data(&LocalDataPurgeInput {
6928                    purge_id: "protected-purge".into(),
6929                    targets: vec![LocalDataPurgeTarget {
6930                        table: "tasks".into(),
6931                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6932                    }],
6933                })
6934                .unwrap();
6935            assert!(reopened
6936                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6937                .expect("read visible row")
6938                .is_none());
6939            assert!(reopened
6940                .commit_outcome("retained")
6941                .unwrap()
6942                .unwrap()
6943                .retained_rows
6944                .is_none());
6945        }
6946        std::fs::remove_file(path).unwrap();
6947    }
6948
6949    #[test]
6950    fn durable_conflict_outcome_and_resolution_survive_reopen() {
6951        let path = std::env::temp_dir().join(format!(
6952            "syncular-durable-outcome-{}.db",
6953            uuid::Uuid::new_v4()
6954        ));
6955        let schema = json!({
6956            "version": 1,
6957            "tables": [{
6958                "name": "tasks",
6959                "primaryKey": "id",
6960                "columns": [
6961                    { "name": "id", "type": "string", "nullable": false },
6962                    { "name": "project_id", "type": "string", "nullable": false }
6963                ],
6964                "scopes": [{ "pattern": "project:{project_id}" }]
6965            }]
6966        });
6967        let conflict = ConflictRecord {
6968            client_commit_id: "losing-commit".to_owned(),
6969            op_index: 0,
6970            table: "tasks".to_owned(),
6971            row_id: "t1".to_owned(),
6972            code: "sync.version_conflict".to_owned(),
6973            message: "stale base version".to_owned(),
6974            server_version: 2,
6975            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6976            conflict_columns: vec!["project_id".to_owned()],
6977            operation: Some(CommitOperation {
6978                table: "tasks".to_owned(),
6979                row_id: "t1".to_owned(),
6980                op: "upsert".to_owned(),
6981                base_version: Some(1),
6982                values: Some(Map::from_iter([
6983                    ("id".into(), json!("t1")),
6984                    ("project_id".into(), json!("p1")),
6985                ])),
6986                insert: false,
6987            }),
6988        };
6989        let failed_operations = vec![
6990            OutboxOp {
6991                upsert: true,
6992                insert: false,
6993                table: "tasks".to_owned(),
6994                row_id: "t1".to_owned(),
6995                base_version: Some(1),
6996                values: Some(Map::from_iter([
6997                    ("id".into(), json!("t1")),
6998                    ("project_id".into(), json!("p1")),
6999                ])),
7000            },
7001            OutboxOp {
7002                upsert: true,
7003                insert: false,
7004                table: "tasks".to_owned(),
7005                row_id: "status-event-1".to_owned(),
7006                base_version: Some(0),
7007                values: Some(Map::from_iter([
7008                    ("id".into(), json!("status-event-1")),
7009                    ("project_id".into(), json!("p1")),
7010                ])),
7011            },
7012        ];
7013
7014        {
7015            let mut first = SyncClient::open_path(
7016                "durable-native".to_owned(),
7017                &schema,
7018                ClientLimits::default(),
7019                path.to_str().expect("UTF-8 temp path"),
7020            )
7021            .expect("first open");
7022            first
7023                .begin_observation("test_outcome")
7024                .expect("begin outcome");
7025            first
7026                .persist_commit_outcome(
7027                    "losing-commit",
7028                    CommitOutcomeStatus::Conflict,
7029                    &[CommitOperationOutcome::Conflict {
7030                        conflict: conflict.clone(),
7031                    }],
7032                    Some(&failed_operations),
7033                )
7034                .expect("persist outcome");
7035            first.conflicts.push(conflict);
7036            first
7037                .finish_observation(
7038                    "test_outcome",
7039                    ChangeAccumulator {
7040                        conflicts: true,
7041                        outcomes: true,
7042                        ..ChangeAccumulator::default()
7043                    },
7044                )
7045                .expect("commit outcome");
7046        }
7047
7048        {
7049            let mut reopened = SyncClient::open_path(
7050                "durable-native".to_owned(),
7051                &schema,
7052                ClientLimits::default(),
7053                path.to_str().expect("UTF-8 temp path"),
7054            )
7055            .expect("reopen");
7056            assert_eq!(reopened.conflicts().len(), 1);
7057            assert_eq!(
7058                reopened.conflicts()[0].conflict_columns,
7059                vec!["project_id".to_owned()],
7060                "conflictColumns survives the journal round trip"
7061            );
7062            let outcome = reopened
7063                .commit_outcome("losing-commit")
7064                .expect("read outcome")
7065                .expect("outcome");
7066            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7067            let operations = outcome.operations.expect("aggregate envelope");
7068            assert_eq!(operations.len(), 2);
7069            assert_eq!(operations[1].row_id, "status-event-1");
7070            let resolved = reopened
7071                .resolve_commit_outcome(ResolveCommitOutcomeInput {
7072                    client_commit_id: "losing-commit".to_owned(),
7073                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
7074                    replacement_client_commit_id: None,
7075                })
7076                .expect("resolve");
7077            assert_eq!(
7078                resolved.resolution,
7079                CommitOutcomeResolution::ResolvedKeepServer
7080            );
7081            assert!(reopened.conflicts().is_empty());
7082        }
7083
7084        let reopened = SyncClient::open_path(
7085            "durable-native".to_owned(),
7086            &schema,
7087            ClientLimits::default(),
7088            path.to_str().expect("UTF-8 temp path"),
7089        )
7090        .expect("second reopen");
7091        assert!(reopened.conflicts().is_empty());
7092        assert_eq!(
7093            reopened
7094                .commit_outcome("losing-commit")
7095                .expect("read outcome")
7096                .expect("outcome")
7097                .resolution,
7098            CommitOutcomeResolution::ResolvedKeepServer
7099        );
7100        drop(reopened);
7101        std::fs::remove_file(path).expect("remove temp database");
7102    }
7103
7104    #[test]
7105    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
7106        let path =
7107            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7108        let schema = json!({
7109            "version": 1,
7110            "tables": [{
7111                "name": "tasks",
7112                "primaryKey": "id",
7113                "columns": [
7114                    { "name": "id", "type": "string", "nullable": false },
7115                    { "name": "project_id", "type": "string", "nullable": false }
7116                ],
7117                "scopes": [{ "pattern": "project:{project_id}" }]
7118            }]
7119        });
7120        let mut client = SyncClient::open_path_with_identity(
7121            Some("sidecar-client".to_owned()),
7122            &schema,
7123            ClientLimits::default(),
7124            path.to_str().expect("UTF-8 temp path"),
7125        )
7126        .expect("open owner");
7127        let base = WindowBase {
7128            table: "tasks".to_owned(),
7129            variable: "project_id".to_owned(),
7130            fixed_scopes: Vec::new(),
7131            params: None,
7132        };
7133        client
7134            .set_window(&base, &["one".to_owned()])
7135            .expect("set window");
7136        client
7137            .mutate(vec![Mutation::Upsert {
7138                table: "tasks".to_owned(),
7139                values: Map::from_iter([
7140                    ("id".to_owned(), Value::from("t1")),
7141                    ("project_id".to_owned(), Value::from("one")),
7142                ]),
7143                base_version: None,
7144            }])
7145            .expect("local mutate");
7146
7147        let coverage = [WindowCoverage {
7148            base,
7149            units: vec!["one".to_owned(), "missing".to_owned()],
7150        }];
7151        let owner = client
7152            .query_snapshot(
7153                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7154                &[],
7155                &coverage,
7156                None,
7157            )
7158            .expect("owner snapshot");
7159        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7160        let sidecar = reader
7161            .query_snapshot(
7162                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7163                &[],
7164                &coverage,
7165            )
7166            .expect("sidecar snapshot");
7167
7168        assert_eq!(sidecar.revision, owner.revision);
7169        assert_eq!(sidecar.rows, owner.rows);
7170        assert_eq!(
7171            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7172            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7173        );
7174        assert_eq!(sidecar.revision, "2");
7175        assert_eq!(sidecar.rows[0]["id"], "t1");
7176        assert_eq!(sidecar.rows[0]["server_version"], -1);
7177        assert!(!sidecar.coverage.complete);
7178        assert_eq!(sidecar.coverage.pending.len(), 1);
7179        assert_eq!(sidecar.coverage.missing.len(), 1);
7180
7181        drop(reader);
7182        drop(client);
7183        std::fs::remove_file(path).expect("remove temp database");
7184    }
7185
7186    #[test]
7187    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7188        let path =
7189            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7190        let schema = json!({
7191            "version": 1,
7192            "tables": [{
7193                "name": "tasks",
7194                "primaryKey": "id",
7195                "columns": [
7196                    { "name": "id", "type": "string", "nullable": false },
7197                    { "name": "project_id", "type": "string", "nullable": false }
7198                ],
7199                "scopes": [{ "pattern": "project:{project_id}" }]
7200            }]
7201        });
7202        let mut client = SyncClient::open_path_with_identity(
7203            Some("sidecar-catchup".to_owned()),
7204            &schema,
7205            ClientLimits::default(),
7206            path.to_str().expect("UTF-8 temp path"),
7207        )
7208        .expect("open owner");
7209        client
7210            .subscribe(
7211                "s1".into(),
7212                "tasks".into(),
7213                vec![("project_id".into(), vec!["one".into()])],
7214                None,
7215            )
7216            .expect("subscribe");
7217        let commit_id = client
7218            .mutate(vec![Mutation::Upsert {
7219                table: "tasks".to_owned(),
7220                values: Map::from_iter([
7221                    ("id".to_owned(), Value::from("t1")),
7222                    ("project_id".to_owned(), Value::from("one")),
7223                ]),
7224                base_version: None,
7225            }])
7226            .expect("mutate");
7227        let revision = client.local_revision().to_string();
7228
7229        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7230        assert_eq!(reader.local_revision().expect("revision"), revision);
7231
7232        let read = reader
7233            .snapshot_read(&SnapshotReadRequest {
7234                statements: vec![
7235                    SnapshotStatement {
7236                        sql: "SELECT 1 AS n",
7237                        params: &[],
7238                    },
7239                    SnapshotStatement {
7240                        sql: "SELECT id FROM tasks ORDER BY id",
7241                        params: &[],
7242                    },
7243                ],
7244                coverage: &[],
7245                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7246                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7247            })
7248            .expect("snapshot read");
7249        assert_eq!(read.revision, revision);
7250        assert_eq!(read.queries.len(), 2);
7251        assert_eq!(read.queries[0][0]["n"], 1);
7252        assert_eq!(read.queries[1][0]["id"], "t1");
7253        match &read.subscriptions[0] {
7254            SubscriptionCatchup::Known {
7255                status,
7256                cursor,
7257                bootstrap_complete,
7258                known_pending_pages,
7259                ..
7260            } => {
7261                assert_eq!(status, "active");
7262                assert_eq!(*cursor, -1);
7263                assert!(!bootstrap_complete);
7264                assert!(known_pending_pages);
7265            }
7266            other => panic!("expected a known subscription: {other:?}"),
7267        }
7268        assert!(matches!(
7269            &read.subscriptions[1],
7270            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7271        ));
7272        assert!(matches!(
7273            &read.deliveries[0],
7274            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7275        ));
7276        assert!(matches!(
7277            &read.deliveries[1],
7278            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7279        ));
7280
7281        // A bad statement rolls the whole read back and releases the connection.
7282        assert!(reader
7283            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7284            .is_err());
7285        assert_eq!(
7286            reader.local_revision().expect("revision after rollback"),
7287            revision
7288        );
7289        // The read-only guard refuses a write on any statement.
7290        assert!(reader
7291            .query_snapshot("DELETE FROM tasks", &[], &[])
7292            .is_err());
7293
7294        // A corrupt revision marker fails typed, not as an invented zero.
7295        client
7296            .conn
7297            .execute(
7298                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7299                [],
7300            )
7301            .expect("corrupt revision");
7302        let failure = reader.local_revision().expect_err("corrupt revision fails");
7303        assert_eq!(failure.code, Some("sync.local_corrupt"));
7304
7305        drop(reader);
7306        drop(client);
7307        std::fs::remove_file(path).expect("remove temp database");
7308    }
7309
7310    #[test]
7311    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7312        let path =
7313            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7314        let schema = json!({
7315            "version": 1,
7316            "tables": [{
7317                "name": "tasks",
7318                "primaryKey": "id",
7319                "columns": [
7320                    { "name": "id", "type": "string", "nullable": false },
7321                    { "name": "project_id", "type": "string", "nullable": false }
7322                ],
7323                "scopes": [{ "pattern": "project:{project_id}" }]
7324            }]
7325        });
7326        let mut client = SyncClient::open_path_with_identity(
7327            Some("sidecar-delivery".to_owned()),
7328            &schema,
7329            ClientLimits::default(),
7330            path.to_str().expect("UTF-8 temp path"),
7331        )
7332        .expect("open owner");
7333        client.begin_observation("test_delivery").expect("observe");
7334        let operations = [OutboxOp {
7335            upsert: true,
7336            insert: false,
7337            table: "tasks".to_owned(),
7338            row_id: "t1".to_owned(),
7339            base_version: Some(1),
7340            values: Some(Map::from_iter([
7341                ("id".into(), json!("t1")),
7342                ("project_id".into(), json!("p1")),
7343            ])),
7344        }];
7345        client
7346            .persist_commit_outcome(
7347                "conflict-commit",
7348                CommitOutcomeStatus::Conflict,
7349                &[CommitOperationOutcome::Conflict {
7350                    conflict: ConflictRecord {
7351                        client_commit_id: "conflict-commit".to_owned(),
7352                        op_index: 0,
7353                        table: "tasks".to_owned(),
7354                        row_id: "t1".to_owned(),
7355                        code: "sync.version_conflict".to_owned(),
7356                        message: "stale base version".to_owned(),
7357                        server_version: 2,
7358                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7359                        conflict_columns: vec!["project_id".to_owned()],
7360                        operation: None,
7361                    },
7362                }],
7363                Some(&operations),
7364            )
7365            .expect("persist conflict");
7366        client
7367            .persist_commit_outcome(
7368                "rejected-commit",
7369                CommitOutcomeStatus::Rejected,
7370                &[CommitOperationOutcome::Error {
7371                    rejection: RejectionRecord {
7372                        client_commit_id: "rejected-commit".to_owned(),
7373                        op_index: 0,
7374                        code: "sync.unknown_table".to_owned(),
7375                        message: "unknown table".to_owned(),
7376                        retryable: false,
7377                        details: None,
7378                        operation: None,
7379                    },
7380                }],
7381                Some(&operations),
7382            )
7383            .expect("persist rejection");
7384        client
7385            .finish_observation(
7386                "test_delivery",
7387                ChangeAccumulator {
7388                    conflicts: true,
7389                    outcomes: true,
7390                    ..ChangeAccumulator::default()
7391                },
7392            )
7393            .expect("commit outcomes");
7394
7395        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7396        match reader.commit_delivery("conflict-commit").expect("conflict") {
7397            CommitDelivery::Known { outcome, .. } => {
7398                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7399                assert!(matches!(
7400                    outcome.results.as_slice(),
7401                    [CommitOperationOutcome::Conflict { conflict }]
7402                        if conflict.code == "sync.version_conflict"
7403                            && conflict.server_version == 2
7404                ));
7405                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7406                assert!(
7407                    outcome.retained_rows.is_none(),
7408                    "the sidecar reports the persisted view, not owner-derived retained rows"
7409                );
7410            }
7411            other => panic!("expected a known conflict: {other:?}"),
7412        }
7413        match reader
7414            .commit_delivery("rejected-commit")
7415            .expect("rejection")
7416        {
7417            CommitDelivery::Known { outcome, .. } => {
7418                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7419                assert!(matches!(
7420                    outcome.results.as_slice(),
7421                    [CommitOperationOutcome::Error { rejection }]
7422                        if rejection.code == "sync.unknown_table"
7423                ));
7424                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7425                assert!(outcome.retained_rows.is_none());
7426            }
7427            other => panic!("expected a known rejection: {other:?}"),
7428        }
7429        drop(reader);
7430        drop(client);
7431        std::fs::remove_file(path).expect("remove temp database");
7432    }
7433
7434    #[test]
7435    fn snapshot_read_isolates_a_concurrent_writer_commit() {
7436        use std::sync::mpsc::channel;
7437        let path = std::env::temp_dir().join(format!(
7438            "syncular-read-concurrent-{}.db",
7439            uuid::Uuid::new_v4()
7440        ));
7441        let schema = json!({
7442            "version": 1,
7443            "tables": [{
7444                "name": "tasks",
7445                "primaryKey": "id",
7446                "columns": [
7447                    { "name": "id", "type": "string", "nullable": false },
7448                    { "name": "project_id", "type": "string", "nullable": false }
7449                ],
7450                "scopes": [{ "pattern": "project:{project_id}" }]
7451            }]
7452        });
7453        let mut client = SyncClient::open_path_with_identity(
7454            Some("sidecar-concurrent".to_owned()),
7455            &schema,
7456            ClientLimits::default(),
7457            path.to_str().expect("UTF-8 temp path"),
7458        )
7459        .expect("open owner");
7460        client
7461            .mutate(vec![Mutation::Upsert {
7462                table: "tasks".to_owned(),
7463                values: Map::from_iter([
7464                    ("id".to_owned(), Value::from("t1")),
7465                    ("project_id".to_owned(), Value::from("one")),
7466                ]),
7467                base_version: None,
7468            }])
7469            .expect("seed row");
7470        let revision_before = client.local_revision().to_string();
7471
7472        // A writer on a second connection commits while the reader is between
7473        // statements; the hook rendezvous is a barrier, not a sleep.
7474        let (ready_tx, ready_rx) = channel::<()>();
7475        let (done_tx, done_rx) = channel::<()>();
7476        let writer_path = path.to_string_lossy().into_owned();
7477        let writer_schema = schema.clone();
7478        let writer = std::thread::spawn(move || {
7479            let mut writer = SyncClient::open_path_with_identity(
7480                Some("sidecar-concurrent".to_owned()),
7481                &writer_schema,
7482                ClientLimits::default(),
7483                &writer_path,
7484            )
7485            .expect("open writer");
7486            ready_rx.recv().expect("reader signalled");
7487            writer
7488                .mutate(vec![Mutation::Upsert {
7489                    table: "tasks".to_owned(),
7490                    values: Map::from_iter([
7491                        ("id".to_owned(), Value::from("t2")),
7492                        ("project_id".to_owned(), Value::from("one")),
7493                    ]),
7494                    base_version: None,
7495                }])
7496                .expect("writer commit");
7497            let _ = done_tx.send(());
7498        });
7499        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7500        SNAPSHOT_READ_HOOK.with(|slot| {
7501            *slot.borrow_mut() = Some(Box::new(move || {
7502                ready_tx.send(()).expect("signal writer");
7503                done_rx.recv().expect("writer finished");
7504            }));
7505        });
7506        let read = reader
7507            .snapshot_read(&SnapshotReadRequest {
7508                statements: vec![
7509                    SnapshotStatement {
7510                        sql: "SELECT count(*) AS n FROM tasks",
7511                        params: &[],
7512                    },
7513                    SnapshotStatement {
7514                        sql: "SELECT count(*) AS n FROM tasks",
7515                        params: &[],
7516                    },
7517                ],
7518                coverage: &[],
7519                subscriptions: Vec::new(),
7520                commit_ids: Vec::new(),
7521            })
7522            .expect("snapshot read");
7523        assert_eq!(
7524            read.queries[0][0]["n"], 1,
7525            "first statement sees the pre-commit row"
7526        );
7527        assert_eq!(
7528            read.queries[1][0]["n"], 1,
7529            "a later statement keeps the one read snapshot"
7530        );
7531        assert_eq!(
7532            read.revision, revision_before,
7533            "the read reports the snapshot revision, not the writer's"
7534        );
7535        writer.join().expect("join writer");
7536
7537        assert_ne!(
7538            reader.local_revision().expect("revision after commit"),
7539            revision_before,
7540            "the next read observes the committed revision"
7541        );
7542        let after = reader
7543            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7544            .expect("count after commit");
7545        assert_eq!(
7546            after.rows[0]["n"], 2,
7547            "the committed row is visible afterwards"
7548        );
7549
7550        drop(reader);
7551        drop(client);
7552        std::fs::remove_file(path).expect("remove temp database");
7553    }
7554
7555    #[test]
7556    fn snapshot_read_rejects_every_non_canonical_revision() {
7557        let path = std::env::temp_dir().join(format!(
7558            "syncular-read-revision-{}.db",
7559            uuid::Uuid::new_v4()
7560        ));
7561        let schema = json!({
7562            "version": 1,
7563            "tables": [{
7564                "name": "tasks",
7565                "primaryKey": "id",
7566                "columns": [
7567                    { "name": "id", "type": "string", "nullable": false },
7568                    { "name": "project_id", "type": "string", "nullable": false }
7569                ],
7570                "scopes": [{ "pattern": "project:{project_id}" }]
7571            }]
7572        });
7573        let client = SyncClient::open_path_with_identity(
7574            Some("sidecar-revision".to_owned()),
7575            &schema,
7576            ClientLimits::default(),
7577            path.to_str().expect("UTF-8 temp path"),
7578        )
7579        .expect("open owner");
7580        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7581        for bad in [
7582            "+1",
7583            "01",
7584            "-1",
7585            " 1",
7586            "1 ",
7587            "18446744073709551616",
7588            "1.0",
7589            "abc",
7590            "",
7591        ] {
7592            client
7593                .conn
7594                .execute(
7595                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7596                    rusqlite::params![bad],
7597                )
7598                .expect("corrupt revision");
7599            let failure = reader.local_revision().expect_err("corrupt marker fails");
7600            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7601        }
7602        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7603            client
7604                .conn
7605                .execute_batch(&format!(
7606                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7607                ))
7608                .expect("corrupt stored revision type");
7609            assert_eq!(
7610                reader.local_revision().expect_err("invalid SQL field").code,
7611                Some("sync.local_corrupt")
7612            );
7613        }
7614        // The absent marker is the legacy zero, not corruption.
7615        client
7616            .conn
7617            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7618            .expect("delete marker");
7619        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7620
7621        drop(reader);
7622        drop(client);
7623        std::fs::remove_file(path).expect("remove temp database");
7624    }
7625
7626    #[test]
7627    fn snapshot_read_rejects_corrupt_subscription_state() {
7628        let path = std::env::temp_dir().join(format!(
7629            "syncular-read-substate-{}.db",
7630            uuid::Uuid::new_v4()
7631        ));
7632        let schema = json!({
7633            "version": 1,
7634            "tables": [{
7635                "name": "tasks",
7636                "primaryKey": "id",
7637                "columns": [
7638                    { "name": "id", "type": "string", "nullable": false },
7639                    { "name": "project_id", "type": "string", "nullable": false }
7640                ],
7641                "scopes": [{ "pattern": "project:{project_id}" }]
7642            }]
7643        });
7644        let mut client = SyncClient::open_path_with_identity(
7645            Some("sidecar-substate".to_owned()),
7646            &schema,
7647            ClientLimits::default(),
7648            path.to_str().expect("UTF-8 temp path"),
7649        )
7650        .expect("open owner");
7651        client
7652            .subscribe(
7653                "s1".into(),
7654                "tasks".into(),
7655                vec![("project_id".into(), vec!["one".into()])],
7656                None,
7657            )
7658            .expect("subscribe");
7659        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7660        for bad in [
7661            "not json".to_owned(),
7662            "{}".to_owned(),
7663            json!({ "status": "bogus", "cursor": 0 }).to_string(),
7664            json!({ "status": "active", "cursor": "x" }).to_string(),
7665            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7666            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7667        ] {
7668            client
7669                .conn
7670                .execute(
7671                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7672                    rusqlite::params![bad],
7673                )
7674                .expect("corrupt subscription state");
7675            let failure = reader
7676                .subscription_catchup("s1")
7677                .expect_err("corrupt state fails");
7678            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7679        }
7680
7681        for assignment in [
7682            "tbl=X'7461736b73'",
7683            "state_json=X'7b7d'",
7684            "state_json=CAST(X'FF' AS TEXT)",
7685        ] {
7686            client.conn.execute_batch(&format!(
7687                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7688            )).expect("corrupt stored subscription type");
7689            assert_eq!(
7690                reader
7691                    .subscription_catchup("s1")
7692                    .expect_err("invalid SQL field")
7693                    .code,
7694                Some("sync.local_corrupt")
7695            );
7696        }
7697
7698        drop(reader);
7699        drop(client);
7700        std::fs::remove_file(path).expect("remove temp database");
7701    }
7702
7703    #[test]
7704    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7705        let path = std::env::temp_dir().join(format!(
7706            "syncular-read-rollback-{}.db",
7707            uuid::Uuid::new_v4()
7708        ));
7709        let schema = json!({
7710            "version": 1,
7711            "tables": [{
7712                "name": "tasks",
7713                "primaryKey": "id",
7714                "columns": [
7715                    { "name": "id", "type": "string", "nullable": false },
7716                    { "name": "project_id", "type": "string", "nullable": false }
7717                ],
7718                "scopes": [{ "pattern": "project:{project_id}" }]
7719            }]
7720        });
7721        let mut client = SyncClient::open_path_with_identity(
7722            Some("sidecar-rollback".to_owned()),
7723            &schema,
7724            ClientLimits::default(),
7725            path.to_str().expect("UTF-8 temp path"),
7726        )
7727        .expect("open owner");
7728        let commit_id = client
7729            .mutate(vec![Mutation::Upsert {
7730                table: "tasks".to_owned(),
7731                values: Map::from_iter([
7732                    ("id".to_owned(), Value::from("t1")),
7733                    ("project_id".to_owned(), Value::from("one")),
7734                ]),
7735                base_version: None,
7736            }])
7737            .expect("seed row");
7738        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7739        let revision = reader.local_revision().expect("revision");
7740
7741        // The second statement fails: the whole read rolls back and the
7742        // connection releases, so the same reader still works afterwards.
7743        let failure = reader
7744            .snapshot_read(&SnapshotReadRequest {
7745                statements: vec![
7746                    SnapshotStatement {
7747                        sql: "SELECT id FROM tasks",
7748                        params: &[],
7749                    },
7750                    SnapshotStatement {
7751                        sql: "SELECT * FROM does_not_exist",
7752                        params: &[],
7753                    },
7754                ],
7755                coverage: &[],
7756                subscriptions: Vec::new(),
7757                commit_ids: vec![commit_id.clone()],
7758            })
7759            .expect_err("second statement fails");
7760        assert_eq!(failure.code, None);
7761        assert_eq!(
7762            reader.local_revision().expect("revision after rollback"),
7763            revision,
7764            "a rolled-back read consumes no revision"
7765        );
7766        assert!(
7767            !matches!(
7768                reader
7769                    .commit_delivery(&commit_id)
7770                    .expect("delivery after rollback"),
7771                CommitDelivery::Unknown { .. }
7772            ),
7773            "the same reader serves the next read after releasing the connection"
7774        );
7775
7776        // The read-only guard rejects a write in the first position too.
7777        let guard = reader
7778            .snapshot_read(&SnapshotReadRequest {
7779                statements: vec![SnapshotStatement {
7780                    sql: "DELETE FROM tasks",
7781                    params: &[],
7782                }],
7783                coverage: &[],
7784                subscriptions: Vec::new(),
7785                commit_ids: Vec::new(),
7786            })
7787            .expect_err("write guard");
7788        assert!(guard.message.contains("read-only"), "{}", guard.message);
7789
7790        drop(reader);
7791        drop(client);
7792        std::fs::remove_file(path).expect("remove temp database");
7793    }
7794
7795    #[test]
7796    fn owned_query_failures_classify_order_bound_and_clear() {
7797        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7798            rusqlite::ffi::Error::new(266),
7799            Some("private SQLite prose".to_owned()),
7800        ));
7801        assert_eq!(io.code, Some("client.storage_io"));
7802        assert_eq!(io.sqlite_code, Some(266));
7803        assert_eq!(io.message, "local SQLite storage I/O failed");
7804        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7805            rusqlite::ffi::Error::new(26),
7806            None,
7807        ));
7808        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7809        assert!(!io.retryable());
7810        assert!(!corrupt.retryable());
7811
7812        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
7813        // transient lock contention: classified busy and retryable.
7814        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7815            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7816                rusqlite::ffi::Error::new(raw),
7817                Some("database is locked".to_owned()),
7818            ));
7819            assert_eq!(busy.code, Some("client.storage_busy"));
7820            assert_eq!(busy.sqlite_code, Some(extended));
7821            assert_eq!(busy.message, "local SQLite storage is busy");
7822            assert!(busy.retryable());
7823        }
7824        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7825            rusqlite::ffi::Error::new(1),
7826            Some("generic SQLite prose".to_owned()),
7827        ));
7828        assert_eq!(unrelated.code, None);
7829        assert!(!unrelated.retryable());
7830
7831        let mut client = client();
7832        let generic = QueryReadFailure::from("private query prose".to_owned());
7833        client.record_query_read(
7834            QueryOwner {
7835                id: "first",
7836                tables: &["tasks", "tasks"],
7837            },
7838            Some(&generic),
7839        );
7840        client.record_query_read(
7841            QueryOwner {
7842                id: "second",
7843                tables: &["tasks"],
7844            },
7845            Some(&generic),
7846        );
7847        client.record_query_read(
7848            QueryOwner {
7849                id: "first",
7850                tables: &["docs"],
7851            },
7852            Some(&io),
7853        );
7854        let failures = &client
7855            .diagnostics_snapshot(&Default::default())
7856            .expect("diagnostics")
7857            .query_failures;
7858        assert_eq!(
7859            failures
7860                .iter()
7861                .map(|entry| entry.id.as_str())
7862                .collect::<Vec<_>>(),
7863            vec!["second", "first"]
7864        );
7865        assert_eq!(failures[1].tables, vec!["docs"]);
7866        assert_eq!(failures[1].code, "client.storage_io");
7867
7868        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7869            client.record_query_read(
7870                QueryOwner {
7871                    id: &format!("query-{index}"),
7872                    tables: &["tasks"],
7873                },
7874                Some(&generic),
7875            );
7876        }
7877        let failures = &client
7878            .diagnostics_snapshot(&Default::default())
7879            .expect("bounded diagnostics")
7880            .query_failures;
7881        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7882        assert_eq!(failures.last().expect("last failure").id, "query-256");
7883
7884        client
7885            .query_snapshot(
7886                "SELECT id FROM tasks",
7887                &[],
7888                &[],
7889                Some(QueryOwner {
7890                    id: "query-256",
7891                    tables: &["tasks"],
7892                }),
7893            )
7894            .expect("successful owned read");
7895        assert!(client
7896            .diagnostics_snapshot(&Default::default())
7897            .expect("cleared diagnostics")
7898            .query_failures
7899            .iter()
7900            .all(|entry| entry.id != "query-256"));
7901    }
7902
7903    #[test]
7904    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7905        let mut client = client();
7906        let image = Connection::open_in_memory().unwrap();
7907        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7908            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7909            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7910            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7911            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7912        let table = client.schema.table("tasks").unwrap().clone();
7913        let scopes = vec![("project_id".into(), vec!["p1".into()])];
7914        assert!(client
7915            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7916            .is_err());
7917        assert!(client.conn.is_autocommit());
7918        assert_eq!(
7919            client
7920                .query("SELECT count(*) AS n FROM tasks", &[])
7921                .unwrap()[0]["n"],
7922            1024
7923        );
7924        assert_eq!(client.drain_change_batches().len(), 1);
7925        image
7926            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7927            .unwrap();
7928        assert!(matches!(
7929            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7930            Ok(2050)
7931        ));
7932        assert_eq!(
7933            client
7934                .query("SELECT count(*) AS n FROM tasks", &[])
7935                .unwrap()[0]["n"],
7936            2050
7937        );
7938        assert_eq!(client.drain_change_batches().len(), 3);
7939    }
7940
7941    #[test]
7942    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7943        let mut client = client();
7944        let base = WindowBase {
7945            table: "tasks".into(),
7946            variable: "project_id".into(),
7947            fixed_scopes: Vec::new(),
7948            params: None,
7949        };
7950        client
7951            .set_window(&base, &["p1".into(), "p2".into()])
7952            .unwrap();
7953        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7954            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7955            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7956            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7957            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7958        client.drain_change_batches();
7959        assert!(client.set_window(&base, &["p2".into()]).is_err());
7960        assert!(client.conn.is_autocommit());
7961        assert_eq!(
7962            client
7963                .query("SELECT count(*) AS n FROM tasks", &[])
7964                .unwrap()[0]["n"],
7965            1027
7966        );
7967        assert_eq!(client.window_state(&base).units, vec!["p2"]);
7968        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7969        assert_eq!(client.drain_change_batches().len(), 1);
7970        client
7971            .conn
7972            .execute_batch("DROP TRIGGER interrupt_evict")
7973            .unwrap();
7974        client.drain_pending_evictions().unwrap();
7975        assert!(client.load_pending_evictions().unwrap().is_empty());
7976        assert_eq!(
7977            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7978            "held"
7979        );
7980        assert_eq!(client.drain_change_batches().len(), 2);
7981    }
7982
7983    #[test]
7984    fn eviction_marker_failures_roll_back_window_changes() {
7985        let mut client = client();
7986        let base = WindowBase {
7987            table: "tasks".into(),
7988            variable: "project_id".into(),
7989            fixed_scopes: Vec::new(),
7990            params: None,
7991        };
7992        client.set_window(&base, &["p1".into()]).unwrap();
7993        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7994            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7995            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7996            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7997        let revision = client.local_revision();
7998        assert!(client.set_window(&base, &[]).is_err());
7999        assert!(client.conn.is_autocommit());
8000        assert_eq!(client.local_revision(), revision);
8001        assert_eq!(
8002            client
8003                .query("SELECT count(*) AS n FROM tasks", &[])
8004                .unwrap()[0]["n"],
8005            2050
8006        );
8007        assert_eq!(client.window_state(&base).units, vec!["p1"]);
8008        client.conn.execute_batch("DROP TRIGGER fail_marker;
8009            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
8010        assert!(client.set_window(&base, &[]).is_err());
8011        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
8012            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
8013        assert!(client.drain_pending_evictions().is_err());
8014        assert!(client.conn.is_autocommit());
8015        assert_eq!(
8016            client
8017                .query("SELECT count(*) AS n FROM tasks", &[])
8018                .unwrap()[0]["n"],
8019            2
8020        );
8021        assert!(client.set_window(&base, &["p1".into()]).is_err());
8022        assert!(client.conn.is_autocommit());
8023        assert!(client.window_state(&base).units.is_empty());
8024        assert!(client.subs.is_empty());
8025        assert_eq!(
8026            client
8027                .query(
8028                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
8029                    &[]
8030                )
8031                .unwrap()[0]["n"],
8032            1
8033        );
8034        client
8035            .conn
8036            .execute_batch("DROP TRIGGER fail_marker")
8037            .unwrap();
8038        client.set_window(&base, &["p1".into()]).unwrap();
8039        assert_eq!(client.window_state(&base).units, vec!["p1"]);
8040    }
8041
8042    #[test]
8043    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
8044        let mut client = client();
8045        client
8046            .conn
8047            .execute(
8048                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
8049                [],
8050            )
8051            .unwrap();
8052        assert_eq!(
8053            client.drain_pending_evictions().unwrap_err(),
8054            "sync.local_corrupt: invalid pending eviction scopes"
8055        );
8056        assert!(client.conn.is_autocommit());
8057        assert!(client.enqueue_startup_sync_if_needed().is_err());
8058    }
8059
8060    #[test]
8061    fn image_chunks_reject_null_primary_keys() {
8062        let mut client = client();
8063        let image = Connection::open_in_memory().unwrap();
8064        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8065            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
8066            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
8067            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8068            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
8069        let table = client.schema.table("tasks").unwrap().clone();
8070        assert!(client
8071            .apply_sqlite_image(
8072                &image,
8073                &table,
8074                true,
8075                &[("project_id".into(), vec!["p1".into()])],
8076                1025,
8077                7,
8078                "digest"
8079            )
8080            .is_err());
8081        assert!(client.conn.is_autocommit());
8082        assert_eq!(
8083            client
8084                .query("SELECT count(*) AS n FROM tasks", &[])
8085                .unwrap()[0]["n"],
8086            0
8087        );
8088    }
8089
8090    #[test]
8091    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
8092        for images in [false, true] {
8093            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8094                "name":name,"primaryKey":"id","columns":[
8095                    {"name":"id","type":"string","nullable":false},
8096                    {"name":"project_id","type":"string","nullable":false},
8097                    {"name":"title","type":"string","nullable":false}],
8098                "scopes":[{"pattern":"project:{project_id}"}],
8099                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
8100                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8101            })))});
8102            let mut actual =
8103                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
8104            let mut reference =
8105                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
8106            let scopes = vec![("project_id".into(), vec!["p1".into()])];
8107            for client in [&mut actual, &mut reference] {
8108                client
8109                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
8110                    .unwrap();
8111                client.subs[0].effective = Some(scopes.clone());
8112                for (table, id, title) in [
8113                    ("tasks", "a", "original"),
8114                    ("tasks", "b", "occupied"),
8115                    ("tasks", "c", "deleted"),
8116                    ("notes", "n", "untouched"),
8117                ] {
8118                    client
8119                        .write_base_row(
8120                            table,
8121                            &vec![
8122                                Some(ColumnValue::String(id.into())),
8123                                Some(ColumnValue::String("p1".into())),
8124                                Some(ColumnValue::String(title.into())),
8125                            ],
8126                            1,
8127                        )
8128                        .unwrap();
8129                }
8130                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8131                client
8132                    .mutate(vec![
8133                        Mutation::Upsert {
8134                            table: "tasks".into(),
8135                            values: Map::from_iter([
8136                                ("id".into(), Value::from("a")),
8137                                ("project_id".into(), Value::from("p1")),
8138                                ("title".into(), Value::from("available")),
8139                            ]),
8140                            base_version: None,
8141                        },
8142                        Mutation::Delete {
8143                            table: "tasks".into(),
8144                            row_id: "c".into(),
8145                            base_version: None,
8146                        },
8147                        Mutation::Upsert {
8148                            table: "tasks".into(),
8149                            values: Map::from_iter([
8150                                ("id".into(), Value::from("local")),
8151                                ("project_id".into(), Value::from("p1")),
8152                                ("title".into(), Value::from("local value")),
8153                            ]),
8154                            base_version: None,
8155                        },
8156                        Mutation::Upsert {
8157                            table: "notes".into(),
8158                            values: Map::from_iter([
8159                                ("id".into(), Value::from("n")),
8160                                ("project_id".into(), Value::from("p1")),
8161                                ("title".into(), Value::from("pending note")),
8162                            ]),
8163                            base_version: None,
8164                        },
8165                    ])
8166                    .unwrap();
8167            }
8168            let pending = actual.pending_commit_ids();
8169            actual.overlay_rebuild_count.set(0);
8170            for (id, title) in [
8171                ("b", "available"),
8172                ("b", "occupied"),
8173                ("a", "server original"),
8174                ("d", "local value"),
8175                ("d", "released"),
8176                ("c", "redelivered"),
8177            ] {
8178                let table = actual.schema.table("tasks").unwrap().clone();
8179                let values = vec![
8180                    Some(ColumnValue::String(id.into())),
8181                    Some(ColumnValue::String("p1".into())),
8182                    Some(ColumnValue::String(title.into())),
8183                ];
8184                if images {
8185                    let image = Connection::open_in_memory().unwrap();
8186                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8187                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8188                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8189                    image
8190                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8191                        .unwrap();
8192                    assert!(matches!(
8193                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8194                        Ok(1)
8195                    ));
8196                } else {
8197                    let segment = RowsSegment {
8198                        table: "tasks".into(),
8199                        schema_version: 1,
8200                        columns: table.wire_columns.clone(),
8201                        blocks: vec![vec![ssp2::segment::SegmentRow {
8202                            server_version: 2,
8203                            values: values.clone(),
8204                        }]],
8205                    };
8206                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8207                }
8208                reference.write_base_row("tasks", &values, 2).unwrap();
8209                reference.rebuild_overlay().expect("rebuild overlay");
8210                for table in ["tasks", "notes"] {
8211                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),format!("SELECT _syncular_source_id,title FROM {table}_fts ORDER BY _syncular_source_id"),format!("SELECT source_id FROM _syncular_fts_{table}_fts ORDER BY source_id")] {
8212                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8213                    }
8214                }
8215                assert_eq!(actual.pending_commit_ids(), pending);
8216            }
8217            assert_eq!(
8218                actual.overlay_rebuild_count.get(),
8219                0,
8220                "each chunk must leave unrelated tables alone"
8221            );
8222            let before = actual
8223                .query("SELECT * FROM tasks ORDER BY id", &[])
8224                .unwrap();
8225            let fts_before = actual
8226                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8227                .unwrap();
8228            let revision = actual.local_revision();
8229            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8230            let table = actual.schema.table("tasks").unwrap().clone();
8231            let segment = RowsSegment {
8232                table: "tasks".into(),
8233                schema_version: 1,
8234                columns: table.wire_columns.clone(),
8235                blocks: vec![vec![ssp2::segment::SegmentRow {
8236                    server_version: 3,
8237                    values: vec![
8238                        Some(ColumnValue::String("b".into())),
8239                        Some(ColumnValue::String("p1".into())),
8240                        Some(ColumnValue::String("fresh".into())),
8241                    ],
8242                }]],
8243            };
8244            assert!(actual.apply_segment(0, &segment, false).is_err());
8245            assert!(actual.conn.is_autocommit());
8246            assert_eq!(
8247                actual
8248                    .query("SELECT * FROM tasks ORDER BY id", &[])
8249                    .unwrap(),
8250                before
8251            );
8252            assert_eq!(
8253                actual
8254                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8255                    .unwrap(),
8256                fts_before
8257            );
8258            assert_eq!(actual.local_revision(), revision);
8259            assert_eq!(actual.pending_commit_ids(), pending);
8260        }
8261    }
8262
8263    #[test]
8264    fn import_reconciliation_preserves_typed_pending_primary_keys() {
8265        for (kind, a, b) in [
8266            ("string", json!("a"), json!("b")),
8267            ("integer", json!(1), json!(2)),
8268            ("boolean", json!(true), json!(false)),
8269            ("json", json!("1"), json!("2")),
8270        ] {
8271            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[{"name":"id","type":kind,"nullable":false},{"name":"title","type":"string","nullable":false}],"scopes":[],"indexes":[{"name":"unique_title","columns":["title"],"unique":true}]}]});
8272            for images in [false, true] {
8273                let mut client =
8274                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8275                let table = client.schema.table("tasks").unwrap().clone();
8276                client
8277                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
8278                    .unwrap();
8279                for (id, title) in [(&a, "original"), (&b, "occupied")] {
8280                    client
8281                        .write_base_row(
8282                            "tasks",
8283                            &vec![
8284                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8285                                Some(ColumnValue::String(title.into())),
8286                            ],
8287                            1,
8288                        )
8289                        .unwrap();
8290                }
8291                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8292                client
8293                    .mutate(vec![Mutation::Upsert {
8294                        table: "tasks".into(),
8295                        values: Map::from_iter([
8296                            ("id".into(), a.clone()),
8297                            ("title".into(), json!("available")),
8298                        ]),
8299                        base_version: None,
8300                    }])
8301                    .unwrap();
8302                client.overlay_rebuild_count.set(0);
8303                for (title, expected) in [("available", "original"), ("occupied", "available")] {
8304                    let values = vec![
8305                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8306                        Some(ColumnValue::String(title.into())),
8307                    ];
8308                    if images {
8309                        let image = Connection::open_in_memory().unwrap();
8310                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8311                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8312                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8313                        image
8314                            .execute(
8315                                "INSERT INTO tasks VALUES (?1,?2,2)",
8316                                rusqlite::params![RowParam::Cell(&values[0]), title],
8317                            )
8318                            .unwrap();
8319                        assert!(
8320                            matches!(
8321                                client.apply_sqlite_image(
8322                                    &image,
8323                                    &table,
8324                                    false,
8325                                    &[],
8326                                    1,
8327                                    7,
8328                                    "digest"
8329                                ),
8330                                Ok(1)
8331                            ),
8332                            "{kind}, images={images}"
8333                        );
8334                    } else {
8335                        let segment = RowsSegment {
8336                            table: "tasks".into(),
8337                            schema_version: 1,
8338                            columns: table.wire_columns.clone(),
8339                            blocks: vec![vec![ssp2::segment::SegmentRow {
8340                                server_version: 2,
8341                                values,
8342                            }]],
8343                        };
8344                        assert!(
8345                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
8346                            "{kind}"
8347                        );
8348                    }
8349                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8350                    let actual: String = client
8351                        .conn
8352                        .query_row(
8353                            "SELECT title FROM tasks WHERE id=?1",
8354                            [RowParam::Cell(&key)],
8355                            |row| row.get(0),
8356                        )
8357                        .unwrap();
8358                    assert_eq!(actual, expected, "{kind}, images={images}");
8359                }
8360                assert_eq!(client.overlay_rebuild_count.get(), 0);
8361            }
8362        }
8363    }
8364
8365    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8366        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8367            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8368            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8369            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8370        })))});
8371        let mut client =
8372            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8373        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<8192) INSERT INTO _syncular_base_notes SELECT printf('%08d',x),'p2','note-'||x,1 FROM n").unwrap();
8374        client.rebuild_overlay().expect("rebuild overlay");
8375        client
8376            .mutate(vec![Mutation::Upsert {
8377                table: "tasks".into(),
8378                values: Map::from_iter([
8379                    ("id".into(), json!("offline")),
8380                    ("project_id".into(), json!("p1")),
8381                    ("title".into(), json!("pending edit")),
8382                ]),
8383                base_version: None,
8384            }])
8385            .unwrap();
8386        let image = Connection::open_in_memory().unwrap();
8387        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8388            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8389        image.execute("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<?1) INSERT INTO tasks SELECT printf('%08d',x),'p1','item-'||x,1 FROM n",[rows]).unwrap();
8390        image
8391            .execute(
8392                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8393                [rows],
8394            )
8395            .unwrap();
8396        client.overlay_rebuild_count.set(0);
8397        let table = client.schema.table("tasks").unwrap().clone();
8398        (client, image, table)
8399    }
8400
8401    #[test]
8402    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8403        let (mut client, image, table) = unique_import_fixture(1);
8404        image
8405            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8406            .unwrap();
8407        client
8408            .conn
8409            .execute_batch("PRAGMA max_page_count = 1")
8410            .unwrap();
8411        let effective = [("project_id".into(), vec!["p1".into()])];
8412        assert!(client
8413            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8414            .is_err());
8415        let failure = client.storage_failure.borrow().clone().unwrap();
8416        assert_eq!(failure.code, Some("client.storage_full"));
8417        assert_eq!(failure.sqlite_code, Some(13));
8418        assert!(!failure.message.contains("rollback"));
8419        assert!(client.conn.is_autocommit());
8420        client
8421            .conn
8422            .execute_batch("PRAGMA max_page_count = 1073741823")
8423            .unwrap();
8424        assert!(matches!(
8425            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8426            Ok(1)
8427        ));
8428        assert!(client.conn.is_autocommit());
8429    }
8430
8431    #[test]
8432    fn authoring_storage_failures_are_structured_and_scoped() {
8433        let path = std::env::temp_dir().join(format!(
8434            "syncular-authoring-failure-{}.db",
8435            uuid::Uuid::new_v4()
8436        ));
8437        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8438            {"name":"id","type":"string","nullable":false},
8439            {"name":"project_id","type":"string","nullable":false},
8440            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8441        let mut client = SyncClient::open_path(
8442            "authoring".into(),
8443            &schema,
8444            ClientLimits::default(),
8445            path.to_str().unwrap(),
8446        )
8447        .expect("open client");
8448        client.create_synced_tables().expect("create synced tables");
8449        // Return BUSY immediately rather than waiting out the file timeout.
8450        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8451
8452        let upsert = |id: &str| {
8453            vec![Mutation::Upsert {
8454                table: "tasks".into(),
8455                values: Map::from_iter([
8456                    ("id".into(), json!(id)),
8457                    ("project_id".into(), json!("p1")),
8458                    ("title".into(), json!("first")),
8459                ]),
8460                base_version: None,
8461            }]
8462        };
8463
8464        let locker = Connection::open(&path).expect("second connection");
8465        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8466        let busy = client.mutate(upsert("t1")).expect_err("busy write");
8467        assert_eq!(busy.code, "client.storage_busy");
8468        assert!(busy.retryable);
8469        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8470        assert!(client.pending_commit_ids().is_empty());
8471        assert_eq!(client.local_revision(), 0);
8472
8473        // A stale classified failure must not classify a later unrelated one.
8474        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8475        assert_eq!(invalid.code, "sync.invalid_request");
8476        assert_eq!(invalid.message, "the authoring request is invalid");
8477        assert!(
8478            invalid.details.as_ref().unwrap()["legacyCause"]
8479                .as_str()
8480                .unwrap()
8481                .contains("at least one operation"),
8482            "{invalid:?}"
8483        );
8484
8485        locker.execute_batch("ROLLBACK").unwrap();
8486        let id = client.mutate(upsert("t1")).expect("retry after busy");
8487        assert_eq!(client.pending_commit_ids(), vec![id]);
8488        assert_eq!(client.local_revision(), 1);
8489
8490        // A later storage failure reports its own code and details.
8491        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8492        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8493        assert_eq!(busy_again.code, "client.storage_busy");
8494        assert!(busy_again.retryable);
8495        locker.execute_batch("ROLLBACK").unwrap();
8496
8497        // Non-retryable classified failures keep their sqlite evidence too.
8498        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8499            rusqlite::ffi::Error::new(13),
8500            Some("database or disk is full".to_owned()),
8501        )));
8502        assert_eq!(full.code, "client.storage_full");
8503        assert!(!full.retryable);
8504        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8505
8506        drop(locker);
8507        drop(client);
8508        let _ = std::fs::remove_file(&path);
8509    }
8510
8511    #[test]
8512    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8513        let path = std::env::temp_dir().join(format!(
8514            "syncular-caller-values-{}.db",
8515            uuid::Uuid::new_v4()
8516        ));
8517        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8518            {"name":"id","type":"string","nullable":false},
8519            {"name":"project_id","type":"string","nullable":false},
8520            {"name":"title","type":"string","nullable":false},
8521            {"name":"priority","type":"integer","nullable":false},
8522            {"name":"payload","type":"bytes","nullable":false},
8523            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8524        let mut client = SyncClient::open_path(
8525            "caller-values".into(),
8526            &schema,
8527            ClientLimits::default(),
8528            path.to_str().unwrap(),
8529        )
8530        .expect("open client");
8531        client.create_synced_tables().expect("create synced tables");
8532
8533        let values = |id: &str| {
8534            Map::from_iter([
8535                ("id".to_owned(), json!(id)),
8536                ("project_id".to_owned(), json!("p1")),
8537                ("title".to_owned(), json!("first")),
8538                ("priority".to_owned(), json!(1)),
8539                ("payload".to_owned(), json!({"$bytes": "0a"})),
8540            ])
8541        };
8542        let upsert = |values: Map<String, Value>| {
8543            vec![Mutation::Upsert {
8544                table: "tasks".into(),
8545                values,
8546                base_version: None,
8547            }]
8548        };
8549        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8550        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8551
8552        // §6.1/§7.1: every caller value the authoring seam rejects reports the
8553        // same stable identity instead of the internal-failure default, keeps
8554        // the dynamic cause in `details.legacyCause`, and leaves the durable
8555        // outbox, the local revision and the projection untouched.
8556        for (label, override_value, cause) in [
8557            ("unknown column", ("nope", json!(1)), "unknown column"),
8558            (
8559                "internal sync column",
8560                ("_sync_version", json!(1)),
8561                "internal sync column",
8562            ),
8563            ("wrong value type", ("title", json!(7)), "expected a string"),
8564            (
8565                "malformed bytes envelope",
8566                ("payload", json!({"$bytes": "zz"})),
8567                "expected exactly",
8568            ),
8569            (
8570                "odd-length bytes envelope",
8571                ("payload", json!({"$bytes": "abc"})),
8572                "expected exactly",
8573            ),
8574            (
8575                "plus-sign bytes envelope",
8576                ("payload", json!({"$bytes": "+a"})),
8577                "expected exactly",
8578            ),
8579            (
8580                "bytes envelope with a second key",
8581                ("payload", json!({"$bytes": "0a", "extra": 1})),
8582                "expected exactly",
8583            ),
8584            (
8585                "no bytes envelope",
8586                ("payload", json!({"nested": true})),
8587                "expected exactly",
8588            ),
8589        ] {
8590            let mut full = values("t2");
8591            full.insert(override_value.0.to_owned(), override_value.1);
8592            let error = client.mutate(upsert(full)).expect_err(label);
8593            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8594            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8595            assert!(!error.retryable, "{label}");
8596            let legacy = error.details.as_ref().unwrap()["legacyCause"]
8597                .as_str()
8598                .unwrap();
8599            assert!(
8600                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8601                "{label}: {legacy}"
8602            );
8603            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8604            assert_eq!(client.local_revision(), 1, "{label}");
8605            assert_eq!(
8606                client.query("SELECT * FROM tasks", &[]).unwrap(),
8607                rows,
8608                "{label}"
8609            );
8610        }
8611
8612        // A full-row upsert presents every column, so a missing required one is
8613        // the caller's failure too.
8614        let mut missing = values("t3");
8615        missing.remove("payload");
8616        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8617        assert_eq!(missing_error.code, "sync.invalid_request");
8618        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8619            .as_str()
8620            .unwrap()
8621            .contains("is not nullable"));
8622
8623        // A primary key the wire cannot render is rejected before any op is
8624        // recorded.
8625        let mut bad_row_id = values("t4");
8626        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8627        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8628        assert_eq!(row_id_error.code, "sync.invalid_request");
8629
8630        // The sparse route classifies the same value failures.
8631        for (label, partial, cause) in [
8632            (
8633                "patch wrong value type",
8634                ("priority", json!("high")),
8635                "expected an integer",
8636            ),
8637            ("patch unknown column", ("nope", json!(1)), "unknown column"),
8638        ] {
8639            let error = client
8640                .patch(
8641                    "tasks",
8642                    "t1",
8643                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
8644                    None,
8645                )
8646                .expect_err(label);
8647            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8648            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8649            assert!(
8650                error.details.as_ref().unwrap()["legacyCause"]
8651                    .as_str()
8652                    .unwrap()
8653                    .contains(cause),
8654                "{label}"
8655            );
8656        }
8657        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8658        assert_eq!(client.local_revision(), 1);
8659        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8660
8661        // Nullable explicit null, an empty envelope, and uppercase digits stay
8662        // accepted; the stored payload is the decoded byte sequence.
8663        for (label, payload) in [
8664            ("explicit null", json!(null)),
8665            ("empty envelope", json!({"$bytes": ""})),
8666            ("uppercase envelope", json!({"$bytes": "0AFF"})),
8667        ] {
8668            let mut accepted = values("t-ok");
8669            accepted.insert("extra".to_owned(), payload);
8670            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8671                panic!("{label} must be accepted: {error:?}");
8672            });
8673            assert!(!encoded.is_empty(), "{label}");
8674        }
8675
8676        // Nothing is poisoned: the next valid authoring call succeeds.
8677        let next = client
8678            .mutate(upsert(values("t5")))
8679            .expect("valid after failures");
8680        assert_ne!(next, seed);
8681
8682        drop(client);
8683        let _ = std::fs::remove_file(&path);
8684    }
8685
8686    #[test]
8687    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8688        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8689            {"name":"id","type":"string","nullable":false},
8690            {"name":"project_id","type":"string","nullable":false},
8691            {"name":"payload","type":"bytes","nullable":true}],
8692            "scopes":[{"pattern":"project:{project_id}"}]}]});
8693        let open = |path: &std::path::Path| {
8694            SyncClient::open_path(
8695                "legacy-values".into(),
8696                &schema,
8697                ClientLimits::default(),
8698                path.to_str().unwrap(),
8699            )
8700            .expect("open client")
8701        };
8702        let row = |id: &str| {
8703            Map::from_iter([
8704                ("id".to_owned(), json!(id)),
8705                ("project_id".to_owned(), json!("p1")),
8706                ("payload".to_owned(), json!({"$bytes": "0a"})),
8707            ])
8708        };
8709        let upsert = |values| {
8710            vec![Mutation::Upsert {
8711                table: "tasks".into(),
8712                values,
8713                base_version: None,
8714            }]
8715        };
8716        for (label, stored_cell, representable) in [
8717            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8718            ("non-hex envelope", json!({"$bytes": "zz"}), false),
8719            ("odd-length envelope", json!({"$bytes": "abc"}), false),
8720            ("no envelope", json!({"nested": true}), false),
8721            // A wrong declared type and a stored null for a non-nullable column
8722            // are journalable as stored, so their rejection keeps the operation
8723            // envelope.
8724            ("wrong declared type", json!("high"), true),
8725            ("stored null for a non-nullable column", json!(null), true),
8726        ] {
8727            let path = std::env::temp_dir().join(format!(
8728                "syncular-legacy-values-{}-{}.db",
8729                label.replace(' ', "-"),
8730                uuid::Uuid::new_v4()
8731            ));
8732            let mut client = open(&path);
8733            client.create_synced_tables().expect("create synced tables");
8734            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8735            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8736            drop(client);
8737
8738            // The stored intent an earlier version accepted.
8739            let connection = Connection::open(&path).expect("raw connection");
8740            let (column, cell) = if stored_cell.is_null() {
8741                ("project_id", stored_cell.clone())
8742            } else {
8743                ("payload", stored_cell.clone())
8744            };
8745            let mut planted_values = json!({
8746                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8747            });
8748            planted_values[column] = cell;
8749            let planted = json!([{
8750                "op": "upsert",
8751                "table": "tasks",
8752                "rowId": "t1",
8753                "baseVersion": null,
8754                "values": planted_values
8755            }]);
8756            connection
8757                .execute(
8758                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8759                    rusqlite::params![planted.to_string(), stale],
8760                )
8761                .expect("plant legacy intent");
8762            drop(connection);
8763
8764            // Reopen: the startup reconciliation recovers the commit before any
8765            // replay, so the observable phase matches the browser core.
8766            let reopened = open(&path);
8767            assert_eq!(
8768                reopened.pending_commit_ids(),
8769                vec![successor.clone()],
8770                "{label}"
8771            );
8772            let rejection = reopened
8773                .rejections()
8774                .iter()
8775                .find(|rejection| rejection.client_commit_id == stale)
8776                .unwrap_or_else(|| panic!("{label}: rejection"));
8777            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8778            assert_eq!(
8779                rejection.message, "the persisted commit carries values the current codec refuses",
8780                "{label}"
8781            );
8782            assert_eq!(rejection.operation.is_some(), representable, "{label}");
8783            assert_eq!(
8784                rejection
8785                    .details
8786                    .as_ref()
8787                    .and_then(|details| details.reason.as_deref()),
8788                Some(INVALID_STORED_VALUES_REASON),
8789                "{label}"
8790            );
8791            // The rejection stays readable after a further reopen.
8792            let journal = reopened
8793                .commit_outcome(&stale)
8794                .expect("read outcome")
8795                .expect("journal entry");
8796            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8797            drop(reopened);
8798            let mut reopened = open(&path);
8799            let reread = reopened
8800                .commit_outcome(&stale)
8801                .expect("read outcome")
8802                .expect("journal entry");
8803            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8804
8805            // The legacy row's optimistic projection is gone, the successor
8806            // survives and still encodes, and the round path keeps the queue.
8807            assert_eq!(
8808                reopened
8809                    .query("SELECT id FROM tasks ORDER BY id", &[])
8810                    .unwrap(),
8811                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8812                "{label}"
8813            );
8814            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8815            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8816            assert!(!reopened
8817                .drop_unencodable_outbox(false, None)
8818                .expect("idempotent prepass"));
8819            let mut transport = CountingRealtimeTransport::default();
8820            let outcome = reopened.sync(&mut transport);
8821            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8822            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8823            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8824            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8825            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8826            drop(reopened);
8827            let _ = std::fs::remove_file(&path);
8828        }
8829    }
8830
8831    #[test]
8832    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8833        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8834            {"name":"id","type":"string","nullable":false},
8835            {"name":"project_id","type":"string","nullable":false},
8836            {"name":"payload","type":"bytes","nullable":true}],
8837            "scopes":[{"pattern":"project:{project_id}"}]}]});
8838        for (label, column, planted) in [
8839            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8840            (
8841                "stored null for a non-nullable column",
8842                "project_id",
8843                json!(null),
8844            ),
8845            ("wrong declared type", "payload", json!(7)),
8846        ] {
8847            let path = std::env::temp_dir().join(format!(
8848                "syncular-shared-row-{}-{}.db",
8849                label.replace(' ', "-"),
8850                uuid::Uuid::new_v4()
8851            ));
8852            let open = |path: &std::path::Path| {
8853                SyncClient::open_path(
8854                    "shared-row".into(),
8855                    &schema,
8856                    ClientLimits::default(),
8857                    path.to_str().unwrap(),
8858                )
8859                .expect("open client")
8860            };
8861            let row = |id: &str| {
8862                Map::from_iter([
8863                    ("id".to_owned(), json!(id)),
8864                    ("project_id".to_owned(), json!("p1")),
8865                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8866                ])
8867            };
8868            let upsert = |values| {
8869                vec![Mutation::Upsert {
8870                    table: "tasks".into(),
8871                    values,
8872                    base_version: None,
8873                }]
8874            };
8875            let mut client = open(&path);
8876            client.create_synced_tables().expect("create synced tables");
8877            let older = client.mutate(upsert(row("t1"))).expect("older commit");
8878            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8879            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8880            drop(client);
8881
8882            let connection = Connection::open(&path).expect("raw connection");
8883            for commit_id in [&older, &newer] {
8884                let mut values = row("t1");
8885                values.insert(column.to_owned(), planted.clone());
8886                let planted_ops = json!([{
8887                    "op": "upsert",
8888                    "table": "tasks",
8889                    "rowId": "t1",
8890                    "baseVersion": null,
8891                    "values": values
8892                }]);
8893                connection
8894                    .execute(
8895                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8896                        rusqlite::params![planted_ops.to_string(), commit_id],
8897                    )
8898                    .expect("plant legacy intent");
8899            }
8900            drop(connection);
8901
8902            // Startup recovery drops both, FIFO, and publishes one batch.
8903            let mut reopened = open(&path);
8904            assert_eq!(
8905                reopened.pending_commit_ids(),
8906                vec![successor.clone()],
8907                "{label}"
8908            );
8909            assert_eq!(
8910                reopened
8911                    .rejections()
8912                    .iter()
8913                    .map(|rejection| rejection.client_commit_id.clone())
8914                    .collect::<Vec<_>>(),
8915                vec![older.clone(), newer.clone()],
8916                "{label}"
8917            );
8918            assert!(
8919                reopened
8920                    .rejections()
8921                    .iter()
8922                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8923                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8924                            == Some(INVALID_STORED_VALUES_REASON)),
8925                "{label}"
8926            );
8927            for commit_id in [&older, &newer] {
8928                assert!(
8929                    matches!(
8930                        reopened
8931                            .commit_outcome(commit_id)
8932                            .expect("outcome")
8933                            .map(|o| o.status),
8934                        Some(CommitOutcomeStatus::Rejected)
8935                    ),
8936                    "{label}"
8937                );
8938            }
8939            assert_eq!(
8940                reopened
8941                    .query("SELECT id FROM tasks ORDER BY id", &[])
8942                    .unwrap(),
8943                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8944                "{label}"
8945            );
8946            let revision = reopened.local_revision();
8947            let batches = reopened.drain_change_batches();
8948            assert!(
8949                batches.iter().any(|batch| batch.rejections_changed
8950                    && batch.outcomes_changed
8951                    && batch.tables.iter().any(|change| change.table == "tasks")
8952                    && batch.revision == revision.to_string()),
8953                "{label}: {batches:?}"
8954            );
8955            drop(reopened);
8956
8957            // An ordinary reopen with nothing to recover publishes nothing and
8958            // leaves the revision alone.
8959            let mut stable = open(&path);
8960            assert_eq!(stable.local_revision(), revision, "{label}");
8961            assert!(stable.drain_change_batches().is_empty(), "{label}");
8962            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8963            drop(stable);
8964            let _ = std::fs::remove_file(&path);
8965        }
8966    }
8967
8968    #[test]
8969    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8970        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8971            {"name":"id","type":"string","nullable":false},
8972            {"name":"project_id","type":"string","nullable":false},
8973            {"name":"payload","type":"bytes","nullable":true}],
8974            "scopes":[{"pattern":"project:{project_id}"}]}]});
8975        for (label, second_op) in [
8976            (
8977                "delete carrying values",
8978                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8979            ),
8980            (
8981                "baseVersion beyond the safe bound",
8982                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8983                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8984                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8985            ),
8986        ] {
8987            let path = std::env::temp_dir().join(format!(
8988                "syncular-journal-shape-{}-{}.db",
8989                label.replace(' ', "-"),
8990                uuid::Uuid::new_v4()
8991            ));
8992            let open = |path: &std::path::Path| {
8993                SyncClient::open_path(
8994                    "journal-shape".into(),
8995                    &schema,
8996                    ClientLimits::default(),
8997                    path.to_str().unwrap(),
8998                )
8999                .expect("open client")
9000            };
9001            let values = |id: &str| {
9002                Map::from_iter([
9003                    ("id".to_owned(), json!(id)),
9004                    ("project_id".to_owned(), json!("p1")),
9005                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9006                ])
9007            };
9008            let upsert = |values| {
9009                vec![Mutation::Upsert {
9010                    table: "tasks".into(),
9011                    values,
9012                    base_version: None,
9013                }]
9014            };
9015            let mut client = open(&path);
9016            client.create_synced_tables().expect("create synced tables");
9017            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
9018            drop(client);
9019
9020            let connection = Connection::open(&path).expect("raw connection");
9021            // The first operation triggers the drop with canonical cells (a
9022            // stored null for a non-nullable column); the second one is the
9023            // operation shape the journal reader refuses.
9024            let planted = json!([
9025                {
9026                    "op": "upsert",
9027                    "table": "tasks",
9028                    "rowId": "t1",
9029                    "baseVersion": null,
9030                    "values": {"id": "t1", "project_id": null,
9031                        "payload": {"$bytes": "0a"}}
9032                },
9033                second_op
9034            ]);
9035            connection
9036                .execute(
9037                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9038                    rusqlite::params![planted.to_string(), stale],
9039                )
9040                .expect("plant legacy intent");
9041            drop(connection);
9042
9043            let reopened = open(&path);
9044            let journal = reopened
9045                .commit_outcome(&stale)
9046                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
9047                .unwrap_or_else(|| panic!("{label}: journal entry"));
9048            assert!(
9049                matches!(journal.status, CommitOutcomeStatus::Rejected),
9050                "{label}"
9051            );
9052            assert!(journal.operations.is_none(), "{label}");
9053            assert!(
9054                reopened
9055                    .commit_outcomes(CommitOutcomeQuery::default())
9056                    .is_ok(),
9057                "{label}: outcome list query"
9058            );
9059            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
9060            drop(reopened);
9061            let _ = std::fs::remove_file(&path);
9062        }
9063    }
9064
9065    #[test]
9066    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
9067        let schema = |version: i32, extra: bool| {
9068            let mut columns = vec![
9069                json!({"name":"id","type":"string","nullable":false}),
9070                json!({"name":"project_id","type":"string","nullable":false}),
9071                json!({"name":"payload","type":"bytes","nullable":true}),
9072            ];
9073            if extra {
9074                columns.push(json!({"name":"later","type":"string","nullable":true}));
9075            }
9076            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
9077                "scopes":[{"pattern":"project:{project_id}"}]}]})
9078        };
9079        for (label, bump) in [
9080            ("same-version recreation", false),
9081            ("version-bump reset", true),
9082            ("version-only bump reset", true),
9083        ] {
9084            let path = std::env::temp_dir().join(format!(
9085                "syncular-legacy-reset-{}-{}.db",
9086                label.replace(' ', "-"),
9087                uuid::Uuid::new_v4()
9088            ));
9089            let v1 = schema(1, false);
9090            let open = |schema: &Value, path: &std::path::Path| {
9091                SyncClient::open_path(
9092                    "legacy-reset".into(),
9093                    schema,
9094                    ClientLimits::default(),
9095                    path.to_str().unwrap(),
9096                )
9097                .expect("open client")
9098            };
9099            let row = |id: &str| {
9100                Map::from_iter([
9101                    ("id".to_owned(), json!(id)),
9102                    ("project_id".to_owned(), json!("p1")),
9103                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9104                ])
9105            };
9106            let upsert = |values| {
9107                vec![Mutation::Upsert {
9108                    table: "tasks".into(),
9109                    values,
9110                    base_version: None,
9111                }]
9112            };
9113            let mut client = open(&v1, &path);
9114            client.create_synced_tables().expect("create synced tables");
9115            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9116            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9117            drop(client);
9118
9119            let connection = Connection::open(&path).expect("raw connection");
9120            let planted = json!([{
9121                "op": "upsert",
9122                "table": "tasks",
9123                "rowId": "t1",
9124                "baseVersion": null,
9125                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9126            }]);
9127            connection
9128                .execute(
9129                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9130                    rusqlite::params![planted.to_string(), stale],
9131                )
9132                .expect("plant legacy intent");
9133            drop(connection);
9134
9135            let target = if bump {
9136                schema(2, label == "version-bump reset")
9137            } else {
9138                v1.clone()
9139            };
9140            let reopened = open(&target, &path);
9141            let rejection = reopened
9142                .rejections()
9143                .iter()
9144                .find(|rejection| rejection.client_commit_id == stale)
9145                .unwrap_or_else(|| panic!("{label}: rejection"));
9146            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9147            assert_eq!(
9148                rejection
9149                    .details
9150                    .as_ref()
9151                    .and_then(|details| details.reason.as_deref()),
9152                Some(INVALID_STORED_VALUES_REASON),
9153                "{label}"
9154            );
9155            assert!(rejection.operation.is_some(), "{label}");
9156            assert_eq!(
9157                reopened.pending_commit_ids(),
9158                vec![successor.clone()],
9159                "{label}"
9160            );
9161            // The successor survives and still encodes. It carries §7.1
9162            // insert intent, so after a bump that appends a nullable column it
9163            // replays as a full row with that column NULL.
9164            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9165            if label == "version-bump reset" {
9166                assert_eq!(
9167                    reopened
9168                        .query("SELECT id, later FROM tasks ORDER BY id", &[])
9169                        .unwrap(),
9170                    vec![Map::from_iter([
9171                        ("id".to_owned(), json!("t2")),
9172                        ("later".to_owned(), Value::Null),
9173                    ])],
9174                    "{label}"
9175                );
9176                // The guarded bump reset owns the publication, so its change
9177                // batch carries the recovery's rejection, outcome, and table
9178                // domains at the revision it advanced to.
9179                let mut next = reopened;
9180                let revision = next.local_revision();
9181                let batches = next.drain_change_batches();
9182                assert!(
9183                    batches.iter().any(|batch| batch.rejections_changed
9184                        && batch.outcomes_changed
9185                        && batch.tables.iter().any(|change| change.table == "tasks")
9186                        && batch.revision == revision.to_string()),
9187                    "{label}: {batches:?}"
9188                );
9189                next.mutate(upsert(Map::from_iter([
9190                    ("id".to_owned(), json!("t3")),
9191                    ("project_id".to_owned(), json!("p1")),
9192                    ("payload".to_owned(), json!({"$bytes": "0b"})),
9193                    ("later".to_owned(), json!("v2")),
9194                ])))
9195                .expect("v2 authoring after the reset");
9196                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9197                assert_eq!(next.pending_payloads().len(), 2, "{label}");
9198                drop(next);
9199            } else {
9200                assert_eq!(
9201                    reopened
9202                        .query("SELECT id FROM tasks ORDER BY id", &[])
9203                        .unwrap(),
9204                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9205                    "{label}"
9206                );
9207                drop(reopened);
9208            }
9209
9210            // Queue and corrupt a new commit while the client stays open so only
9211            // recreation can recover it.
9212            if !bump {
9213                let mut recreated = open(&v1, &path);
9214                let fresh = recreated
9215                    .mutate(upsert(row("t3")))
9216                    .expect("fresh commit while open");
9217                recreated.drain_change_batches();
9218                let planted_fresh = json!([{
9219                    "op": "upsert",
9220                    "table": "tasks",
9221                    "rowId": "t3",
9222                    "baseVersion": null,
9223                    "values": {"id": "t3", "project_id": "p1",
9224                        "payload": {"$bytes": "zz"}}
9225                }]);
9226                let connection = Connection::open(&path).expect("raw connection");
9227                let affected = connection
9228                    .execute(
9229                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9230                        rusqlite::params![planted_fresh.to_string(), fresh],
9231                    )
9232                    .expect("plant fresh legacy intent");
9233                assert_eq!(
9234                    affected, 1,
9235                    "{label}: the in-place plant must hit the queued commit"
9236                );
9237                drop(connection);
9238                let revision_before = recreated.local_revision();
9239                recreated
9240                    .recreate_with_schema(&v1)
9241                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9242                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9243                let fresh_rejection = recreated
9244                    .rejections()
9245                    .iter()
9246                    .find(|rejection| rejection.client_commit_id == fresh)
9247                    .expect("the in-place recovery rejection");
9248                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9249                assert_eq!(
9250                    fresh_rejection
9251                        .details
9252                        .as_ref()
9253                        .and_then(|details| details.reason.as_deref()),
9254                    Some(INVALID_STORED_VALUES_REASON),
9255                    "{label}"
9256                );
9257                assert!(
9258                    matches!(
9259                        recreated
9260                            .commit_outcome(&fresh)
9261                            .expect("outcome read")
9262                            .map(|outcome| outcome.status),
9263                        Some(CommitOutcomeStatus::Rejected)
9264                    ),
9265                    "{label}"
9266                );
9267                assert!(recreated.local_revision() > revision_before, "{label}");
9268                let revision = recreated.local_revision();
9269                let batches = recreated.drain_change_batches();
9270                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9271                assert!(
9272                    batches.iter().any(|batch| batch.rejections_changed
9273                        && batch.outcomes_changed
9274                        && batch.tables.iter().any(|change| change.table == "tasks")
9275                        && batch.revision == revision.to_string()),
9276                    "{label}: {batches:?}"
9277                );
9278                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9279                assert_eq!(
9280                    recreated
9281                        .query("SELECT id FROM tasks ORDER BY id", &[])
9282                        .unwrap(),
9283                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9284                    "{label}: the recovered commit leaves no projection"
9285                );
9286                drop(recreated);
9287            }
9288            let _ = std::fs::remove_file(&path);
9289        }
9290    }
9291
9292    #[test]
9293    fn legacy_values_recover_over_a_synced_authoritative_row() {
9294        let path = std::env::temp_dir().join(format!(
9295            "syncular-authoritative-row-{}.db",
9296            uuid::Uuid::new_v4()
9297        ));
9298        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9299            {"name":"id","type":"string","nullable":false},
9300            {"name":"project_id","type":"string","nullable":false},
9301            {"name":"title","type":"string","nullable":false},
9302            {"name":"payload","type":"bytes","nullable":true}],
9303            "scopes":[{"pattern":"project:{project_id}"}]}]});
9304        let open = |path: &std::path::Path| {
9305            SyncClient::open_path(
9306                "authoritative-row".into(),
9307                &schema,
9308                ClientLimits::default(),
9309                path.to_str().unwrap(),
9310            )
9311            .expect("open client")
9312        };
9313        let row = |title: &str| {
9314            Map::from_iter([
9315                ("id".to_owned(), json!("t1")),
9316                ("project_id".to_owned(), json!("p1")),
9317                ("title".to_owned(), json!(title)),
9318                ("payload".to_owned(), json!({"$bytes": "0a"})),
9319            ])
9320        };
9321        let upsert = |values| {
9322            vec![Mutation::Upsert {
9323                table: "tasks".into(),
9324                values,
9325                base_version: None,
9326            }]
9327        };
9328        let mut client = open(&path);
9329        client.create_synced_tables().expect("create synced tables");
9330        // A server-confirmed row: base and visible carry the authoritative
9331        // values at version 1.
9332        client
9333            .conn
9334            .execute_batch(
9335                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9336                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9337            )
9338            .expect("seed authoritative row");
9339        let older = client.mutate(upsert(row("older"))).expect("older");
9340        let newer = client.mutate(upsert(row("newer"))).expect("newer");
9341        let successor = client.mutate(upsert(row("successor"))).expect("successor");
9342        drop(client);
9343
9344        let connection = Connection::open(&path).expect("raw connection");
9345        for commit_id in [&older, &newer] {
9346            let planted = json!([{
9347                "op": "upsert",
9348                "table": "tasks",
9349                "rowId": "t1",
9350                "baseVersion": null,
9351                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9352                    "payload": {"$bytes": "zz"}}
9353            }]);
9354            connection
9355                .execute(
9356                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9357                    rusqlite::params![planted.to_string(), commit_id],
9358                )
9359                .expect("plant legacy intent");
9360        }
9361        drop(connection);
9362
9363        let reopened = open(&path);
9364        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9365        assert_eq!(
9366            reopened
9367                .rejections()
9368                .iter()
9369                .map(|rejection| rejection.client_commit_id.clone())
9370                .collect::<Vec<_>>(),
9371            vec![older.clone(), newer.clone()]
9372        );
9373        // The pending successor overlays the restored authoritative row, which
9374        // keeps its server version.
9375        assert_eq!(
9376            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9377            Ok(vec![Map::from_iter([
9378                ("id".to_owned(), json!("t1")),
9379                ("title".to_owned(), json!("successor")),
9380                ("_syncular_version".to_owned(), json!(1)),
9381            ])])
9382        );
9383
9384        // A later rejection of the surviving successor restores the
9385        // authoritative values and version exactly.
9386        drop(reopened);
9387        let connection = Connection::open(&path).expect("raw connection");
9388        connection
9389            .execute(
9390                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9391                rusqlite::params![
9392                    json!([{
9393                        "op": "upsert",
9394                        "table": "tasks",
9395                        "rowId": "t1",
9396                        "baseVersion": null,
9397                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9398                            "payload": {"$bytes": "zz"}}
9399                    }])
9400                    .to_string(),
9401                    successor
9402                ],
9403            )
9404            .expect("plant successor intent");
9405        drop(connection);
9406        let late = open(&path);
9407        assert!(late.pending_commit_ids().is_empty());
9408        assert_eq!(
9409            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9410            Ok(vec![Map::from_iter([
9411                ("id".to_owned(), json!("t1")),
9412                ("title".to_owned(), json!("authoritative")),
9413                ("_syncular_version".to_owned(), json!(1)),
9414            ])])
9415        );
9416        drop(late);
9417        let _ = std::fs::remove_file(&path);
9418    }
9419
9420    #[test]
9421    fn a_failed_value_recovery_commit_restores_every_side_effect() {
9422        let path = std::env::temp_dir().join(format!(
9423            "syncular-value-recovery-commit-{}.db",
9424            uuid::Uuid::new_v4()
9425        ));
9426        let path_str = path.to_str().expect("path");
9427        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9428            {"name":"id","type":"string","nullable":false},
9429            {"name":"project_id","type":"string","nullable":false},
9430            {"name":"payload","type":"bytes","nullable":true}],
9431            "scopes":[{"pattern":"project:{project_id}"}]}]});
9432        // A rollback-journal replica, so a second read connection can hold
9433        // SHARED across the recovery transaction's COMMIT.
9434        let conn = Connection::open(path_str).expect("replica connection");
9435        let mut client = SyncClient::with_connection(
9436            "value-recovery-commit".into(),
9437            &schema,
9438            ClientLimits::default(),
9439            conn,
9440        )
9441        .expect("replica");
9442        assert_eq!(
9443            client
9444                .conn
9445                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9446                .expect("journal mode"),
9447            "delete"
9448        );
9449        client.create_synced_tables().expect("create synced tables");
9450        let stale = client
9451            .mutate(vec![Mutation::Upsert {
9452                table: "tasks".into(),
9453                values: Map::from_iter([
9454                    ("id".to_owned(), json!("t1")),
9455                    ("project_id".to_owned(), json!("p1")),
9456                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9457                ]),
9458                base_version: None,
9459            }])
9460            .expect("queued write");
9461        client.drain_change_batches();
9462        let affected = client
9463            .conn
9464            .execute(
9465                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9466                rusqlite::params![
9467                    json!([{
9468                        "op": "upsert",
9469                        "table": "tasks",
9470                        "rowId": "t1",
9471                        "baseVersion": null,
9472                        "values": {"id": "t1", "project_id": "p1",
9473                            "payload": {"$bytes": "zz"}}
9474                    }])
9475                    .to_string(),
9476                    stale
9477                ],
9478            )
9479            .expect("plant legacy intent");
9480        assert_eq!(affected, 1, "the planted row must exist");
9481        let revision = client.local_revision();
9482        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9483        let prior_outbox = client.pending_commit_ids();
9484        let prior_rejections = client.rejections().len();
9485        let prior_changes = client.change_queue.len();
9486        let prior_intents = client.sync_intent_queue.len();
9487        let prior_failed = client.failed_commits.len();
9488        // The reader holds SHARED for the whole attempt: the recovery runs to
9489        // its publication and the COMMIT fails.
9490        let reader = Connection::open(path_str).expect("reader");
9491        reader
9492            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9493            .expect("reader snapshot");
9494        client
9495            .conn
9496            .busy_timeout(std::time::Duration::ZERO)
9497            .expect("busy timeout");
9498        let error = client
9499            .recreate_with_schema(&schema)
9500            .expect_err("a blocked COMMIT must refuse the recovery");
9501        assert!(error.starts_with("client.storage_busy:"), "{error}");
9502        assert!(client.conn.is_autocommit(), "the transaction is released");
9503        assert_eq!(client.local_revision(), revision);
9504        assert_eq!(client.pending_commit_ids(), prior_outbox);
9505        assert_eq!(client.rejections().len(), prior_rejections);
9506        assert_eq!(client.change_queue.len(), prior_changes);
9507        assert_eq!(client.sync_intent_queue.len(), prior_intents);
9508        assert_eq!(client.failed_commits.len(), prior_failed);
9509        assert_eq!(
9510            client.query("SELECT * FROM tasks", &[]).expect("rows"),
9511            rows
9512        );
9513        assert!(
9514            client
9515                .commit_outcome(&stale)
9516                .expect("outcome read")
9517                .is_none(),
9518            "the rolled-back recovery kept no outcome"
9519        );
9520        // The retry runs the same publication branch to completion.
9521        reader.execute_batch("ROLLBACK").expect("release reader");
9522        client
9523            .recreate_with_schema(&schema)
9524            .expect("retry after the blocked commit");
9525        assert!(client.pending_commit_ids().is_empty());
9526        assert_eq!(client.rejections().len(), prior_rejections + 1);
9527        assert!(client.local_revision() > revision);
9528        let batches = client.drain_change_batches();
9529        assert!(
9530            batches.iter().any(|batch| batch.rejections_changed
9531                && batch.outcomes_changed
9532                && batch.tables.iter().any(|change| change.table == "tasks")),
9533            "{batches:?}"
9534        );
9535        drop(client);
9536        let _ = std::fs::remove_file(&path);
9537    }
9538
9539    #[cfg(feature = "e2ee")]
9540    #[test]
9541    fn a_failed_recovery_restores_retained_failed_commits() {
9542        let path = std::env::temp_dir().join(format!(
9543            "syncular-value-recovery-retention-{}.db",
9544            uuid::Uuid::new_v4()
9545        ));
9546        let path_str = path.to_str().expect("path");
9547        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9548            {"name":"id","type":"string","nullable":false},
9549            {"name":"project_id","type":"string","nullable":false},
9550            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9551            "scopes":[{"pattern":"project:{project_id}"}]}]});
9552        let conn = Connection::open(path_str).expect("replica connection");
9553        let mut client = SyncClient::with_connection(
9554            "value-recovery-retention".into(),
9555            &schema,
9556            ClientLimits::default(),
9557            conn,
9558        )
9559        .expect("replica");
9560        client.create_synced_tables().expect("create synced tables");
9561        client.set_retain_failed_commits(true).expect("retention");
9562        // No keys configured: the §5.11 seal fails, so retention is exercised.
9563        client.set_encryption(crate::values::EncryptionConfig::default());
9564        client
9565            .mutate(vec![Mutation::Upsert {
9566                table: "secrets".into(),
9567                values: Map::from_iter([
9568                    ("id".to_owned(), json!("s1")),
9569                    ("project_id".to_owned(), json!("p1")),
9570                    ("note".to_owned(), json!("secret")),
9571                ]),
9572                base_version: None,
9573            }])
9574            .expect("queued write");
9575        client.drain_change_batches();
9576        let revision = client.local_revision();
9577        let prior_outbox = client.pending_commit_ids();
9578        let prior_rejections = client.rejections().len();
9579        let prior_failed = client.failed_commits.len();
9580        let reader = Connection::open(path_str).expect("reader");
9581        reader
9582            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9583            .expect("reader snapshot");
9584        client
9585            .conn
9586            .busy_timeout(std::time::Duration::ZERO)
9587            .expect("busy timeout");
9588        let error = client
9589            .drop_unencodable_outbox(true, None)
9590            .expect_err("a blocked COMMIT must refuse the recovery");
9591        assert!(error.starts_with("client.storage_busy:"), "{error}");
9592        assert_eq!(client.failed_commits.len(), prior_failed);
9593        assert_eq!(client.pending_commit_ids(), prior_outbox);
9594        assert_eq!(client.rejections().len(), prior_rejections);
9595        assert_eq!(client.local_revision(), revision);
9596        // The retry retains the failed commit and drops it from the outbox.
9597        reader.execute_batch("ROLLBACK").expect("release reader");
9598        client
9599            .drop_unencodable_outbox(true, None)
9600            .expect("retry after the blocked commit");
9601        assert_eq!(client.failed_commits.len(), prior_failed + 1);
9602        assert!(client.pending_commit_ids().is_empty());
9603        assert_eq!(client.rejections().len(), prior_rejections + 1);
9604        drop(client);
9605        let _ = std::fs::remove_file(&path);
9606    }
9607
9608    #[test]
9609    fn client_error_maps_legacy_codes_and_static_messages() {
9610        let code_only = ClientError::from("sync.unknown_table".to_owned());
9611        assert_eq!(code_only.code, "sync.unknown_table");
9612        assert_eq!(code_only.message, "the commit targets an unknown table");
9613        assert!(code_only.details.is_none());
9614
9615        let dynamic = ClientError::from(
9616            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9617        );
9618        assert_eq!(dynamic.code, "sync.invalid_request");
9619        assert_eq!(dynamic.message, "the authoring request is invalid");
9620        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9621            .as_str()
9622            .unwrap()
9623            .contains("patch cannot write scope column"));
9624
9625        let unclassified =
9626            ClientError::from("a commit must contain at least one operation".to_owned());
9627        assert_eq!(unclassified.code, "client.failed");
9628        assert_eq!(unclassified.message, "the local authoring operation failed");
9629        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9630            .as_str()
9631            .unwrap()
9632            .contains("at least one operation"));
9633
9634        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
9635        let metadata = ClientError::from(QueryReadFailure {
9636            code: None,
9637            sqlite_code: Some(1),
9638            sqlite_message: Some("generic SQLite prose".to_owned()),
9639            rollback_failure: Some(Box::new(QueryReadFailure::from(
9640                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9641            ))),
9642            message: "unclassified read".to_owned(),
9643        });
9644        assert_eq!(metadata.code, "client.failed");
9645        assert_eq!(metadata.message, "the local authoring operation failed");
9646        let details = metadata.details.as_ref().unwrap();
9647        assert_eq!(details["sqliteCode"], 1);
9648        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9649        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9650        assert_eq!(details["legacyCause"], "unclassified read");
9651    }
9652
9653    #[test]
9654    fn schema_marker_read_keeps_storage_classification() {
9655        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9656            rusqlite::ffi::Error::new(5),
9657            Some("database is locked".to_owned()),
9658        ));
9659        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9660        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9661            rusqlite::ffi::Error::new(1),
9662            Some("unreadable".to_owned()),
9663        ));
9664        assert_eq!(
9665            invalid,
9666            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9667        );
9668
9669        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
9670        // and the guard writes nothing.
9671        let path =
9672            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9673        let setup = Connection::open(&path).expect("setup connection");
9674        setup
9675            .execute_batch(
9676                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9677            )
9678            .unwrap();
9679        let locker = Connection::open(&path).expect("locker connection");
9680        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9681        let reader = Connection::open(&path).expect("reader connection");
9682        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9683        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9684        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9685        locker.execute_batch("ROLLBACK").unwrap();
9686        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9687        let rows = setup
9688            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9689                row.get::<_, i64>(0)
9690            })
9691            .unwrap();
9692        assert_eq!(rows, 1, "the guard performed no writes");
9693        drop(reader);
9694        drop(locker);
9695        drop(setup);
9696        let _ = std::fs::remove_file(&path);
9697    }
9698
9699    #[cfg(feature = "crdt-yjs")]
9700    #[test]
9701    fn crdt_authoring_pre_read_preserves_storage_classification() {
9702        let path =
9703            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9704        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9705            {"name":"id","type":"string","nullable":false},
9706            {"name":"project_id","type":"string","nullable":false},
9707            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9708            "scopes":[{"pattern":"project:{project_id}"}]}]});
9709        let mut client = SyncClient::open_path(
9710            "crdt-busy".into(),
9711            &schema,
9712            ClientLimits::default(),
9713            path.to_str().unwrap(),
9714        )
9715        .expect("open client");
9716        client.create_synced_tables().expect("create synced tables");
9717        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9718
9719        let locker = Connection::open(&path).expect("locker connection");
9720        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9721        let busy = client
9722            .crdt_apply_update("notes", "n1", "doc", &[])
9723            .expect_err("busy crdt pre-read");
9724        assert_eq!(busy.code, "client.storage_busy");
9725        assert!(busy.retryable);
9726        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9727        assert!(client.pending_commit_ids().is_empty());
9728        assert_eq!(client.local_revision(), 0);
9729
9730        locker.execute_batch("ROLLBACK").unwrap();
9731        drop(locker);
9732        drop(client);
9733        let _ = std::fs::remove_file(&path);
9734    }
9735
9736    #[test]
9737    fn large_unique_import_copies_no_unrelated_tables() {
9738        let (mut client, image, table) = unique_import_fixture(10240);
9739        let pending = client.pending_commit_ids();
9740        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9741            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9742        assert!(matches!(
9743            client.apply_sqlite_image(
9744                &image,
9745                &table,
9746                true,
9747                &[("project_id".into(), vec!["p1".into()])],
9748                10240,
9749                7,
9750                "digest"
9751            ),
9752            Ok(10240)
9753        ));
9754        assert_eq!(client.overlay_rebuild_count.get(), 0);
9755        assert_eq!(client.pending_commit_ids(), pending);
9756        assert_eq!(
9757            client
9758                .query("SELECT count(*) AS n FROM tasks", &[])
9759                .unwrap()[0]["n"],
9760            10241
9761        );
9762        assert_eq!(
9763            client
9764                .query("SELECT count(*) AS n FROM tasks_fts", &[])
9765                .unwrap()[0]["n"],
9766            10241
9767        );
9768        assert_eq!(
9769            client
9770                .query("SELECT count(*) AS n FROM notes_fts", &[])
9771                .unwrap()[0]["n"],
9772            8192
9773        );
9774    }
9775
9776    #[test]
9777    #[ignore = "manual scaling benchmark; see bench/README.md"]
9778    fn benchmark_unique_image_import() {
9779        let mut samples = Vec::new();
9780        for rows in [10240, 51200, 102400] {
9781            for trial in 0..4 {
9782                let (mut client, image, table) = unique_import_fixture(rows);
9783                let start = std::time::Instant::now();
9784                assert!(
9785                    matches!(client.apply_sqlite_image(&image,&table,true,&[("project_id".into(),vec!["p1".into()])],rows as i64,7,"digest"),Ok(n) if n as usize == rows)
9786                );
9787                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9788                assert_eq!(
9789                    client
9790                        .query("SELECT count(*) AS n FROM tasks", &[])
9791                        .unwrap()[0]["n"],
9792                    rows + 1
9793                );
9794                assert_eq!(
9795                    client
9796                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
9797                        .unwrap()[0]["n"],
9798                    rows + 1
9799                );
9800                assert_eq!(
9801                    client
9802                        .query("SELECT count(*) AS n FROM notes_fts", &[])
9803                        .unwrap()[0]["n"],
9804                    8192
9805                );
9806                assert_eq!(client.pending_commit_ids().len(), 1);
9807                if trial > 0 {
9808                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9809                }
9810            }
9811        }
9812        let artifact = json!({"boundary":"Rust core; in-memory SQLite; one pending edit; secondary unique indexes and FTS; 8192 unrelated rows; one warmup per size","optimized":!cfg!(debug_assertions),"sqliteVersion":rusqlite::version(),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,"sourceHash":bytes_to_hex(&Sha256::digest(include_str!("client.rs").as_bytes())),"samples":samples});
9813        let output = serde_json::to_string_pretty(&artifact).unwrap();
9814        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9815            use std::io::Write;
9816            let mut file = std::fs::OpenOptions::new()
9817                .write(true)
9818                .create_new(true)
9819                .open(path)
9820                .unwrap();
9821            file.write_all(output.as_bytes()).unwrap();
9822        } else {
9823            println!("{output}");
9824        }
9825    }
9826
9827    #[test]
9828    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9829        let schema = json!({
9830            "version": 1,
9831            "tables": [{
9832                "name": "catalogue_codes",
9833                "primaryKey": "id",
9834                "columns": [
9835                    { "name": "id", "type": "string", "nullable": false },
9836                    { "name": "release_id", "type": "string", "nullable": false },
9837                    { "name": "code", "type": "string", "nullable": false },
9838                    { "name": "title", "type": "string", "nullable": false }
9839                ],
9840                "scopes": [{ "pattern": "release:{release_id}" }],
9841                "ftsIndexes": [{
9842                    "name": "catalogue_codes_fts",
9843                    "columns": ["code", "title"],
9844                    "tokenize": "unicode61 remove_diacritics 2"
9845                }]
9846            }]
9847        });
9848        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9849            .expect("FTS5 client");
9850        let insert_trigger: String = client
9851            .conn
9852            .query_row(
9853                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9854                [],
9855                |row| row.get(0),
9856            )
9857            .expect("insert trigger");
9858        let replace_guard: String = client
9859            .conn
9860            .query_row(
9861                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9862                [],
9863                |row| row.get(0),
9864            )
9865            .expect("replace guard");
9866        assert!(!insert_trigger.contains("DELETE FROM"));
9867        assert!(replace_guard.contains("BEFORE INSERT"));
9868        assert!(replace_guard.contains("WHEN EXISTS"));
9869        let search = |client: &SyncClient, query: &str| {
9870            client
9871                .query(
9872                    "SELECT c.id FROM catalogue_codes_fts f JOIN catalogue_codes c ON CAST(c.id AS TEXT) = f._syncular_source_id WHERE catalogue_codes_fts MATCH ?1 ORDER BY c.id",
9873                    &[Value::from(query)],
9874                )
9875                .expect("FTS query")
9876        };
9877
9878        client
9879            .mutate(vec![Mutation::Upsert {
9880                table: "catalogue_codes".to_owned(),
9881                values: Map::from_iter([
9882                    ("id".to_owned(), Value::from("c1")),
9883                    ("release_id".to_owned(), Value::from("r1")),
9884                    ("code".to_owned(), Value::from("A01")),
9885                    ("title".to_owned(), Value::from("Cholera")),
9886                ]),
9887                base_version: None,
9888            }])
9889            .expect("insert code");
9890        assert_eq!(search(&client, "cholera").len(), 1);
9891        let prior = client
9892            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9893            .unwrap();
9894        client
9895            .conn
9896            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9897            .unwrap();
9898        client.create_synced_tables().unwrap();
9899        assert_eq!(
9900            client
9901                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9902                .unwrap(),
9903            prior
9904        );
9905        let plan = client.query("EXPLAIN QUERY PLAN SELECT rowid FROM catalogue_codes_fts WHERE rowid = (SELECT id FROM _syncular_fts_catalogue_codes_fts WHERE source_id = 'c1')", &[]).unwrap();
9906        let rendered = serde_json::to_string(&plan).unwrap();
9907        assert!(rendered.contains("COVERING INDEX"));
9908        assert!(rendered.contains("INDEX 0:="));
9909        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9910        assert_eq!(search(&client, "cholera").len(), 1);
9911
9912        client
9913            .mutate(vec![Mutation::Upsert {
9914                table: "catalogue_codes".to_owned(),
9915                values: Map::from_iter([
9916                    ("id".to_owned(), Value::from("c1")),
9917                    ("release_id".to_owned(), Value::from("r1")),
9918                    ("code".to_owned(), Value::from("A01")),
9919                    ("title".to_owned(), Value::from("Enteric infection")),
9920                ]),
9921                base_version: None,
9922            }])
9923            .expect("update code");
9924        assert!(search(&client, "cholera").is_empty());
9925        assert_eq!(search(&client, "enteric").len(), 1);
9926
9927        client
9928            .mutate(vec![Mutation::Delete {
9929                table: "catalogue_codes".to_owned(),
9930                row_id: "c1".to_owned(),
9931                base_version: None,
9932            }])
9933            .expect("delete code");
9934        assert!(search(&client, "enteric").is_empty());
9935    }
9936
9937    #[test]
9938    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9939        let schema = json!({
9940            "version": 1,
9941            "tables": [{
9942                "name": "patient_notes",
9943                "primaryKey": "id",
9944                "columns": [
9945                    { "name": "id", "type": "string", "nullable": false },
9946                    { "name": "practice_id", "type": "string", "nullable": false },
9947                    { "name": "encryption_key_id", "type": "string", "nullable": false },
9948                    { "name": "title", "type": "string", "nullable": false }
9949                ],
9950                "scopes": [{ "pattern": "practice:{practice_id}" }],
9951                "ftsIndexes": [{
9952                    "name": "patient_notes_fts",
9953                    "columns": ["title"],
9954                    "tokenize": "unicode61 remove_diacritics 2"
9955                }]
9956            }]
9957        });
9958        let mut client = SyncClient::new(
9959            "local-purge-test".to_owned(),
9960            &schema,
9961            ClientLimits::default(),
9962        )
9963        .expect("local purge client");
9964        client
9965            .conn
9966            .execute(
9967                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9968                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9969                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9970                [],
9971            )
9972            .expect("seed base rows");
9973        client.overlay_dirty.set(true);
9974        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9975
9976        let note = |id: &str, key_id: &str, title: &str| {
9977            Map::from_iter([
9978                ("id".to_owned(), Value::from(id)),
9979                ("practice_id".to_owned(), Value::from("practice-1")),
9980                ("encryption_key_id".to_owned(), Value::from(key_id)),
9981                ("title".to_owned(), Value::from(title)),
9982            ])
9983        };
9984        let doomed = client
9985            .mutate(vec![
9986                Mutation::Upsert {
9987                    table: "patient_notes".to_owned(),
9988                    values: note("target", "key-revoked", "Target changed"),
9989                    base_version: None,
9990                },
9991                Mutation::Upsert {
9992                    table: "patient_notes".to_owned(),
9993                    values: note("unrelated", "key-held", "Unrelated changed"),
9994                    base_version: None,
9995                },
9996            ])
9997            .expect("doomed commit");
9998        let kept = client
9999            .mutate(vec![Mutation::Upsert {
10000                table: "patient_notes".to_owned(),
10001                values: note("kept", "key-held", "Kept optimistic"),
10002                base_version: None,
10003            }])
10004            .expect("kept commit");
10005        client.drain_change_batches();
10006
10007        let input = LocalDataPurgeInput {
10008            purge_id: "purge-001".to_owned(),
10009            targets: vec![LocalDataPurgeTarget {
10010                table: "patient_notes".to_owned(),
10011                selectors: BTreeMap::from([(
10012                    "encryption_key_id".to_owned(),
10013                    vec!["key-revoked".to_owned()],
10014                )]),
10015            }],
10016        };
10017        assert_eq!(
10018            client.purge_local_data(&input).expect("apply purge"),
10019            LocalDataPurgeResult {
10020                already_applied: false,
10021                purged_rows: 1,
10022                dropped_commits: 1,
10023            }
10024        );
10025        let rows = client
10026            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
10027            .expect("visible rows");
10028        assert_eq!(rows.len(), 2);
10029        assert_eq!(rows[0]["id"], "kept");
10030        assert_eq!(rows[1]["id"], "unrelated");
10031        assert_eq!(rows[1]["title"], "Unrelated original");
10032        let fts = client
10033            .query(
10034                "SELECT n.id FROM patient_notes_fts f JOIN patient_notes n ON CAST(n.id AS TEXT) = f._syncular_source_id WHERE patient_notes_fts MATCH 'target'",
10035                &[],
10036            )
10037            .expect("fts query");
10038        assert!(fts.is_empty());
10039        assert_eq!(client.outbox.len(), 1);
10040        assert_eq!(client.outbox[0].client_commit_id, kept);
10041        let outcome = client
10042            .commit_outcome(&doomed)
10043            .expect("read doomed outcome")
10044            .expect("doomed outcome");
10045        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
10046        match &outcome.results[0] {
10047            CommitOperationOutcome::Error { rejection } => {
10048                assert_eq!(rejection.code, "client.local_data_purged");
10049                assert_eq!(rejection.client_commit_id, doomed);
10050            }
10051            other => panic!("expected local purge rejection, got {other:?}"),
10052        }
10053        assert_eq!(client.drain_change_batches().len(), 1);
10054        assert_eq!(
10055            client.purge_local_data(&input).expect("retry purge"),
10056            LocalDataPurgeResult {
10057                already_applied: true,
10058                purged_rows: 0,
10059                dropped_commits: 0,
10060            }
10061        );
10062        let conflicting = LocalDataPurgeInput {
10063            purge_id: input.purge_id.clone(),
10064            targets: vec![LocalDataPurgeTarget {
10065                table: "patient_notes".to_owned(),
10066                selectors: BTreeMap::from([(
10067                    "encryption_key_id".to_owned(),
10068                    vec!["key-held".to_owned()],
10069                )]),
10070            }],
10071        };
10072        assert!(client
10073            .purge_local_data(&conflicting)
10074            .expect_err("id collision must fail")
10075            .contains("already used with a different plan"));
10076    }
10077
10078    /// Mirror `SyncClient::sync` through the split round API.
10079    fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
10080        let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
10081            Ok(prepared) => prepared,
10082            Err(outcome) => return *outcome,
10083        };
10084        loop {
10085            let completed = crate::round::test_support::drive_round(next, transport);
10086            match client.apply_sync_round(completed) {
10087                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10088                crate::AppliedSyncRound::Complete {
10089                    outcome, controls, ..
10090                } => {
10091                    if client.transport_enabled() {
10092                        for text in controls {
10093                            let _ = transport.realtime_send(&text);
10094                        }
10095                    } else {
10096                        client.disconnect_realtime(transport);
10097                    }
10098                    return outcome;
10099                }
10100            }
10101        }
10102    }
10103
10104    #[derive(Clone)]
10105    enum RoundReplyScript {
10106        Response(Message),
10107        /// Answer every pushed commit with an applied result.
10108        PushAck,
10109        Error(TransportError),
10110    }
10111
10112    /// Serves one scripted reply and records every request, segment batch, and
10113    /// upload the round makes.
10114    struct SplitParityTransport {
10115        script: RoundReplyScript,
10116        segments: BTreeMap<String, Vec<u8>>,
10117        requests: Vec<Message>,
10118        batches: Vec<Vec<String>>,
10119        uploads: Vec<String>,
10120        sync_calls: usize,
10121        realtime_sync_calls: usize,
10122    }
10123
10124    impl SplitParityTransport {
10125        fn new(script: RoundReplyScript) -> Self {
10126            Self {
10127                script,
10128                segments: BTreeMap::new(),
10129                requests: Vec::new(),
10130                batches: Vec::new(),
10131                uploads: Vec::new(),
10132                sync_calls: 0,
10133                realtime_sync_calls: 0,
10134            }
10135        }
10136
10137        fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10138            Self {
10139                segments,
10140                ..Self::new(script)
10141            }
10142        }
10143
10144        fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10145            let message =
10146                decode_message(request).expect("the client encodes a legal round request");
10147            self.requests.push(message.clone());
10148            match &self.script {
10149                RoundReplyScript::Response(response) => Ok(encode_message(response)),
10150                RoundReplyScript::Error(error) => Err(error.clone()),
10151                RoundReplyScript::PushAck => {
10152                    let mut frames = vec![Frame::RespHeader {
10153                        required_schema_version: None,
10154                        latest_schema_version: None,
10155                        log_epoch: Some("epoch-1".into()),
10156                        reset_required: Some(false),
10157                    }];
10158                    let mut commit_seq = 0;
10159                    for frame in &message.frames {
10160                        if let Frame::PushCommit {
10161                            client_commit_id,
10162                            operations,
10163                        } = frame
10164                        {
10165                            commit_seq += 1;
10166                            frames.push(Frame::PushResult {
10167                                client_commit_id: client_commit_id.clone(),
10168                                status: PushStatus::Applied,
10169                                commit_seq: Some(commit_seq),
10170                                results: (0..operations.len())
10171                                    .map(|op_index| OpResult::Applied {
10172                                        op_index: op_index as i32,
10173                                    })
10174                                    .collect(),
10175                            });
10176                        }
10177                    }
10178                    frames.push(Frame::SubStart {
10179                        id: "tasks".into(),
10180                        status: SubStatus::Active,
10181                        reason_code: String::new(),
10182                        effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10183                        bootstrap: false,
10184                    });
10185                    frames.push(Frame::SubEnd {
10186                        next_cursor: 0,
10187                        bootstrap_state: None,
10188                    });
10189                    Ok(encode_message(&Message {
10190                        wire_version: WIRE_VERSION,
10191                        msg_kind: MsgKind::Response,
10192                        frames,
10193                    }))
10194                }
10195            }
10196        }
10197    }
10198
10199    impl Transport for SplitParityTransport {
10200        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10201            self.sync_calls += 1;
10202            self.respond(request)
10203        }
10204
10205        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10206            self.realtime_sync_calls += 1;
10207            self.respond(request)
10208        }
10209        fn download_segment(
10210            &mut self,
10211            _request: &SegmentRequest,
10212            _on_progress: &mut dyn FnMut(u64),
10213        ) -> Result<Vec<u8>, TransportError> {
10214            Err(TransportError::new(
10215                "sync.transport_failed",
10216                "per-descriptor fetch",
10217            ))
10218        }
10219
10220        fn download_segments(
10221            &mut self,
10222            fetches: &[crate::SegmentFetch],
10223            on_progress: &mut (dyn FnMut(usize, u64) + Send),
10224        ) -> Vec<Result<Vec<u8>, TransportError>> {
10225            self.batches.push(
10226                fetches
10227                    .iter()
10228                    .map(|fetch| match fetch {
10229                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10230                        crate::SegmentFetch::Url(url) => url.clone(),
10231                    })
10232                    .collect(),
10233            );
10234            fetches
10235                .iter()
10236                .enumerate()
10237                .map(|(index, fetch)| {
10238                    let crate::SegmentFetch::Direct(request) = fetch else {
10239                        panic!("the descriptors are direct-endpoint requests")
10240                    };
10241                    let bytes =
10242                        self.segments
10243                            .get(&request.segment_id)
10244                            .cloned()
10245                            .ok_or_else(|| {
10246                                TransportError::new("transport.not_found", "missing segment body")
10247                            })?;
10248                    on_progress(index, bytes.len() as u64);
10249                    Ok(bytes)
10250                })
10251                .collect()
10252        }
10253
10254        fn blob_upload_grant(
10255            &mut self,
10256            _blob_id: &str,
10257            _byte_length: u64,
10258            _media_type: Option<&str>,
10259        ) -> Result<BlobUploadGrant, TransportError> {
10260            Ok(BlobUploadGrant::None)
10261        }
10262
10263        fn blob_upload(
10264            &mut self,
10265            blob_id: &str,
10266            _bytes: &[u8],
10267            _media_type: Option<&str>,
10268        ) -> Result<(), TransportError> {
10269            self.uploads.push(blob_id.to_owned());
10270            Ok(())
10271        }
10272
10273        fn realtime_connect(&mut self) -> Result<(), TransportError> {
10274            Ok(())
10275        }
10276
10277        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10278            Ok(())
10279        }
10280
10281        fn realtime_close(&mut self) -> Result<(), TransportError> {
10282            Ok(())
10283        }
10284    }
10285
10286    fn parity_clients() -> (SyncClient, SyncClient) {
10287        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10288            {"name":"id","type":"string","nullable":false},
10289            {"name":"project_id","type":"string","nullable":false}],
10290            "scopes":[{"pattern":"project:{project_id}"}]}]});
10291        let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10292            let mut client =
10293                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10294            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10295            client
10296                .subscribe(
10297                    "tasks".into(),
10298                    "tasks".into(),
10299                    vec![("project_id".into(), vec!["p1".into()])],
10300                    None,
10301                )
10302                .unwrap();
10303            client.subs[0].cursor = 0;
10304            client.subs[0].synced_once = true;
10305            client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10306            client.persist_sub(&client.subs[0]).unwrap();
10307            client.drain_change_batches();
10308            client
10309        });
10310        (exchange, split)
10311    }
10312
10313    fn pull_response(client: &SyncClient) -> Message {
10314        let row = encode_row_json(
10315            client.schema.table("tasks").unwrap(),
10316            "kept",
10317            &Map::from_iter([
10318                ("id".into(), json!("kept")),
10319                ("project_id".into(), json!("p1")),
10320            ]),
10321            &client.encryption,
10322        )
10323        .unwrap();
10324        Message {
10325            wire_version: WIRE_VERSION,
10326            msg_kind: MsgKind::Response,
10327            frames: vec![
10328                Frame::RespHeader {
10329                    required_schema_version: None,
10330                    latest_schema_version: None,
10331                    log_epoch: Some("epoch-1".into()),
10332                    reset_required: Some(false),
10333                },
10334                Frame::SubStart {
10335                    id: "tasks".into(),
10336                    status: SubStatus::Active,
10337                    reason_code: String::new(),
10338                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10339                    bootstrap: false,
10340                },
10341                Frame::Commit {
10342                    commit_seq: 1,
10343                    created_at_ms: 1,
10344                    actor_id: "writer".into(),
10345                    tables: vec!["tasks".into()],
10346                    changes: vec![ssp2::model::Change {
10347                        table_index: 0,
10348                        row_id: "kept".into(),
10349                        op: Op::Upsert,
10350                        row_version: Some(1),
10351                        scopes: vec![("project_id".into(), "p1".into())],
10352                        row: Some(row),
10353                    }],
10354                },
10355                Frame::SubEnd {
10356                    next_cursor: 1,
10357                    bootstrap_state: None,
10358                },
10359            ],
10360        }
10361    }
10362
10363    #[test]
10364    fn split_round_matches_exchange_for_a_pull() {
10365        let (mut exchange, mut split) = parity_clients();
10366        let response = pull_response(&exchange);
10367        let mut exchange_transport =
10368            SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10369        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10370
10371        let exchange_outcome = exchange.sync(&mut exchange_transport);
10372        let split_outcome = sync_split(&mut split, &mut split_transport);
10373        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10374        assert_eq!(
10375            exchange
10376                .query("SELECT id, project_id FROM tasks", &[])
10377                .unwrap(),
10378            split
10379                .query("SELECT id, project_id FROM tasks", &[])
10380                .unwrap()
10381        );
10382        assert_eq!(exchange_transport.requests.len(), 1);
10383        assert_eq!(split_transport.requests.len(), 1);
10384    }
10385
10386    #[test]
10387    fn split_round_matches_exchange_for_a_push_with_an_upload() {
10388        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10389            {"name":"id","type":"string","nullable":false},
10390            {"name":"project_id","type":"string","nullable":false},
10391            {"name":"file","type":"blob_ref","nullable":true}],
10392            "scopes":[{"pattern":"project:{project_id}"}]}]});
10393        let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10394            let mut client =
10395                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10396            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10397            client
10398                .subscribe(
10399                    "attachments".into(),
10400                    "attachments".into(),
10401                    vec![("project_id".into(), vec!["p1".into()])],
10402                    None,
10403                )
10404                .unwrap();
10405            client
10406        });
10407        let bytes = b"split upload";
10408        let mut mutations = Vec::new();
10409        for client in [&mut exchange, &mut split] {
10410            client
10411                .upload_blob(bytes, Some("text/plain".into()), None)
10412                .unwrap();
10413            mutations.push(
10414                client
10415                    .mutate(vec![Mutation::Upsert {
10416                        table: "attachments".into(),
10417                        values: Map::from_iter([
10418                            ("id".into(), Value::from("a1")),
10419                            ("project_id".into(), Value::from("p1")),
10420                            (
10421                                "file".into(),
10422                                Value::from(format!(
10423                                    "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10424                                    blob_id_for(bytes),
10425                                    bytes.len()
10426                                )),
10427                            ),
10428                        ]),
10429                        base_version: None,
10430                    }])
10431                    .unwrap(),
10432            );
10433        }
10434        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10435        let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10436        let exchange_outcome = exchange.sync(&mut exchange_transport);
10437        let split_outcome = sync_split(&mut split, &mut split_transport);
10438        let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10439            panic!("the exchange round must succeed: {exchange_outcome:?}")
10440        };
10441        let SyncOutcome::Ok(split_report) = split_outcome else {
10442            panic!("the split round must succeed: {split_outcome:?}")
10443        };
10444        assert_eq!(exchange_report.pushed, 1);
10445        assert_eq!(split_report.pushed, 1);
10446        assert_eq!(
10447            exchange_report.commits_applied,
10448            split_report.commits_applied
10449        );
10450        assert_eq!(
10451            exchange_transport.uploads,
10452            [blob_id_for(bytes)],
10453            "one upload before the metadata request"
10454        );
10455        assert_eq!(split_transport.uploads, exchange_transport.uploads);
10456        assert!(exchange.pending_commit_ids().is_empty());
10457        assert!(split.pending_commit_ids().is_empty());
10458        assert_eq!(
10459            exchange
10460                .query("SELECT id, project_id FROM attachments", &[])
10461                .unwrap(),
10462            split
10463                .query("SELECT id, project_id FROM attachments", &[])
10464                .unwrap()
10465        );
10466    }
10467
10468    #[test]
10469    fn split_round_matches_exchange_for_a_segment_download() {
10470        let (mut exchange, mut split) = parity_clients();
10471        let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10472        let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10473            table: "tasks".into(),
10474            schema_version: 1,
10475            columns,
10476            blocks: vec![vec![ssp2::segment::SegmentRow {
10477                server_version: 1,
10478                values: vec![
10479                    Some(ColumnValue::String("page".into())),
10480                    Some(ColumnValue::String("p1".into())),
10481                ],
10482            }]],
10483        });
10484        let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10485        let response = Message {
10486            wire_version: WIRE_VERSION,
10487            msg_kind: MsgKind::Response,
10488            frames: vec![
10489                Frame::RespHeader {
10490                    required_schema_version: None,
10491                    latest_schema_version: None,
10492                    log_epoch: Some("epoch-1".into()),
10493                    reset_required: Some(false),
10494                },
10495                Frame::SubStart {
10496                    id: "tasks".into(),
10497                    status: SubStatus::Active,
10498                    reason_code: String::new(),
10499                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10500                    bootstrap: true,
10501                },
10502                Frame::SegmentRef {
10503                    segment_id: segment_id.clone(),
10504                    media_type: MediaType::Rows,
10505                    table: "tasks".into(),
10506                    byte_length: page.len() as i64,
10507                    row_count: 1,
10508                    as_of_commit_seq: 1,
10509                    scope_digest: "digest".into(),
10510                    row_cursor: None,
10511                    next_row_cursor: None,
10512                    url: None,
10513                    url_expires_at_ms: None,
10514                },
10515                Frame::SubEnd {
10516                    next_cursor: 1,
10517                    bootstrap_state: None,
10518                },
10519            ],
10520        };
10521        let segments = BTreeMap::from([(segment_id.clone(), page)]);
10522        let mut exchange_transport = SplitParityTransport::with_segments(
10523            RoundReplyScript::Response(response.clone()),
10524            segments.clone(),
10525        );
10526        let mut split_transport =
10527            SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10528        let exchange_outcome = exchange.sync(&mut exchange_transport);
10529        let split_outcome = sync_split(&mut split, &mut split_transport);
10530        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10531        assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10532        assert_eq!(split_transport.batches, exchange_transport.batches);
10533        assert_eq!(
10534            exchange
10535                .query("SELECT id, project_id FROM tasks", &[])
10536                .unwrap(),
10537            split
10538                .query("SELECT id, project_id FROM tasks", &[])
10539                .unwrap()
10540        );
10541    }
10542
10543    #[test]
10544    fn split_round_matches_exchange_for_a_transport_failure() {
10545        let (mut exchange, mut split) = parity_clients();
10546        let error = TransportError::new("transport.timeout", "injected");
10547        let mut exchange_transport =
10548            SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10549        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10550        let exchange_outcome = exchange.sync(&mut exchange_transport);
10551        let split_outcome = sync_split(&mut split, &mut split_transport);
10552        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10553        assert!(matches!(
10554            split_outcome,
10555            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10556        ));
10557        assert_eq!(
10558            exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10559            split.query("SELECT id FROM tasks", &[]).unwrap()
10560        );
10561    }
10562
10563    #[test]
10564    fn split_round_matches_exchange_for_a_failed_realtime_round() {
10565        let (mut exchange, mut split) = parity_clients();
10566        let error = TransportError::new("transport.timeout", "socket round failed");
10567        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10568        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10569            TransportError::new("transport.timeout", "socket round failed"),
10570        ));
10571        exchange.connect_realtime(&mut exchange_transport).unwrap();
10572        split.connect_realtime(&mut split_transport).unwrap();
10573        let exchange_outcome = exchange.sync(&mut exchange_transport);
10574        let split_outcome = sync_split(&mut split, &mut split_transport);
10575        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10576        assert_eq!(exchange_transport.realtime_sync_calls, 1);
10577        assert_eq!(exchange_transport.sync_calls, 0);
10578        assert_eq!(split_transport.realtime_sync_calls, 1);
10579        assert_eq!(split_transport.sync_calls, 0);
10580        assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10581        assert_eq!(split.realtime_state(), RealtimeState::Lost);
10582    }
10583}
10584
10585impl SubState {
10586    fn name(self) -> &'static str {
10587        match self {
10588            SubState::Active => "active",
10589            SubState::Revoked => "revoked",
10590            SubState::Failed => "failed",
10591        }
10592    }
10593
10594    fn parse(value: &str) -> Self {
10595        match value {
10596            "revoked" => Self::Revoked,
10597            "failed" => Self::Failed,
10598            _ => Self::Active,
10599        }
10600    }
10601}
10602
10603#[derive(Debug, Clone)]
10604struct Subscription {
10605    id: String,
10606    table: String,
10607    requested: Vec<(String, Vec<String>)>,
10608    params: Option<String>,
10609    cursor: i64,
10610    /// §4.7 resume token, round-tripped opaquely.
10611    bootstrap_state: Option<String>,
10612    state: SubState,
10613    reason_code: Option<String>,
10614    /// Last effective scopes echoed while active (§3.3: persisted for the
10615    /// purge contract; each active echo replaces it).
10616    effective: Option<Vec<(String, Vec<String>)>>,
10617    synced_once: bool,
10618}
10619
10620/// Internal state for hosts that perform network I/O outside the client owner.
10621#[doc(hidden)]
10622pub struct CurrentBasePreparation {
10623    client_commit_id: String,
10624    sequence: i64,
10625    subscriptions: Vec<Subscription>,
10626    transport_epoch: u64,
10627    subscription_epoch: u64,
10628    security_epoch: u64,
10629}
10630
10631#[cfg(test)]
10632mod subscription_rearm_tests {
10633    use super::*;
10634    use serde_json::json;
10635
10636    fn client() -> SyncClient {
10637        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10638            {"name":"id","type":"string","nullable":false},
10639            {"name":"project_id","type":"string","nullable":false}],
10640            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10641        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10642    }
10643
10644    fn scopes() -> Vec<(String, Vec<String>)> {
10645        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10646    }
10647
10648    /// §3.3: the stored registration carries progress the re-arm must drop.
10649    fn store_revoked(client: &mut SyncClient) {
10650        client
10651            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10652            .expect("subscribe");
10653        let sub = &mut client.subs[0];
10654        sub.cursor = 41;
10655        sub.bootstrap_state = Some("resume".to_owned());
10656        sub.effective = Some(scopes());
10657        sub.state = SubState::Revoked;
10658        sub.reason_code = Some("sync.scope_revoked".to_owned());
10659        client.persist_sub(&client.subs[0]).expect("persist");
10660        client.sync_needed = false;
10661        client.sync_intent_queue.clear();
10662    }
10663
10664    #[test]
10665    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10666        let mut client = client();
10667        store_revoked(&mut client);
10668
10669        client
10670            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10671            .expect("re-arm");
10672
10673        let view = client.subscription_state("sub").expect("state");
10674        assert_eq!(view.status, "active");
10675        assert_eq!(view.cursor, -1);
10676        assert!(!view.has_resume_token);
10677        assert_eq!(view.effective_scopes, None);
10678        assert_eq!(view.reason_code, None);
10679        assert!(client.sync_needed, "a re-arm asks for a pull");
10680        assert!(matches!(
10681            client.drain_sync_intents().as_slice(),
10682            [SyncIntent::Interactive]
10683        ));
10684        let stored: String = client
10685            .conn
10686            .query_row(
10687                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10688                [],
10689                |row| row.get(0),
10690            )
10691            .expect("persisted row");
10692        let stored: Value = serde_json::from_str(&stored).expect("stored json");
10693        assert_eq!(stored["status"], json!("active"));
10694        assert_eq!(stored["cursor"], json!(-1));
10695        assert_eq!(stored["bootstrapState"], Value::Null);
10696        assert_eq!(stored["effectiveScopes"], Value::Null);
10697        assert_eq!(stored["reasonCode"], Value::Null);
10698    }
10699
10700    #[test]
10701    fn a_failed_registration_is_not_rearmed() {
10702        let mut client = client();
10703        client
10704            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10705            .expect("subscribe");
10706        client.subs[0].state = SubState::Failed;
10707        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10708        client.persist_sub(&client.subs[0]).expect("persist");
10709        client.sync_needed = false;
10710        client.sync_intent_queue.clear();
10711
10712        client
10713            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10714            .expect("subscribe");
10715
10716        let view = client.subscription_state("sub").expect("state");
10717        assert_eq!(view.status, "failed");
10718        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10719        assert!(!client.sync_needed);
10720        assert!(client.drain_sync_intents().is_empty());
10721    }
10722}
10723
10724#[derive(Debug, Clone)]
10725struct OutboxOp {
10726    upsert: bool,
10727    table: String,
10728    row_id: String,
10729    base_version: Option<i64>,
10730    /// Schema-agnostic local form (§0): driver JSON values, encoded with
10731    /// the current codec at send time. A key's presence IS the §6.1 sparse
10732    /// presence set — both the overlay and the wire payload derive from it.
10733    values: Option<Map<String, Value>>,
10734    /// §7.1 insert intent: `mutate` recorded this upsert while the row was
10735    /// absent from the visible replica. Replay and send-time encoding present
10736    /// every current-schema column; a column `values` lacks is NULL.
10737    insert: bool,
10738}
10739
10740impl OutboxOp {
10741    /// The values this upsert presents under the current `table` (§7.1). An
10742    /// insert-intent operation presents every column, a missing one as NULL;
10743    /// `None` when a missing column is non-nullable, which leaves the commit
10744    /// unencodable (§7.4.4), or when the operation carries no values.
10745    fn presented_values(&self, table: &TableSchema) -> Option<Cow<'_, Map<String, Value>>> {
10746        let values = self.values.as_ref()?;
10747        if !self.insert || table.columns.iter().all(|c| values.contains_key(&c.name)) {
10748            return Some(Cow::Borrowed(values));
10749        }
10750        full_row_values(table, values.clone()).ok().map(Cow::Owned)
10751    }
10752}
10753
10754impl From<&OutboxOp> for CommitOperation {
10755    fn from(operation: &OutboxOp) -> Self {
10756        Self {
10757            table: operation.table.clone(),
10758            row_id: operation.row_id.clone(),
10759            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10760            base_version: operation.base_version,
10761            values: operation.values.clone(),
10762            insert: operation.insert,
10763        }
10764    }
10765}
10766
10767#[derive(Debug, Clone)]
10768struct OutboxCommit {
10769    client_commit_id: String,
10770    ops: Vec<OutboxOp>,
10771}
10772
10773/// One commit's `(table, value keys, insert intent)` per upsert operation — the classification
10774/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
10775/// can never disagree about which commits cannot re-encode.
10776fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>, bool)> {
10777    commit
10778        .ops
10779        .iter()
10780        .filter(|operation| operation.upsert)
10781        .map(|operation| {
10782            (
10783                operation.table.as_str(),
10784                operation
10785                    .values
10786                    .as_ref()
10787                    .map(|values| values.keys().map(String::as_str).collect())
10788                    .unwrap_or_default(),
10789                operation.insert,
10790            )
10791        })
10792        .collect()
10793}
10794
10795type FailedCommit = (OutboxCommit, Map<String, Value>);
10796
10797#[derive(Debug, Clone)]
10798struct CompiledLocalDataPurgeTarget {
10799    table: String,
10800    selectors: Vec<(String, Vec<String>)>,
10801}
10802
10803struct StoredCommitOutcomeRow {
10804    sequence: i64,
10805    client_commit_id: String,
10806    status: String,
10807    recorded_at_ms: i64,
10808    results_json: String,
10809    operations_json: Option<String>,
10810    resolution: String,
10811    resolved_at_ms: Option<i64>,
10812    replacement_client_commit_id: Option<String>,
10813}
10814
10815impl StoredCommitOutcomeRow {
10816    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10817        Ok(Self {
10818            sequence: row.get(0)?,
10819            client_commit_id: row.get(1)?,
10820            status: row.get(2)?,
10821            recorded_at_ms: row.get(3)?,
10822            results_json: row.get(4)?,
10823            operations_json: row.get(5)?,
10824            resolution: row.get(6)?,
10825            resolved_at_ms: row.get(7)?,
10826            replacement_client_commit_id: row.get(8)?,
10827        })
10828    }
10829}
10830
10831/// Section outcome distinguishing the §5.6 subscription-local fail-closed
10832/// path from a round-aborting failure (§1.4 rule 5).
10833enum SectionError {
10834    FailClosed,
10835    Abort(String, String),
10836}
10837
10838#[derive(Debug)]
10839pub(crate) struct RequestMeta {
10840    pushed_ids: Vec<String>,
10841    /// Subscription id → the request carried `cursor < 0` and no resume
10842    /// token (§5.6 first-page detection: a *fresh* bootstrap).
10843    fresh: Vec<(String, bool)>,
10844    pub(crate) accept: u8,
10845    /// §6.1 splitBatch: outbox commits held back from THIS request because
10846    /// the running operation count reached the push cap — the next round
10847    /// pushes them (`sync_needed` stays set while any remain).
10848    deferred_commits: usize,
10849}
10850
10851#[derive(Default)]
10852struct ChangeAccumulator {
10853    /// None means table-wide; Some is the exact scoped domain.
10854    tables: BTreeMap<String, Option<BTreeSet<String>>>,
10855    windows: BTreeMap<(String, String), BTreeSet<String>>,
10856    status: bool,
10857    conflicts: bool,
10858    rejections: bool,
10859    outcomes: bool,
10860}
10861
10862impl ChangeAccumulator {
10863    fn table(&mut self, table: &str) {
10864        self.tables.insert(table.to_owned(), None);
10865    }
10866
10867    fn scope(&mut self, table: &str, key: String) {
10868        match self.tables.get_mut(table) {
10869            Some(None) => {}
10870            Some(Some(keys)) => {
10871                keys.insert(key);
10872            }
10873            None => {
10874                self.tables
10875                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
10876            }
10877        }
10878    }
10879
10880    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10881        self.windows
10882            .entry((base_key.to_owned(), table.to_owned()))
10883            .or_default()
10884            .insert(unit.to_owned());
10885    }
10886
10887    fn touched(&self) -> bool {
10888        !self.tables.is_empty()
10889            || !self.windows.is_empty()
10890            || self.status
10891            || self.conflicts
10892            || self.rejections
10893            || self.outcomes
10894    }
10895}
10896
10897/// §6.1: the server caps total operations per request (reference default
10898/// 500) and rejects the whole batch with `sync.too_many_operations`; the
10899/// client "splits and retries". Splitting happens at build time: commits are
10900/// included IN ORDER until the operation budget is spent, the rest wait for
10901/// the next round.
10902const PUSH_OPS_PER_REQUEST: usize = 500;
10903const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10904const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10905const MAX_LOCAL_PURGE_VALUES: usize = 128;
10906const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10907pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10908
10909/// Dirty visible tables; `None` means a whole-replica recovery/reset.
10910struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10911
10912impl OverlayDirty {
10913    fn clean() -> Self {
10914        Self(RefCell::new(Some(BTreeSet::new())))
10915    }
10916    fn get(&self) -> bool {
10917        self.0
10918            .borrow()
10919            .as_ref()
10920            .is_none_or(|tables| !tables.is_empty())
10921    }
10922    fn set(&self, dirty: bool) {
10923        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10924    }
10925    fn table(&self, table: &str) {
10926        if let Some(tables) = self.0.borrow_mut().as_mut() {
10927            tables.insert(table.to_owned());
10928        }
10929    }
10930    fn snapshot(&self) -> Option<BTreeSet<String>> {
10931        self.0.borrow().clone()
10932    }
10933    fn restore(&self, tables: Option<BTreeSet<String>>) {
10934        *self.0.borrow_mut() = tables;
10935    }
10936}
10937
10938pub struct SyncClient {
10939    #[cfg(feature = "bench-internals")]
10940    benchmark_phases: Recorder,
10941    conn: Connection,
10942    schema: ClientSchema,
10943    client_id: String,
10944    limits: ClientLimits,
10945    subs: Vec<Subscription>,
10946    outbox: Vec<OutboxCommit>,
10947    failed_commits: Vec<FailedCommit>,
10948    retain_failed_commits: bool,
10949    conflicts: Vec<ConflictRecord>,
10950    rejections: Vec<RejectionRecord>,
10951    schema_floor: Option<SchemaFloor>,
10952    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
10953    lease_state: Option<LeaseState>,
10954    /// §1.6: the schema-floor response stops syncing until an upgrade.
10955    stopped: bool,
10956    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
10957    upgrading: bool,
10958    /// §8.4 coalesced sync-needed signal.
10959    sync_needed: bool,
10960    /// §8.8: how sync rounds treat the realtime binding, and its exact
10961    /// availability state with the evidence behind a lost or refused one.
10962    realtime_policy: RealtimePolicy,
10963    realtime_state: RealtimeState,
10964    realtime_reason_code: Option<String>,
10965    realtime_retry_delay_ms: Option<u64>,
10966    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
10967    presence: HashMap<String, HashMap<String, PresencePeer>>,
10968    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
10969    /// host may pin it (conformance runs on a virtual clock).
10970    now_ms: Option<i64>,
10971    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
10972    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
10973    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
10974    encryption: crate::values::EncryptionConfig,
10975    /// Fail-closed host bootstrap gate. While set, command hosts permit only
10976    /// status/lifecycle inspection and an exact authorized local purge.
10977    security_preflight: bool,
10978    security_epoch: u64,
10979    transport_enabled: bool,
10980    transport_epoch: u64,
10981    subscription_epoch: u64,
10982    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
10983    /// means the feature is off; the descriptor write and the container orphan
10984    /// sweep still happen.
10985    previous_version: Option<PreviousVersionContextConfig>,
10986    /// Per-table primary-key upsert SQL, built once per (full table name) —
10987    /// the row write path runs per row during bootstrap (§5.6), so the SQL
10988    /// string (and, via `prepare_cached`, its compiled statement) is reused
10989    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
10990    /// schema reset (the column lists may have changed).
10991    insert_sql: RefCell<HashMap<String, String>>,
10992    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
10993    /// reset and restart explicitly invalidate every table.
10994    overlay_dirty: OverlayDirty,
10995    // First SQLite failure of the current operation, before savepoint cleanup.
10996    storage_failure: RefCell<Option<QueryReadFailure>>,
10997    /// Test-only structural performance signal: response processing must not
10998    /// turn a batch of acknowledgements into one full overlay rebuild each.
10999    #[cfg(test)]
11000    overlay_rebuild_count: Cell<usize>,
11001    #[cfg(test)]
11002    acknowledged_replay_count: Cell<usize>,
11003    /// Test-only structural performance signal: outcome retention is enforced
11004    /// once per response rather than once per acknowledgement.
11005    #[cfg(test)]
11006    outcome_prune_count: Cell<usize>,
11007    /// Exact observer-transaction output drained by command/FFI hosts.
11008    progress: ProgressObserver,
11009    change_queue: VecDeque<ClientChangeBatch>,
11010    sync_intent_queue: VecDeque<SyncIntent>,
11011    /// Explicit exponential retry policy for transient transport failures.
11012    retry_delay_ms: u64,
11013    /// §7.6: the background retry the current round scheduled, if any.
11014    round_retry_delay_ms: Option<u64>,
11015    /// A round whose response never completed (§1.5): the client keeps its
11016    /// code and reports it on every following request until a round receives a
11017    /// complete, decodable response. The server already recorded the round, so
11018    /// nothing else can tell it the round failed on this side.
11019    round_failure: Option<String>,
11020    active_round: Option<uuid::Uuid>,
11021    last_round: Option<DiagnosticLastRound>,
11022    last_change: Option<DiagnosticLastChange>,
11023    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
11024    query_failures: Vec<DiagnosticQueryFailure>,
11025    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
11026    coalesced: Option<CoalescedApply>,
11027    /// A coalesced apply transaction of the current response failed; every
11028    /// later observation of that response fails until it ends.
11029    coalesced_failure: Option<String>,
11030}
11031
11032/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
11033/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
11034/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
11035struct CoalescedApply {
11036    rows: usize,
11037    subs: Vec<Subscription>,
11038    outbox: Vec<OutboxCommit>,
11039    failed_commits: Vec<FailedCommit>,
11040    conflicts: Vec<ConflictRecord>,
11041    rejections: Vec<RejectionRecord>,
11042    schema_floor: Option<SchemaFloor>,
11043    lease_state: Option<LeaseState>,
11044    stopped: bool,
11045    upgrading: bool,
11046    sync_needed: bool,
11047    overlay_dirty: Option<BTreeSet<String>>,
11048    change_queue: usize,
11049    sync_intent_queue: usize,
11050    last_change: Option<DiagnosticLastChange>,
11051}
11052
11053const COALESCED_APPLY_ROWS: usize = 1024;
11054
11055pub(crate) fn quote_ident(name: &str) -> String {
11056    format!("\"{}\"", name.replace('"', "\"\""))
11057}
11058/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
11059/// previous-version module reads and writes the SAME records as the client
11060/// instead of growing a parallel meta layer.
11061pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
11062    conn.query_row(
11063        "SELECT value FROM _syncular_meta WHERE key = ?1",
11064        rusqlite::params![key],
11065        |row| row.get::<_, String>(0),
11066    )
11067    .ok()
11068}
11069
11070pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
11071    let _ = conn.execute(
11072        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
11073        rusqlite::params![key, value],
11074    );
11075}
11076
11077pub(crate) fn meta_delete(conn: &Connection, key: &str) {
11078    let _ = conn.execute(
11079        "DELETE FROM _syncular_meta WHERE key = ?1",
11080        rusqlite::params![key],
11081    );
11082}
11083
11084/// Seek by the existing key while retaining the exact legacy text match.
11085fn row_id_predicate(table: &TableSchema) -> String {
11086    let key = quote_ident(&table.primary_key);
11087    let text_match = format!("CAST({key} AS TEXT) = ?1");
11088    match table.columns[table.pk_index].ty {
11089        ColumnType::String | ColumnType::Json => {
11090            format!("{key} = ?1 AND {text_match}")
11091        }
11092        // Native columns have no declared SQLite affinity. Match the stored
11093        // integer bind type explicitly. Unary + removes expression affinity so
11094        // SQLite can seek the typeless key; the text check preserves exact IDs.
11095        ColumnType::Integer | ColumnType::Boolean => {
11096            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
11097        }
11098        // REAL-to-text conversion can round distinct values to the same text.
11099        // Narrowing those matches by numeric equality would change behavior.
11100        _ => text_match,
11101    }
11102}
11103
11104fn is_local_operation_code_like(value: &str) -> bool {
11105    let bytes = value.as_bytes();
11106    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
11107        && bytes
11108            .iter()
11109            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
11110}
11111
11112fn base_table(name: &str) -> String {
11113    quote_ident(&format!("_syncular_base_{name}"))
11114}
11115
11116/// §4.8: a stable, server-opaque key for a window base — table + variable +
11117/// canonical fixed scopes. Two `set_window` calls with the same base
11118/// address the same registry rows.
11119/// §4.8 deferred eviction record: (sub id, table, effective scope map).
11120type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
11121
11122fn window_base_key(base: &WindowBase) -> String {
11123    format!(
11124        "{}\0{}\0{}",
11125        base.table,
11126        base.variable,
11127        canonical_scope_json(&base.fixed_scopes)
11128    )
11129}
11130
11131/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
11132fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
11133    let mut scopes = base.fixed_scopes.clone();
11134    scopes.retain(|(k, _)| k != &base.variable);
11135    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
11136    scopes
11137}
11138
11139/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
11140/// echoed not interpreted by the server, so the exact hash is client
11141/// convention; SHA-256 matches the SPEC's worked example.
11142fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
11143    let canonical = canonical_scope_json(&unit_scopes(base, unit));
11144    let digest = Sha256::digest(canonical.as_bytes());
11145    let hex = bytes_to_hex(&digest);
11146    format!("w:{}:{}", base.table, &hex[..16])
11147}
11148
11149fn visible_table(name: &str) -> String {
11150    quote_ident(name)
11151}
11152
11153const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11154
11155/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
11156/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
11157fn is_synced_table_name(name: &str) -> bool {
11158    if name.starts_with("sqlite_") {
11159        return false;
11160    }
11161    if name.starts_with("_syncular_base_") {
11162        return true; // the base half of a synced table pair
11163    }
11164    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
11165    !name.starts_with("_syncular_")
11166}
11167
11168/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
11169fn blob_id_for(bytes: &[u8]) -> String {
11170    let digest = Sha256::digest(bytes);
11171    format!("sha256:{}", bytes_to_hex(&digest))
11172}
11173
11174/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
11175/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
11176/// per row on the §5.6 bootstrap path), scalars bind owned.
11177enum RowParam<'a> {
11178    Cell(&'a Option<ColumnValue>),
11179    Version(i64),
11180}
11181
11182impl rusqlite::ToSql for RowParam<'_> {
11183    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11184        Ok(match self {
11185            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11186            RowParam::Cell(cell) => match cell {
11187                None => ToSqlOutput::Owned(SqlValue::Null),
11188                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11189                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11190                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11191                Some(ColumnValue::Boolean(b)) => {
11192                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11193                }
11194                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11195                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11196                }
11197                // §5.10: crdt bytes store as BLOB, like bytes.
11198                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11199                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
11200                }
11201            },
11202        })
11203    }
11204}
11205
11206// Retain a typed primary key across the current import chunk without copying row payloads.
11207fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11208    match param.to_sql().map_err(|error| error.to_string())? {
11209        ToSqlOutput::Owned(value) => Ok(value),
11210        ToSqlOutput::Borrowed(value) => {
11211            SqlValue::try_from(value).map_err(|error| error.to_string())
11212        }
11213        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11214    }
11215}
11216
11217/// §5.3 image cell → bind parameter, strict per the declared column type
11218/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
11219/// nullable). Mismatches are image-producer violations. Returns the cell
11220/// borrowed when the stored representation already matches what the row
11221/// codec would write, or the normalized scalar (boolean → 0/1, float from
11222/// INTEGER → REAL) otherwise — the local write is byte-identical to the
11223/// old convert-then-insert path without allocating per cell.
11224fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11225    use ssp2::segment::ColumnType;
11226    let mismatch = || {
11227        Err(format!(
11228            "image column {:?} holds a value of the wrong type",
11229            column.name
11230        ))
11231    };
11232    match value {
11233        ValueRef::Null => {
11234            if !column.nullable {
11235                return Err(format!(
11236                    "image column {:?} is NULL but not nullable",
11237                    column.name
11238                ));
11239            }
11240            Ok(ToSqlOutput::Owned(SqlValue::Null))
11241        }
11242        ValueRef::Integer(i) => match column.ty {
11243            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11244            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11245            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11246            _ => mismatch(),
11247        },
11248        ValueRef::Real(_) => match column.ty {
11249            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11250            _ => mismatch(),
11251        },
11252        ValueRef::Text(t) => {
11253            std::str::from_utf8(t)
11254                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11255            match column.ty {
11256                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11257                    Ok(ToSqlOutput::Borrowed(value))
11258                }
11259                _ => mismatch(),
11260            }
11261        }
11262        ValueRef::Blob(_) => match column.ty {
11263            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
11264            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11265            _ => mismatch(),
11266        },
11267    }
11268}
11269
11270pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11271    use rusqlite::types::ValueRef;
11272    match value {
11273        ValueRef::Null => Value::Null,
11274        ValueRef::Integer(i) => match column.ty {
11275            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11276            ssp2::segment::ColumnType::Float => {
11277                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11278            }
11279            _ => Value::from(i),
11280        },
11281        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11282        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11283        ValueRef::Blob(b) => {
11284            let mut map = Map::new();
11285            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11286            Value::Object(map)
11287        }
11288    }
11289}
11290
11291/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
11292/// Objects are accepted in lossless `{"$bytes": hex}` and
11293/// `{"$bigint": decimal}` envelope forms.
11294fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11295    Ok(match value {
11296        Value::Null => SqlValue::Null,
11297        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11298        Value::Number(n) => {
11299            if let Some(i) = n.as_i64() {
11300                SqlValue::Integer(i)
11301            } else if let Some(f) = n.as_f64() {
11302                SqlValue::Real(f)
11303            } else {
11304                return Err(format!("query param number {n} is out of range"));
11305            }
11306        }
11307        Value::String(s) => SqlValue::Text(s.clone()),
11308        Value::Object(_) => {
11309            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11310                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11311            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11312                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11313                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11314                })?)
11315            } else {
11316                return Err(
11317                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11318                        .to_owned(),
11319                );
11320            }
11321        }
11322        Value::Array(_) => return Err("query array params are not supported".to_owned()),
11323    })
11324}
11325
11326/// Map a rusqlite value with no schema column to consult (arbitrary query
11327/// output): integers/reals/text pass through by stored affinity, blobs ride
11328/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
11329/// schema column type to recover booleans/floats/json.
11330fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11331    use rusqlite::types::ValueRef;
11332    match value {
11333        ValueRef::Null => Value::Null,
11334        ValueRef::Integer(i) => {
11335            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
11336            // ordinary UI-sized integers ergonomic and envelope only values
11337            // beyond JavaScript's safe range.
11338            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11339            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11340                Value::from(i)
11341            } else {
11342                let mut map = Map::new();
11343                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11344                Value::Object(map)
11345            }
11346        }
11347        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11348        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11349        ValueRef::Blob(b) => {
11350            let mut map = Map::new();
11351            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11352            Value::Object(map)
11353        }
11354    }
11355}
11356
11357/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
11358fn query_connection(
11359    conn: &Connection,
11360    sql: &str,
11361    params: &[QueryValue],
11362) -> Result<Vec<QueryRow>, QueryReadFailure> {
11363    crate::query_guard::assert_read_only_query(sql)?;
11364    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11365    let bound: Vec<SqlValue> = params
11366        .iter()
11367        .map(json_param_to_sql)
11368        .collect::<Result<_, _>>()?;
11369    let mut stmt = conn.prepare(&lowered_sql)?;
11370    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11371    let bound_refs: Vec<&dyn rusqlite::ToSql> =
11372        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11373    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11374    let mut out = Vec::new();
11375    while let Some(row) = sql_rows.next()? {
11376        let mut record = Map::new();
11377        for (i, name) in column_names.iter().enumerate() {
11378            let value = row.get_ref(i)?;
11379            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11380        }
11381        out.push(record);
11382    }
11383    Ok(out)
11384}
11385
11386fn persisted_window_state(
11387    conn: &Connection,
11388    base: &WindowBase,
11389) -> Result<WindowState, QueryReadFailure> {
11390    let mut stmt = conn.prepare(
11391        "SELECT windows.unit, subscriptions.state_json
11392           FROM _syncular_windows AS windows
11393           JOIN _syncular_subscriptions AS subscriptions
11394             ON subscriptions.id = windows.sub_id
11395          WHERE windows.base = ?1
11396          ORDER BY windows.unit ASC",
11397    )?;
11398    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11399        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11400    })?;
11401    let mut units = Vec::new();
11402    let mut pending = Vec::new();
11403    for row in rows {
11404        let (unit, raw) = row?;
11405        let state: Value = serde_json::from_str(&raw)
11406            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11407        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11408            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11409            || state
11410                .get("bootstrapState")
11411                .is_some_and(|value| !value.is_null());
11412        if is_pending {
11413            pending.push(unit.clone());
11414        }
11415        units.push(unit);
11416    }
11417    Ok(WindowState { units, pending })
11418}
11419
11420/// One read-only statement in a §7.5 snapshot read.
11421#[derive(Debug, Clone, Copy)]
11422pub struct SnapshotStatement<'a> {
11423    pub sql: &'a str,
11424    pub params: &'a [Value],
11425}
11426
11427/// A §7.5 atomic read request: read-only statements, window coverage,
11428/// subscription catch-up states, and commit deliveries, all resolved from one
11429/// SQLite read transaction and one revision.
11430#[derive(Debug, Clone, Default)]
11431pub struct SnapshotReadRequest<'a> {
11432    pub statements: Vec<SnapshotStatement<'a>>,
11433    pub coverage: &'a [WindowCoverage],
11434    pub subscriptions: Vec<String>,
11435    pub commit_ids: Vec<String>,
11436}
11437
11438#[cfg(test)]
11439thread_local! {
11440    /// Test-only rendezvous that runs between the statements of one snapshot
11441    /// read. A test uses it to commit on another connection mid-read and prove
11442    /// the read transaction's snapshot and revision stay stable.
11443    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11444        std::cell::RefCell::new(None);
11445}
11446
11447#[cfg(test)]
11448fn run_snapshot_read_hook() {
11449    SNAPSHOT_READ_HOOK.with(|slot| {
11450        if let Some(hook) = slot.borrow_mut().take() {
11451            hook();
11452        }
11453    });
11454}
11455
11456/// §7.5: resolve a request over one SQLite read transaction. The revision,
11457/// every statement's rows, coverage, catch-up states, and deliveries are
11458/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
11459/// read back and releases the connection.
11460fn snapshot_read_connection(
11461    conn: &Connection,
11462    request: &SnapshotReadRequest<'_>,
11463) -> Result<SnapshotRead, QueryReadFailure> {
11464    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11465    let corrupt = |message: &str| QueryReadFailure {
11466        code: Some("sync.local_corrupt"),
11467        sqlite_code: None,
11468        sqlite_message: None,
11469        rollback_failure: None,
11470        message: message.to_owned(),
11471    };
11472    let stored_failure = |error: rusqlite::Error, message: &str| match error {
11473        rusqlite::Error::Utf8Error(..)
11474        | rusqlite::Error::InvalidColumnType(..)
11475        | rusqlite::Error::FromSqlConversionFailure(..)
11476        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11477        _ => QueryReadFailure::from(error),
11478    };
11479    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11480        // The durable revision as a canonical decimal `u64`; a missing marker
11481        // is the legacy zero, and any other non-canonical value is corrupt.
11482        let revision: Option<String> = conn
11483            .query_row(
11484                "SELECT value FROM _syncular_meta WHERE key = ?1",
11485                rusqlite::params![LOCAL_REVISION_KEY],
11486                |row| row.get(0),
11487            )
11488            .optional()
11489            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11490        let revision = match revision {
11491            None => "0".to_owned(),
11492            Some(value)
11493                if value == "0"
11494                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11495                        && value.bytes().all(|b| b.is_ascii_digit())
11496                        && value.parse::<u64>().is_ok()) =>
11497            {
11498                value
11499            }
11500            Some(_) => return Err(corrupt("persisted local revision is invalid")),
11501        };
11502        let mut queries = Vec::with_capacity(request.statements.len());
11503        for statement in &request.statements {
11504            queries.push(query_connection(conn, statement.sql, statement.params)?);
11505            #[cfg(test)]
11506            run_snapshot_read_hook();
11507        }
11508        let mut pending = Vec::new();
11509        let mut missing = Vec::new();
11510        for requested in request.coverage {
11511            let base_key = window_base_key(&requested.base);
11512            let state = persisted_window_state(conn, &requested.base)?;
11513            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11514                let reference = WindowUnitRef {
11515                    base_key: base_key.clone(),
11516                    unit: unit.clone(),
11517                };
11518                if !state.units.iter().any(|held| held == &unit) {
11519                    missing.push(reference);
11520                } else if state.pending.iter().any(|held| held == &unit) {
11521                    pending.push(reference);
11522                }
11523            }
11524        }
11525        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11526        for id in &request.subscriptions {
11527            let row: Option<(String, String)> = conn
11528                .query_row(
11529                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11530                    rusqlite::params![id],
11531                    |row| Ok((row.get(0)?, row.get(1)?)),
11532                )
11533                .optional()
11534                .map_err(|error| {
11535                    stored_failure(error, "persisted subscription state is invalid")
11536                })?;
11537            let Some((table, raw)) = row else {
11538                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11539                continue;
11540            };
11541            // Every persisted field is required to be well typed; a corrupt
11542            // record fails typed instead of inventing a state.
11543            let state: Value = serde_json::from_str(&raw)
11544                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11545            let status = match state.get("status").and_then(Value::as_str) {
11546                Some(value @ ("active" | "revoked" | "failed")) => value,
11547                _ => return Err(corrupt("persisted subscription state is invalid")),
11548            };
11549            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11550                return Err(corrupt("persisted subscription state is invalid"));
11551            };
11552            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11553                return Err(corrupt("persisted subscription state is invalid"));
11554            }
11555            let effective_scopes = state
11556                .get("effectiveScopes")
11557                .filter(|value| !value.is_null());
11558            if let Some(scopes) = effective_scopes {
11559                json_to_scope_map(scopes)
11560                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11561            }
11562            let has_resume_token = match state.get("bootstrapState") {
11563                None | Some(Value::Null) => false,
11564                Some(Value::String(_)) => true,
11565                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11566            };
11567            // A present reasonCode must be a string; a non-string value is a
11568            // corrupt record, never silently dropped.
11569            let reason_code = match state.get("reasonCode") {
11570                None | Some(Value::Null) => None,
11571                Some(Value::String(value)) => Some(value.clone()),
11572                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11573            };
11574            let active = status == "active";
11575            subscriptions.push(SubscriptionCatchup::Known {
11576                id: id.clone(),
11577                table,
11578                status: status.to_owned(),
11579                cursor,
11580                has_resume_token,
11581                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11582                known_pending_pages: active && (has_resume_token || cursor < 0),
11583                effective_scopes: effective_scopes.cloned(),
11584                reason_code,
11585            });
11586        }
11587        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11588        for id in &request.commit_ids {
11589            let pending: bool = conn.query_row(
11590                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11591                rusqlite::params![id],
11592                |row| row.get(0),
11593            )?;
11594            if pending {
11595                deliveries.push(CommitDelivery::Pending {
11596                    client_commit_id: id.clone(),
11597                });
11598                continue;
11599            }
11600            let row = conn
11601                .query_row(
11602                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11603                            resolution, resolved_at_ms, replacement_client_commit_id
11604                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11605                    rusqlite::params![id],
11606                    StoredCommitOutcomeRow::from_row,
11607                )
11608                .optional()
11609                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11610            deliveries.push(match row {
11611                Some(row) => CommitDelivery::Known {
11612                    client_commit_id: id.clone(),
11613                    outcome: SyncClient::outcome_from_row(row)
11614                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11615                },
11616                None => CommitDelivery::Unknown {
11617                    client_commit_id: id.clone(),
11618                },
11619            });
11620        }
11621        Ok(SnapshotRead {
11622            revision,
11623            queries,
11624            coverage: CoverageSnapshot {
11625                complete: pending.is_empty() && missing.is_empty(),
11626                pending,
11627                missing,
11628            },
11629            subscriptions,
11630            deliveries,
11631        })
11632    })();
11633    match result {
11634        Ok(read) => {
11635            conn.execute_batch("RELEASE syncular_snapshot_read")?;
11636            Ok(read)
11637        }
11638        Err(mut error) => {
11639            if let Err(rollback) = conn
11640                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11641            {
11642                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11643            }
11644            Err(error)
11645        }
11646    }
11647}
11648
11649fn snapshot_connection(
11650    conn: &Connection,
11651    sql: &str,
11652    params: &[Value],
11653    coverage: &[WindowCoverage],
11654) -> Result<QuerySnapshot, QueryReadFailure> {
11655    let read = snapshot_read_connection(
11656        conn,
11657        &SnapshotReadRequest {
11658            statements: vec![SnapshotStatement { sql, params }],
11659            coverage,
11660            subscriptions: Vec::new(),
11661            commit_ids: Vec::new(),
11662        },
11663    )?;
11664    Ok(QuerySnapshot {
11665        revision: read.revision,
11666        rows: read
11667            .queries
11668            .into_iter()
11669            .next()
11670            .expect("single-query snapshot returns one result"),
11671        coverage: read.coverage,
11672    })
11673}
11674
11675/// A long-lived read-only SQLite sidecar for latency-critical native views.
11676/// Network rounds stay serialized on the mutable core owner, while atomic
11677/// query snapshots use this independent connection and therefore never queue
11678/// behind HTTP/WebSocket latency.
11679pub struct FileQuerySnapshotReader {
11680    path: String,
11681    conn: Option<Connection>,
11682}
11683
11684impl FileQuerySnapshotReader {
11685    #[must_use]
11686    pub fn new(path: impl Into<String>) -> Self {
11687        Self {
11688            path: path.into(),
11689            conn: None,
11690        }
11691    }
11692
11693    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11694        if self.conn.is_none() {
11695            let conn = Connection::open_with_flags(
11696                &self.path,
11697                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11698            )
11699            .map_err(QueryReadFailure::from)?;
11700            conn.busy_timeout(std::time::Duration::from_millis(250))
11701                .map_err(QueryReadFailure::from)?;
11702            self.conn = Some(conn);
11703        }
11704        self.conn
11705            .as_ref()
11706            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11707    }
11708
11709    /// One atomic read over the shared primitive: every statement, coverage,
11710    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
11711    pub fn snapshot_read(
11712        &mut self,
11713        request: &SnapshotReadRequest<'_>,
11714    ) -> Result<SnapshotRead, QueryReadFailure> {
11715        snapshot_read_connection(self.connection()?, request)
11716    }
11717
11718    /// The current durable local revision. A host can skip a read when it is
11719    /// unchanged; a corrupt or unreadable marker fails typed.
11720    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11721        Ok(self
11722            .snapshot_read(&SnapshotReadRequest::default())?
11723            .revision)
11724    }
11725
11726    /// One subscription's persisted catch-up state (see the §7.5 contract).
11727    pub fn subscription_catchup(
11728        &mut self,
11729        id: &str,
11730    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11731        let read = self.snapshot_read(&SnapshotReadRequest {
11732            subscriptions: vec![id.to_owned()],
11733            ..Default::default()
11734        })?;
11735        Ok(read
11736            .subscriptions
11737            .into_iter()
11738            .next()
11739            .expect("one requested catch-up"))
11740    }
11741
11742    /// One client commit's delivery status: pending, its retained outcome, or
11743    /// an explicit unknown (never held, or pruned by retention).
11744    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11745        let read = self.snapshot_read(&SnapshotReadRequest {
11746            commit_ids: vec![id.to_owned()],
11747            ..Default::default()
11748        })?;
11749        Ok(read
11750            .deliveries
11751            .into_iter()
11752            .next()
11753            .expect("one requested delivery"))
11754    }
11755
11756    /// Hosts report an owned failure, and the first success after it, to the
11757    /// owning core through [`SyncClient::record_query_read`] (§7.6).
11758    pub fn query_snapshot(
11759        &mut self,
11760        sql: &str,
11761        params: &[Value],
11762        coverage: &[WindowCoverage],
11763    ) -> Result<QuerySnapshot, QueryReadFailure> {
11764        snapshot_connection(self.connection()?, sql, params, coverage)
11765    }
11766}
11767
11768fn validate_authority_reads(
11769    schema: &ClientSchema,
11770    declarations: &[crate::api::AuthorityReadDeclaration],
11771) -> Result<(), String> {
11772    let mut seen = BTreeSet::new();
11773    for read in declarations {
11774        let table = schema
11775            .table(&read.table)
11776            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11777        let plain = |name: &str| {
11778            table.columns.iter().enumerate().any(|(i, c)| {
11779                c.name == name
11780                    && name != "authority_version"
11781                    && !name.starts_with("_sync")
11782                    && !table.encrypted_columns.iter().any(|e| e.index == i)
11783                    && matches!(
11784                        c.ty,
11785                        ssp2::segment::ColumnType::String
11786                            | ssp2::segment::ColumnType::Integer
11787                            | ssp2::segment::ColumnType::Float
11788                            | ssp2::segment::ColumnType::Boolean
11789                            | ssp2::segment::ColumnType::Json
11790                    )
11791            })
11792        };
11793        if !seen.insert(&read.table)
11794            || read.columns.is_empty()
11795            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11796            || !read.columns.contains(&table.primary_key)
11797            || !read.columns.iter().all(|c| plain(c))
11798            || read.scopes.is_empty()
11799            || !read.scopes.iter().all(|(v, values)| {
11800                table
11801                    .scope_column(v)
11802                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11803                    && !values.is_empty()
11804                    && values.iter().all(|v| !v.is_empty() && v != "*")
11805            })
11806        {
11807            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11808        }
11809    }
11810    Ok(())
11811}
11812
11813// §7.4.2: every open and recreation checks before any persistent writes.
11814/// A marker read that hit a recognized SQLite storage failure keeps that
11815/// classification (BUSY/LOCKED stay retryable); only an unclassified read
11816/// failure is the invalid-marker `sync.local_corrupt`.
11817fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11818    let failure = QueryReadFailure::from(error);
11819    if failure.code.is_some() {
11820        failure.to_string()
11821    } else {
11822        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11823    }
11824}
11825
11826/// §7.1: reject an invalid configured client limit before any storage is
11827/// opened or written. Zero would wedge the push queue and an out-of-range
11828/// value would differ per target; the TS core enforces the same bounds.
11829fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11830    if limits.outcome_retention_max_entries == Some(0) {
11831        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11832    }
11833    if [
11834        limits.max_push_commits_per_request,
11835        limits.max_push_operations_per_request,
11836        limits.max_push_request_bytes,
11837    ]
11838    .into_iter()
11839    .flatten()
11840    .any(|value| value == 0 || value > u32::MAX as usize)
11841    {
11842        return Err(
11843            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11844        );
11845    }
11846    Ok(())
11847}
11848
11849fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11850    let corrupt =
11851        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11852    let table_type: Option<String> = conn
11853        .query_row(
11854            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11855            [],
11856            |row| row.get(0),
11857        )
11858        .optional()
11859        .map_err(schema_marker_read_failure)?;
11860    let Some(table_type) = table_type else {
11861        return Ok(None);
11862    };
11863    if table_type != "table" {
11864        return Err(corrupt());
11865    }
11866    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
11867    // whichever row SQLite returns first, and an older one would drive a
11868    // destructive reset over a newer replica.
11869    let mut statement = conn
11870        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11871        .map_err(schema_marker_read_failure)?;
11872    let mut rows = statement
11873        .query([LOCAL_SCHEMA_VERSION_KEY])
11874        .map_err(schema_marker_read_failure)?;
11875    let value: Option<String> = rows
11876        .next()
11877        .map_err(schema_marker_read_failure)?
11878        .map(|row| row.get(0))
11879        .transpose()
11880        .map_err(schema_marker_read_failure)?;
11881    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11882        return Err(corrupt());
11883    }
11884    let Some(value) = value else {
11885        let has_descriptor = conn
11886            .query_row(
11887                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11888                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11889                |_| Ok(()),
11890            )
11891            .optional()
11892            .map_err(schema_marker_read_failure)?
11893            .is_some();
11894        if has_descriptor {
11895            return Err(corrupt());
11896        }
11897        return Ok(None);
11898    };
11899    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11900        || !value.bytes().all(|b| b.is_ascii_digit())
11901    {
11902        return Err(corrupt());
11903    }
11904    let version = value.parse::<i32>().map_err(|_| corrupt())?;
11905    if version > requested {
11906        return Err(
11907            "client.schema_downgrade: persisted local schema is newer than the requested schema"
11908                .to_owned(),
11909        );
11910    }
11911    Ok(Some(version))
11912}
11913
11914/// A file replica on a browser worker must recover a hot journal before any
11915/// statement reads it. `wasm_vfs` replaces the installed OPFS VFS's reserved
11916/// lock callback, so install it before the connection opens its file.
11917#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11918fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11919    if path.is_empty() || path == ":memory:" {
11920        return Ok(());
11921    }
11922    crate::wasm_vfs::disable_foreign_lock_check()
11923}
11924
11925#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11926fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11927    Ok(())
11928}
11929
11930impl SyncClient {
11931    pub fn new_with_identity(
11932        client_id: Option<String>,
11933        schema_json: &Value,
11934        limits: ClientLimits,
11935    ) -> Result<Self, String> {
11936        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11937        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11938        Self::with_connection(resolved, schema_json, limits, conn)
11939    }
11940
11941    pub fn new(
11942        client_id: String,
11943        schema_json: &Value,
11944        limits: ClientLimits,
11945    ) -> Result<Self, String> {
11946        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11947        Self::with_connection(client_id, schema_json, limits, conn)
11948    }
11949
11950    /// Build a client backed by an on-disk SQLite database at `path` — the
11951    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
11952    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
11953    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
11954    /// of the command router's dependency set (the router only holds a path).
11955    pub fn open_path(
11956        client_id: String,
11957        schema_json: &Value,
11958        limits: ClientLimits,
11959        path: &str,
11960    ) -> Result<Self, String> {
11961        validate_client_limits(&limits)?;
11962        parse_schema_json(schema_json)?;
11963        prepare_wasm_replica(path)?;
11964        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11965        Self::with_connection(client_id, schema_json, limits, conn)
11966    }
11967
11968    /// Open a file replica and resolve its client id. Native targets require
11969    /// WAL for the independent snapshot reader; `wasm32-unknown-unknown`
11970    /// accepts the sqlite-wasm worker journal instead.
11971    pub fn open_path_with_identity(
11972        client_id: Option<String>,
11973        schema_json: &Value,
11974        limits: ClientLimits,
11975        path: &str,
11976    ) -> Result<Self, String> {
11977        Self::open_path_with_identity_journal(
11978            client_id,
11979            schema_json,
11980            limits,
11981            path,
11982            !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11983        )
11984    }
11985
11986    fn open_path_with_identity_journal(
11987        client_id: Option<String>,
11988        schema_json: &Value,
11989        limits: ClientLimits,
11990        path: &str,
11991        require_wal: bool,
11992    ) -> Result<Self, String> {
11993        validate_client_limits(&limits)?;
11994        // §7.4.2: an invalid requested version is refused before the replica is
11995        // even created, so a rejected constructor leaves no file behind.
11996        parse_schema_json(schema_json)?;
11997        prepare_wasm_replica(path)?;
11998        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11999        // A file-backed native client uses an independent read connection for
12000        // latency-critical snapshots. WAL is SQLite's intended reader/writer
12001        // concurrency mode: a view read never holds a rollback-journal lock
12002        // that delays the client's next commit. The busy timeout must precede
12003        // this replica's own startup transaction; WAL is applied only after
12004        // startup succeeded, because a refused open must leave the replica's
12005        // journal mode and contents untouched (§7.4.2).
12006        conn.busy_timeout(std::time::Duration::from_millis(250))
12007            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
12008        // Durability is the caller's contract here, not the VFS default: a
12009        // rollback-journal commit needs FULL before it writes the database
12010        // pages, or a killed worker can leave a half-written image behind.
12011        // WAL below inherits the same setting.
12012        conn.pragma_update(None, "synchronous", "FULL")
12013            .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
12014        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
12015        // A browser worker's sqlite-wasm VFS has no WAL mode, and the wasm
12016        // host has no independent snapshot reader. The WAL requirement exists
12017        // for native file replicas, where it is the reader/writer boundary.
12018        if require_wal {
12019            let journal_mode: String = client
12020                .conn
12021                .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
12022                .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12023            if journal_mode != "wal" {
12024                return Err(
12025                    "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
12026                );
12027            }
12028        }
12029        Ok(client)
12030    }
12031
12032    /// Delete a file replica at `path`, including its `-journal`, `-wal`, and
12033    /// `-shm` sidecars, through the VFS that owns it. Use this after
12034    /// `client.storage_corrupt`: an OPFS SAH pool stores those files under
12035    /// opaque names, so deleting the pool directory behind SQLite bypasses the
12036    /// pool's slot accounting.
12037    ///
12038    /// Close the client and every [`FileQuerySnapshotReader`] on `path` first;
12039    /// a VFS cannot delete a file another handle still owns. The next open
12040    /// creates a fresh replica (new client id) and re-bootstraps from the
12041    /// server. A memory replica has nothing to delete and fails with
12042    /// `sync.invalid_request`.
12043    pub fn reset_replica(path: &str) -> Result<(), String> {
12044        if path.is_empty() || path == ":memory:" {
12045            return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
12046        }
12047        // SAFETY: a null VFS name asks SQLite for its process-wide default VFS,
12048        // the same one `Connection::open(path)` above selects. `xDelete` is
12049        // that VFS's own function pointer, and `c_name` outlives the call.
12050        let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
12051        if vfs.is_null() {
12052            return Err("client.storage_io: no SQLite VFS is registered".to_owned());
12053        }
12054        let delete = unsafe { (*vfs).xDelete }
12055            .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
12056        for suffix in ["-wal", "-shm", "-journal", ""] {
12057            let file_name = format!("{path}{suffix}");
12058            let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
12059                format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
12060            })?;
12061            // SAFETY: `vfs` and its `xDelete` pointer come from the live
12062            // SQLite library; `c_name` is a valid NUL-terminated path.
12063            let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
12064            if code != rusqlite::ffi::SQLITE_OK {
12065                return Err(format!(
12066                    "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
12067                ));
12068            }
12069        }
12070        Ok(())
12071    }
12072
12073    #[must_use]
12074    pub fn client_id(&self) -> &str {
12075        &self.client_id
12076    }
12077
12078    /// Rotate the caller's client id (§1.5) and return the new one. Use it
12079    /// after a `sync.invalid_client_id` refusal (`recommendedAction:
12080    /// resetClientId`): the next round carries the new id. A live realtime
12081    /// socket is closed so the next connection registers with the new id.
12082    /// The replica and its outbox stay in place; wipe them yourself when the
12083    /// stored data belongs to the account that signed out.
12084    pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
12085        self.disconnect_realtime(transport);
12086        let client_id = uuid::Uuid::new_v4().to_string();
12087        self.client_id.clone_from(&client_id);
12088        self.set_meta(CLIENT_ID_KEY, &client_id);
12089        self.round_failure = None;
12090        client_id
12091    }
12092
12093    /// Build a client over a caller-supplied rusqlite connection — the seam a
12094    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
12095    /// with an on-disk database (`Connection::open(path)`) rather than the
12096    /// default `:memory:`. The connection MUST be fresh (no pre-existing
12097    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
12098    /// the same file across process restarts reuses the persisted rows.
12099    pub fn with_connection(
12100        client_id: String,
12101        schema_json: &Value,
12102        limits: ClientLimits,
12103        conn: Connection,
12104    ) -> Result<Self, String> {
12105        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
12106    }
12107
12108    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
12109    /// means a durable replica's persisted `clientId` wins over a fresh one.
12110    fn with_connection_identity(
12111        client_id: Option<String>,
12112        schema_json: &Value,
12113        limits: ClientLimits,
12114        conn: Connection,
12115    ) -> Result<Self, String> {
12116        validate_client_limits(&limits)?;
12117        let schema = parse_schema_json(schema_json)?;
12118        validate_authority_reads(&schema, &limits.authority_reads)?;
12119        // RFC 0005 D8: resolve the previous-version config before the opening
12120        // reset runs — the capture happens inside it. Bad bounds fail loud.
12121        let previous_version = match limits.previous_version_context {
12122            Some(config) => {
12123                config.validate()?;
12124                Some(config)
12125            }
12126            None => None,
12127        };
12128        let mut client = SyncClient {
12129            #[cfg(feature = "bench-internals")]
12130            benchmark_phases: Recorder::default(),
12131            conn,
12132            schema,
12133            client_id: client_id
12134                .clone()
12135                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
12136            limits,
12137            subs: Vec::new(),
12138            outbox: Vec::new(),
12139            failed_commits: Vec::new(),
12140            retain_failed_commits: false,
12141            conflicts: Vec::new(),
12142            rejections: Vec::new(),
12143            schema_floor: None,
12144            lease_state: None,
12145            stopped: false,
12146            upgrading: false,
12147            sync_needed: false,
12148            realtime_policy: RealtimePolicy::Optional,
12149            realtime_state: RealtimeState::Disconnected,
12150            realtime_reason_code: None,
12151            realtime_retry_delay_ms: None,
12152            presence: HashMap::new(),
12153            now_ms: None,
12154            encryption: crate::values::EncryptionConfig::default(),
12155            security_preflight: false,
12156            security_epoch: 0,
12157            transport_enabled: true,
12158            transport_epoch: 0,
12159            subscription_epoch: 0,
12160            previous_version,
12161            insert_sql: RefCell::new(HashMap::new()),
12162            overlay_dirty: OverlayDirty::clean(),
12163            storage_failure: RefCell::new(None),
12164            #[cfg(test)]
12165            overlay_rebuild_count: Cell::new(0),
12166            #[cfg(test)]
12167            acknowledged_replay_count: Cell::new(0),
12168            #[cfg(test)]
12169            outcome_prune_count: Cell::new(0),
12170            progress: ProgressObserver::default(),
12171            change_queue: VecDeque::new(),
12172            sync_intent_queue: VecDeque::new(),
12173            retry_delay_ms: 250,
12174            round_retry_delay_ms: None,
12175            round_failure: None,
12176            active_round: None,
12177            last_round: None,
12178            last_change: None,
12179            query_failures: Vec::new(),
12180            coalesced: None,
12181            coalesced_failure: None,
12182        };
12183        // The row write path leans on the prepared-statement cache (two
12184        // insert statements per synced table, plus the bookkeeping
12185        // statements); size it so a multi-table schema never thrashes.
12186        client
12187            .conn
12188            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12189        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
12190        // writer) covers the marker read, the identity check, and every
12191        // bookkeeping/schema write, so a concurrent open that upgraded the
12192        // replica cannot interleave between the guard and the writes, and a
12193        // refusal leaves the replica unchanged.
12194        client
12195            .conn
12196            .execute_batch("BEGIN IMMEDIATE")
12197            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12198        let startup = (|| -> Result<(), String> {
12199            // The marker guard runs inside the write transaction, so the version
12200            // it validates is the version every write below commits under.
12201            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12202            // New app indexes may reference columns that only exist after a
12203            // version-bump reset.
12204            client.create_bookkeeping_tables()?;
12205            match client.get_meta(CLIENT_ID_KEY) {
12206                Some(existing) => match client_id {
12207                    Some(requested) if existing != requested => {
12208                        return Err(format!(
12209                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12210                        ))
12211                    }
12212                    _ => client.client_id = existing,
12213                },
12214                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12215            }
12216            client.restore_persisted_state()?;
12217            client.failed_commits = client.load_failed_commits()?;
12218            match marker {
12219                None => {
12220                    client.create_synced_tables()?;
12221                    client.persist_schema_version()?;
12222                    client.save_subscription_scope_schema();
12223                }
12224                Some(version) if version == client.schema.version => {
12225                    client.create_synced_tables()?;
12226                    // RFC 0005 D1: the same-version open backfills the descriptor
12227                    // for a database first opened by an unaware binary, so the
12228                    // NEXT bump can capture without needing a schema bump first.
12229                    set_local_schema_descriptor(&client.conn, &client.schema)
12230                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12231                    client.save_subscription_scope_schema();
12232                }
12233                Some(_) => client.run_schema_reset()?,
12234            }
12235            // RFC 0005 D9: the reset sweep cannot see a container whose capture
12236            // committed in its OWN file while the replica's savepoint never
12237            // released — a crash at that seam leaves a container beside a matching
12238            // OLD marker, and the next open at that version runs no reset. Discard
12239            // it before anything can read it. No-op when no container exists.
12240            client.reconcile_previous_version_at_boot()?;
12241            client.clear_satisfied_persisted_schema_floor();
12242            client.prune_unknown_subscriptions(false)?;
12243            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
12244            // the full startup/reset reconciliation boundary, inside this open
12245            // transaction and after the schema and identity guards, so a commit
12246            // an earlier version accepted is dropped with durable rejection
12247            // evidence before any replay. A table the current schema removed
12248            // keeps the §7.4.4 send-time classification, and an unresolved
12249            // encryption key stays with the send seam because hosts configure
12250            // keys after open.
12251            client.drop_unencodable_outbox(false, None)?;
12252            if marker == Some(client.schema.version)
12253                && (!client.outbox.is_empty()
12254                    || !client.failed_commits.is_empty()
12255                    || client.has_acknowledged_rows())
12256            {
12257                // Reconstruct the visible optimistic overlay from the durable
12258                // base plus outbox instead of trusting a process-interrupted
12259                // mirror.
12260                client.overlay_dirty.set(true);
12261                client.rebuild_overlay()?;
12262            }
12263            // Every persisted active subscription needs one catch-up pull on
12264            // open: realtime only covers changes after connection, while an
12265            // idempotent setWindow correctly creates no fresh command effect.
12266            // Pending outbox work has the same restart requirement. The core
12267            // owns this intent so native hosts never poll or require an
12268            // application-issued sync().
12269            client.enqueue_startup_sync_if_needed()?;
12270            Ok(())
12271        })();
12272        match startup {
12273            Ok(()) => match client.conn.execute_batch("COMMIT") {
12274                Ok(()) => Ok(client),
12275                Err(error) => {
12276                    let failure = Self::sqlite_failure(&client.storage_failure, error);
12277                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12278                        // Keep a failed rollback's typed storage classification.
12279                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12280                    }
12281                    Err(failure)
12282                }
12283            },
12284            Err(error) => {
12285                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12286                    // Keep a failed rollback's typed storage classification.
12287                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12288                }
12289                Err(error)
12290            }
12291        }
12292    }
12293
12294    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
12295    /// against this instead of system time (conformance virtual clock).
12296    pub fn set_now_ms(&mut self, now_ms: i64) {
12297        self.now_ms = Some(now_ms);
12298    }
12299
12300    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
12301    /// The command router parses these from the `create` command's
12302    /// `encryption` config (keys as `{$bytes: hex}`).
12303    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12304        self.encryption = encryption;
12305    }
12306
12307    #[must_use]
12308    pub fn security_lifecycle(&self) -> &'static str {
12309        if self.security_preflight {
12310            "preflight"
12311        } else {
12312            "active"
12313        }
12314    }
12315
12316    #[must_use]
12317    pub fn security_preflight(&self) -> bool {
12318        self.security_preflight
12319    }
12320
12321    /// Host-owned, non-persisted network gate. Local operations remain available.
12322    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12323        if self.transport_enabled != enabled {
12324            self.transport_enabled = enabled;
12325            self.transport_epoch = self.transport_epoch.wrapping_add(1);
12326            self.sync_intent_queue.clear();
12327            self.sync_intent_queue
12328                .push_back(if enabled && !self.security_preflight {
12329                    SyncIntent::Interactive
12330                } else {
12331                    SyncIntent::None
12332                });
12333        }
12334        // A captured reply keeps its normal apply/revocation semantics.
12335        if !enabled && self.active_round.is_none() {
12336            self.disconnect_realtime(transport);
12337        }
12338    }
12339
12340    pub fn transport_enabled(&self) -> bool {
12341        self.transport_enabled
12342    }
12343
12344    /// Quarantine this replica: enter the fail-closed gate, release all
12345    /// core-owned key material, and record the gate in the database so
12346    /// reopening re-enters preflight until `activate_security` clears it.
12347    ///
12348    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
12349    /// which must NOT leave that durable mark.
12350    pub fn begin_security_preflight(&mut self) {
12351        self.seal_security_on_teardown();
12352        // Persist the quarantine so it survives handle teardown and restart:
12353        // reopening this replica re-enters preflight until activation clears it.
12354        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12355    }
12356
12357    /// Release core-owned key material as a host tears the client down,
12358    /// WITHOUT recording a quarantine.
12359    ///
12360    /// Shutting an activated client down is not a quarantine event. Persisting
12361    /// the gate here marks every cleanly closed replica as pending, and the
12362    /// next plain `create` is then refused permanently: the reopen path
12363    /// restores the flag from the marker and the create guard rejects it. The
12364    /// in-memory flag still closes the gate for anything still holding this
12365    /// instance, which is all a teardown barrier needs.
12366    pub fn seal_security_on_teardown(&mut self) {
12367        self.cancel_sync_round();
12368        self.security_preflight = true;
12369        self.security_epoch = self.security_epoch.wrapping_add(1);
12370        self.encryption = crate::values::EncryptionConfig::default();
12371        self.sync_intent_queue.clear();
12372    }
12373
12374    /// Install the post-authentication keyring and release the host loop.
12375    pub fn activate_security(
12376        &mut self,
12377        encryption: crate::values::EncryptionConfig,
12378    ) -> Result<(), String> {
12379        if !self.security_preflight {
12380            return Err(
12381                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12382            );
12383        }
12384        self.enqueue_startup_sync_if_needed()?;
12385        self.encryption = encryption;
12386        self.security_preflight = false;
12387        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12388        Ok(())
12389    }
12390
12391    fn clock_now_ms(&self) -> i64 {
12392        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12393    }
12394
12395    fn create_bookkeeping_tables(&self) -> Result<(), String> {
12396        // Durable client bookkeeping (outbox + subscription + meta).
12397        self.conn
12398            .execute_batch(
12399                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12400                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12401                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12402                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12403                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12404                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12405                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12406                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12407                   PRIMARY KEY(commit_id, idx));
12408                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12409                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12410                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12411                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12412                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12413                   client_commit_id TEXT NOT NULL UNIQUE,
12414                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12415                   recorded_at_ms INTEGER NOT NULL,
12416                   results_json TEXT NOT NULL,
12417                   operations_json TEXT,
12418                   resolution TEXT NOT NULL DEFAULT 'active'
12419                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12420                   resolved_at_ms INTEGER,
12421                   replacement_client_commit_id TEXT);
12422                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12423                   ON _syncular_commit_outcomes(resolution, seq);
12424                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12425                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12426                 CREATE TABLE IF NOT EXISTS _syncular_meta (
12427                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
12428                 CREATE TABLE IF NOT EXISTS _syncular_windows (
12429                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12430                   PRIMARY KEY (base, unit));
12431                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12432                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12433                   effective_scopes TEXT NOT NULL);
12434                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12435                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12436                   PRIMARY KEY(commit_id, blob_id));
12437                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12438                   ON _syncular_blob_commit_refs(blob_id);",
12439            )
12440            .map_err(|e| e.to_string())?;
12441        // Migrate an outcome journal created before failed aggregate
12442        // envelopes were retained. Historical rows intentionally stay NULL.
12443        let _ = self
12444            .conn
12445            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12446        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12447            self.set_meta(LOCAL_REVISION_KEY, "0");
12448        }
12449        // §5.9.7 blob cache. Mutable upload state stays off the body row so
12450        // completing a large upload does not rewrite its BLOB record.
12451        if self.schema_has_blobs() {
12452            let exists = self
12453                .conn
12454                .query_row(
12455                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12456                    [],
12457                    |row| row.get::<_, bool>(0),
12458                )
12459                .map_err(|e| e.to_string())?;
12460            if exists {
12461                let mut statement = self
12462                    .conn
12463                    .prepare("PRAGMA table_info(_syncular_blobs)")
12464                    .map_err(|e| e.to_string())?;
12465                let mut columns = statement
12466                    .query_map([], |row| row.get::<_, String>(1))
12467                    .map_err(|e| e.to_string())?
12468                    .collect::<Result<Vec<_>, _>>()
12469                    .map_err(|e| e.to_string())?;
12470                columns.sort();
12471                let mut expected = vec![
12472                    "blob_id",
12473                    "byte_length",
12474                    "bytes",
12475                    "created_at_ms",
12476                    "media_type",
12477                ];
12478                expected.sort_unstable();
12479                if columns != expected {
12480                    return Err(
12481                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12482                    );
12483                }
12484            } else {
12485                self.conn
12486                    .execute_batch(
12487                        "CREATE TABLE _syncular_blobs(
12488                           blob_id TEXT PRIMARY KEY,
12489                           bytes BLOB NOT NULL,
12490                           byte_length INTEGER NOT NULL,
12491                           media_type TEXT,
12492                           created_at_ms INTEGER NOT NULL);",
12493                    )
12494                    .map_err(|e| e.to_string())?;
12495            }
12496            self.conn
12497                .execute_batch(
12498                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12499                       blob_id TEXT PRIMARY KEY,
12500                       media_type TEXT,
12501                       created_at_ms INTEGER NOT NULL);",
12502                )
12503                .map_err(|e| e.to_string())?;
12504        }
12505        Ok(())
12506    }
12507
12508    /// True iff any synced table declares a `blob_ref` column (§5.9).
12509    fn schema_has_blobs(&self) -> bool {
12510        self.schema
12511            .tables
12512            .iter()
12513            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12514    }
12515
12516    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
12517
12518    fn get_meta(&self, key: &str) -> Option<String> {
12519        meta_get(&self.conn, key)
12520    }
12521
12522    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12523        self.conn
12524            .query_row(
12525                "SELECT value FROM _syncular_meta WHERE key = ?1",
12526                rusqlite::params![key],
12527                |row| row.get::<_, String>(0),
12528            )
12529            .optional()
12530            .map_err(|_| {
12531                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12532            })
12533    }
12534
12535    // The caller holds the startup/reset transaction. Either both schema
12536    // records persist or the enclosing operation fails and rolls back.
12537    fn persist_schema_version(&self) -> Result<(), String> {
12538        self.conn
12539            .execute(
12540                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12541                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12542            )
12543            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12544        set_local_schema_descriptor(&self.conn, &self.schema)
12545            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12546    }
12547
12548    pub(crate) fn set_meta(&self, key: &str, value: &str) {
12549        meta_set(&self.conn, key, value);
12550    }
12551
12552    fn delete_meta(&self, key: &str) {
12553        meta_delete(&self.conn, key);
12554    }
12555
12556    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
12557    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12558        let mut stmt = self
12559            .conn
12560            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12561            .map_err(|error| error.to_string())?;
12562        let rows = stmt
12563            .query_map([], |row| {
12564                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12565            })
12566            .map_err(|error| error.to_string())?;
12567        let mut commits = Vec::new();
12568        for row in rows {
12569            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12570            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12571                format!("invalid persisted outbox {client_commit_id:?}: {error}")
12572            })?;
12573            let mut ops = Vec::with_capacity(entries.len());
12574            for entry in entries {
12575                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12576                ops.push(OutboxOp {
12577                    upsert: op == "upsert",
12578                    table: entry
12579                        .get("table")
12580                        .and_then(Value::as_str)
12581                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12582                        .to_owned(),
12583                    row_id: entry
12584                        .get("rowId")
12585                        .and_then(Value::as_str)
12586                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12587                        .to_owned(),
12588                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
12589                    // A legacy entry's `changedFields` key is ignored:
12590                    // the values map is the presence set (§6.1).
12591                    values: entry.get("values").and_then(Value::as_object).cloned(),
12592                    insert: entry.get("insert") == Some(&Value::Bool(true)),
12593                });
12594            }
12595            commits.push(OutboxCommit {
12596                client_commit_id,
12597                ops,
12598            });
12599        }
12600        Ok(commits)
12601    }
12602
12603    fn restore_persisted_state(&mut self) -> Result<(), String> {
12604        self.subs = {
12605            let mut stmt = self
12606                .conn
12607                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12608                .map_err(|error| error.to_string())?;
12609            let rows = stmt
12610                .query_map([], |row| {
12611                    Ok((
12612                        row.get::<_, String>(0)?,
12613                        row.get::<_, String>(1)?,
12614                        row.get::<_, String>(2)?,
12615                    ))
12616                })
12617                .map_err(|error| error.to_string())?;
12618            let mut subscriptions = Vec::new();
12619            for row in rows {
12620                let (id, table, raw) = row.map_err(|error| error.to_string())?;
12621                let state: Value = serde_json::from_str(&raw)
12622                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12623                let requested = json_to_scope_map(
12624                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
12625                )?;
12626                let effective = state
12627                    .get("effectiveScopes")
12628                    .filter(|value| !value.is_null())
12629                    .map(json_to_scope_map)
12630                    .transpose()?;
12631                subscriptions.push(Subscription {
12632                    id,
12633                    table,
12634                    requested,
12635                    params: state
12636                        .get("params")
12637                        .and_then(Value::as_str)
12638                        .map(str::to_owned),
12639                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12640                    bootstrap_state: state
12641                        .get("bootstrapState")
12642                        .and_then(Value::as_str)
12643                        .map(str::to_owned),
12644                    state: SubState::parse(
12645                        state
12646                            .get("status")
12647                            .and_then(Value::as_str)
12648                            .unwrap_or("active"),
12649                    ),
12650                    reason_code: state
12651                        .get("reasonCode")
12652                        .and_then(Value::as_str)
12653                        .map(str::to_owned),
12654                    effective,
12655                    synced_once: state
12656                        .get("syncedOnce")
12657                        .and_then(Value::as_bool)
12658                        .unwrap_or(false),
12659                });
12660            }
12661            subscriptions
12662        };
12663
12664        self.outbox = self.load_outbox()?;
12665
12666        self.prune_commit_outcomes()?;
12667        let active = self.commit_outcomes(CommitOutcomeQuery {
12668            active_only: true,
12669            ..CommitOutcomeQuery::default()
12670        })?;
12671        self.conflicts = active
12672            .iter()
12673            .flat_map(|outcome| outcome.results.iter())
12674            .filter_map(|result| match result {
12675                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12676                _ => None,
12677            })
12678            .collect();
12679        self.rejections = active
12680            .iter()
12681            .flat_map(|outcome| outcome.results.iter())
12682            .filter_map(|result| match result {
12683                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12684                _ => None,
12685            })
12686            .collect();
12687
12688        self.lease_state = self
12689            .get_meta(LEASE_STATE_KEY)
12690            .map(|raw| serde_json::from_str(&raw))
12691            .transpose()
12692            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12693        self.schema_floor = self
12694            .get_meta(SCHEMA_FLOOR_KEY)
12695            .map(|raw| serde_json::from_str(&raw))
12696            .transpose()
12697            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12698        self.stopped = self.schema_floor.is_some();
12699        // A replica left in unactivated preflight reopens gated: the quarantine
12700        // decision persists with the data across handle teardown and restart.
12701        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12702            self.security_preflight = true;
12703        }
12704        Ok(())
12705    }
12706
12707    fn save_subscription_scope_schema(&self) {
12708        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12709            .schema
12710            .tables
12711            .iter()
12712            .map(|table| {
12713                (
12714                    table.name.clone(),
12715                    table
12716                        .scope_variables
12717                        .iter()
12718                        .map(|scope| {
12719                            (
12720                                scope.variable.clone(),
12721                                [scope.prefix.clone(), scope.column.clone()],
12722                            )
12723                        })
12724                        .collect(),
12725                )
12726            })
12727            .collect();
12728        self.set_meta(
12729            "subscriptionScopeSchema",
12730            &serde_json::to_string(&declarations).expect("scope schema serialization"),
12731        );
12732    }
12733
12734    /// Remove registrations with incompatible table/key/prefix/column meaning.
12735    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12736        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12737            self.get_meta("subscriptionScopeSchema")
12738                .map(|raw| serde_json::from_str(&raw))
12739                .transpose()
12740                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12741        } else {
12742            None
12743        };
12744        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12745            let Some(table) = self.schema.table(table_name) else {
12746                return false;
12747            };
12748            if requested
12749                .iter()
12750                .any(|(variable, _)| table.scope_column(variable).is_none())
12751            {
12752                return false;
12753            }
12754            if !bump {
12755                return true;
12756            }
12757            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12758                return false;
12759            };
12760            let variables: BTreeSet<_> = if requested.is_empty() {
12761                old.keys()
12762                    .cloned()
12763                    .chain(
12764                        table
12765                            .scope_variables
12766                            .iter()
12767                            .map(|scope| scope.variable.clone()),
12768                    )
12769                    .collect()
12770            } else {
12771                requested
12772                    .iter()
12773                    .map(|(variable, _)| variable.clone())
12774                    .collect()
12775            };
12776            variables.iter().all(|variable| {
12777                let current = table
12778                    .scope_variables
12779                    .iter()
12780                    .find(|scope| &scope.variable == variable);
12781                match (old.get(variable), current) {
12782                    (Some(old), Some(current)) => {
12783                        old == &[current.prefix.clone(), current.column.clone()]
12784                    }
12785                    _ => false,
12786                }
12787            })
12788        };
12789        let mut stale_ids: BTreeSet<_> = self
12790            .subs
12791            .iter()
12792            .filter(|sub| !compatible(&sub.table, &sub.requested))
12793            .map(|sub| sub.id.clone())
12794            .collect();
12795        for (id, table, effective) in self.load_pending_evictions()? {
12796            if !compatible(&table, &effective) {
12797                stale_ids.insert(id);
12798            }
12799        }
12800        for id in &stale_ids {
12801            for sql in [
12802                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12803                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12804                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12805            ] {
12806                self.conn
12807                    .execute(sql, rusqlite::params![id])
12808                    .map_err(|error| error.to_string())?;
12809            }
12810        }
12811        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12812        Ok(())
12813    }
12814
12815    #[must_use]
12816    pub fn local_revision(&self) -> u64 {
12817        self.get_meta(LOCAL_REVISION_KEY)
12818            .and_then(|value| value.parse().ok())
12819            .unwrap_or(0)
12820    }
12821
12822    /// §7.5: resolve a snapshot read on the mutable owner connection through
12823    /// the same primitive the read-only sidecar uses, so the command surface
12824    /// and the file sidecar return one revision, rows, coverage, catch-up, and
12825    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
12826    /// `owner` records its failure (and its next success) in diagnostics.
12827    pub fn snapshot_read(
12828        &mut self,
12829        request: &SnapshotReadRequest<'_>,
12830        owner: Option<QueryOwner<'_>>,
12831    ) -> Result<SnapshotRead, QueryReadFailure> {
12832        let result = snapshot_read_connection(&self.conn, request);
12833        if let Some(owner) = owner {
12834            self.record_query_read(owner, result.as_ref().err());
12835        }
12836        result
12837    }
12838
12839    #[must_use]
12840    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12841        SyncStatusSnapshot {
12842            current_schema_version: self.schema.version,
12843            outbox: self.outbox.len(),
12844            upgrading: self.upgrading,
12845            lease_state: self.lease_state.clone(),
12846            schema_floor: self.schema_floor.clone(),
12847            sync_needed: self.sync_needed,
12848            previous_version_context: self.previous_version_status(),
12849        }
12850    }
12851
12852    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
12853    /// rollback precondition), read without creating the file.
12854    fn previous_version_status(&self) -> PreviousVersionStatus {
12855        let Some(replica_path) = self.previous_version_replica_path() else {
12856            return PreviousVersionStatus {
12857                present: false,
12858                created_at_ms: None,
12859            };
12860        };
12861        match crate::previous_version::read_previous_version_record(&replica_path) {
12862            Ok(Some(record)) => PreviousVersionStatus {
12863                present: true,
12864                created_at_ms: Some(record.created_at_ms),
12865            },
12866            _ => PreviousVersionStatus {
12867                present: std::path::Path::new(
12868                    &crate::previous_version::previous_version_container_path(&replica_path),
12869                )
12870                .exists(),
12871                created_at_ms: None,
12872            },
12873        }
12874    }
12875
12876    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
12877    /// is always `previousVersion`; the container never makes
12878    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
12879    /// revocation, coverage completion and the TTL DISCARD the container and
12880    /// close the read with their own reason.
12881    pub fn previous_version_snapshot(
12882        &self,
12883        spec: &PreviousVersionReadSpec,
12884    ) -> Result<PreviousVersionSnapshot, String> {
12885        let now_ms = self.clock_now_ms();
12886        crate::previous_version::previous_version_snapshot(
12887            &self.conn,
12888            self.previous_version_replica_path().as_deref(),
12889            self.previous_version.as_ref(),
12890            PreviousVersionLifecycle {
12891                lease_inactive: self.previous_version_lease_inactive(now_ms),
12892                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12893                coverage_complete: self.previous_version_coverage_complete(),
12894            },
12895            now_ms,
12896            self.schema.version,
12897            spec,
12898        )
12899    }
12900
12901    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
12902    #[must_use]
12903    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12904        crate::previous_version::stored_previous_version_audit(&self.conn)
12905    }
12906
12907    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
12908    /// and both metadata records, and report whether anything was present.
12909    /// Idempotent: absence is a successful no-op.
12910    pub fn previous_version_discard(
12911        &self,
12912    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12913        self.drop_previous_version()
12914    }
12915
12916    /// The module's discard has no failure path (it removes the file even when
12917    /// the container cannot be opened), so the only error a caller could ever
12918    /// see is a bug; report absence rather than claiming a discard happened.
12919    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12920        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12921            present: false,
12922            discarded: false,
12923        };
12924        let Some(replica_path) = self.previous_version_replica_path() else {
12925            return absent;
12926        };
12927        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12928            .unwrap_or(absent)
12929    }
12930
12931    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
12932    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12933        let Some(lease) = &self.lease_state else {
12934            return false;
12935        };
12936        if lease.error_code.is_some() {
12937            return true;
12938        }
12939        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12940    }
12941
12942    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
12943    /// has `cursor >= 0` and no resume token. An empty active set is not
12944    /// completion (mirrors the TS predicate exactly).
12945    fn previous_version_coverage_complete(&self) -> bool {
12946        let active = self
12947            .subs
12948            .iter()
12949            .filter(|sub| sub.state == SubState::Active)
12950            .collect::<Vec<_>>();
12951        if active.is_empty() {
12952            return false;
12953        }
12954        active
12955            .iter()
12956            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12957    }
12958
12959    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
12960    /// apply. A container that no longer has a reason to exist is discarded so
12961    /// the rows do not outlive the window they were kept for.
12962    fn previous_version_lifetime_check(&self) {
12963        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12964            return;
12965        };
12966        let now_ms = self.clock_now_ms();
12967        if self.previous_version_lease_inactive(now_ms) {
12968            let _ = self.drop_previous_version();
12969            return;
12970        }
12971        let Some(replica_path) = self.previous_version_replica_path() else {
12972            return;
12973        };
12974        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12975        else {
12976            return;
12977        };
12978        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12979            let _ = self.drop_previous_version();
12980            return;
12981        }
12982        if now_ms - record.created_at_ms > config.max_age_ms {
12983            let _ = self.drop_previous_version();
12984            return;
12985        }
12986        if self.previous_version_coverage_complete() {
12987            let _ = self.drop_previous_version();
12988        }
12989    }
12990
12991    pub fn diagnostics_snapshot(
12992        &self,
12993        request: &ClientDiagnosticsRequest,
12994    ) -> Result<ClientDiagnosticsSnapshot, String> {
12995        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12996            return Err(format!(
12997                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12998            ));
12999        }
13000        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
13001        for sub in &self.subs {
13002            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
13003            let complete =
13004                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
13005            let state = match sub.state {
13006                SubState::Revoked => "revoked",
13007                SubState::Failed => "failed",
13008                SubState::Active if reset => "reset",
13009                SubState::Active if complete => "complete",
13010                SubState::Active => "bootstrapping",
13011            };
13012            subscriptions.insert(
13013                sub.id.clone(),
13014                DiagnosticSubscription {
13015                    id: sub.id.clone(),
13016                    table: sub.table.clone(),
13017                    state: state.to_owned(),
13018                    complete,
13019                    cursor: Some(sub.cursor),
13020                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
13021                },
13022            );
13023        }
13024        for expected in &request.expected_subscriptions {
13025            if expected.id.is_empty() || expected.table.is_empty() {
13026                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
13027            }
13028            if subscriptions
13029                .get(&expected.id)
13030                .is_some_and(|registered| registered.table != expected.table)
13031            {
13032                subscriptions.insert(
13033                    expected.id.clone(),
13034                    DiagnosticSubscription {
13035                        id: expected.id.clone(),
13036                        table: expected.table.clone(),
13037                        state: "failed".to_owned(),
13038                        complete: false,
13039                        cursor: None,
13040                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
13041                    },
13042                );
13043            } else {
13044                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
13045                    DiagnosticSubscription {
13046                        id: expected.id.clone(),
13047                        table: expected.table.clone(),
13048                        state: "unregistered".to_owned(),
13049                        complete: false,
13050                        cursor: None,
13051                        reason_code: None,
13052                    }
13053                });
13054            }
13055        }
13056        let mut ordered_subscriptions = Vec::new();
13057        let mut included = BTreeSet::new();
13058        for expected in &request.expected_subscriptions {
13059            if included.insert(expected.id.clone()) {
13060                if let Some(subscription) = subscriptions.get(&expected.id) {
13061                    ordered_subscriptions.push(subscription.clone());
13062                }
13063            }
13064        }
13065        for (id, subscription) in &subscriptions {
13066            if included.insert(id.clone()) {
13067                ordered_subscriptions.push(subscription.clone());
13068            }
13069        }
13070        let subscriptions_truncated =
13071            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
13072        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
13073        let captured_at_ms = self.clock_now_ms();
13074        let lease = if let Some(error_code) = self
13075            .lease_state
13076            .as_ref()
13077            .and_then(|state| state.error_code.clone())
13078        {
13079            ClientDiagnosticsLease {
13080                state: "stopped".to_owned(),
13081                expires_at_ms: self
13082                    .lease_state
13083                    .as_ref()
13084                    .and_then(|state| state.expires_at_ms),
13085                error_code: Some(Self::diagnostic_code(&error_code)),
13086            }
13087        } else if let Some(expires_at_ms) = self
13088            .lease_state
13089            .as_ref()
13090            .and_then(|state| state.expires_at_ms)
13091        {
13092            ClientDiagnosticsLease {
13093                state: if expires_at_ms <= captured_at_ms {
13094                    "expired".to_owned()
13095                } else {
13096                    "active".to_owned()
13097                },
13098                expires_at_ms: Some(expires_at_ms),
13099                error_code: None,
13100            }
13101        } else {
13102            ClientDiagnosticsLease {
13103                state: "none".to_owned(),
13104                expires_at_ms: None,
13105                error_code: None,
13106            }
13107        };
13108        let connectivity = match self.last_round.as_ref() {
13109            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
13110            Some(round) if round.status == "succeeded" => "online",
13111            Some(round)
13112                if round.status == "failed"
13113                    && round
13114                        .error_code
13115                        .as_deref()
13116                        .is_some_and(Self::retryable_transport_code) =>
13117            {
13118                "offline"
13119            }
13120            _ => "unknown",
13121        };
13122        Ok(ClientDiagnosticsSnapshot {
13123            version: CLIENT_DIAGNOSTICS_VERSION,
13124            captured_at_ms,
13125            host: ClientDiagnosticsHost {
13126                kind: "direct".to_owned(),
13127                role: "single".to_owned(),
13128                connectivity: connectivity.to_owned(),
13129                realtime: self.realtime_state,
13130                realtime_policy: self.realtime_policy,
13131                realtime_reason_code: self.realtime_reason_code.clone(),
13132                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
13133            },
13134            security_lifecycle: self.security_lifecycle().to_owned(),
13135            schema: ClientDiagnosticsSchema {
13136                current_version: self.schema.version,
13137                upgrading: self.upgrading,
13138                required_version: self
13139                    .schema_floor
13140                    .as_ref()
13141                    .and_then(|floor| floor.required_schema_version),
13142                latest_version: self
13143                    .schema_floor
13144                    .as_ref()
13145                    .and_then(|floor| floor.latest_schema_version),
13146            },
13147            replica: ClientDiagnosticsReplica {
13148                local_revision: self.local_revision().to_string(),
13149                sync_needed: self.sync_needed,
13150                pending_outbox: self.outbox.len(),
13151            },
13152            lease,
13153            subscriptions: ordered_subscriptions,
13154            subscriptions_truncated,
13155            last_round: self.last_round.clone(),
13156            last_change: self.last_change.clone(),
13157            storage: self.diagnostics_storage(),
13158            query_failures: self.query_failures.clone(),
13159        })
13160    }
13161
13162    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13163        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13164            let page_count: i64 = self
13165                .conn
13166                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13167            let page_size: i64 = self
13168                .conn
13169                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13170            // Reuse compiled aggregates; every invocation still reads current storage.
13171            let outbox_bytes: i64 = self
13172                .conn
13173                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13174                .query_row([], |row| row.get(0))?;
13175            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13176                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13177            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13178            let blob_bytes = if self.schema_has_blobs() {
13179                self.conn
13180                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13181                    .query_row([], |row| row.get(0))?
13182            } else {
13183                0
13184            };
13185            let pressure = self
13186                .limits
13187                .blob_cache_max_bytes
13188                .is_some_and(|limit| blob_bytes > limit);
13189            Ok(ClientDiagnosticsStorage {
13190                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13191                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13192                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13193                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13194                retained_outcome_entries: Some(outcome_entries.max(0)),
13195                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13196                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13197            })
13198        };
13199        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13200            status: "unreadable".to_owned(),
13201            database_bytes_approx: None,
13202            pending_outbox_bytes_approx: None,
13203            retained_outcome_bytes_approx: None,
13204            retained_outcome_entries: None,
13205            blob_cache_bytes_approx: None,
13206            pressure_reason_code: None,
13207        })
13208    }
13209
13210    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13211        self.change_queue.drain(..).collect()
13212    }
13213
13214    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13215        let intents = self.sync_intent_queue.drain(..).collect();
13216        if self.transport_enabled {
13217            intents
13218        } else {
13219            vec![SyncIntent::None]
13220        }
13221    }
13222
13223    fn schedule_background_retry(&mut self) -> u64 {
13224        let delay_ms = self.retry_delay_ms;
13225        self.sync_intent_queue
13226            .push_back(SyncIntent::Background { delay_ms });
13227        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13228        self.round_retry_delay_ms = Some(delay_ms);
13229        delay_ms
13230    }
13231
13232    fn reset_background_retry(&mut self) {
13233        self.retry_delay_ms = 250;
13234    }
13235
13236    fn retryable_transport_code(code: &str) -> bool {
13237        code == "transport.failed"
13238            || code == "transport.timeout"
13239            || code == "transport.unavailable"
13240            || code == "sync.transport_failed"
13241    }
13242
13243    /// §1.8: the transport reported a refused or unreachable endpoint. The
13244    /// native transport says `connect` (DNS failure, connection refused,
13245    /// reset, or aborted); a browser fetch transport says `network`. Repeating
13246    /// the request on a backoff repairs neither, so no retry is scheduled.
13247    fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
13248        matches!(
13249            details
13250                .as_ref()
13251                .and_then(|details| details.get("causeKind"))
13252                .and_then(serde_json::Value::as_str),
13253            Some("connect" | "network")
13254        )
13255    }
13256
13257    /// §1.5: a segment fetch that fails inside an otherwise complete round is
13258    /// invisible to the server (the round response arrived whole), so its code
13259    /// rides the following requests like a socket-round failure. A code the
13260    /// frame grammar rejects is not reportable and the previous code stays.
13261    fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13262        if ssp2::model::valid_round_failure_code(&error.code) {
13263            self.round_failure = Some(error.code.clone());
13264        }
13265        SectionError::Abort(error.code, error.message)
13266    }
13267
13268    /// A failed round worth a background retry: a transport failure, or a
13269    /// §10.2 code the catalog marks retryable (clients may hardcode the
13270    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
13271    /// on the server's `retryable` flag.
13272    fn retryable_failure_code(code: &str) -> bool {
13273        Self::retryable_transport_code(code)
13274            || matches!(
13275                code,
13276                "sync.auth_required"
13277                    | "sync.auth_lease_required"
13278                    | "sync.auth_lease_revoked"
13279                    | "sync.internal_error"
13280                    | "sync.idempotency_cache_miss"
13281                    | "sync.schema_not_ready"
13282                    | "sync.segment_expired"
13283                    | "sync.rate_limited"
13284                    | "sync.websocket_connection_limit"
13285            )
13286    }
13287
13288    fn diagnostic_code(code: &str) -> String {
13289        let valid = !code.is_empty()
13290            && code.len() <= 96
13291            && code.contains('.')
13292            && code
13293                .bytes()
13294                .next()
13295                .is_some_and(|byte| byte.is_ascii_lowercase())
13296            && code.bytes().all(|byte| {
13297                byte.is_ascii_lowercase()
13298                    || byte.is_ascii_digit()
13299                    || matches!(byte, b'.' | b'_' | b'-')
13300            });
13301        if valid {
13302            code.to_owned()
13303        } else {
13304            "client.unknown_failure".to_owned()
13305        }
13306    }
13307
13308    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13309        if self.sync_needed != value {
13310            if self.begin_observation("syncular_status").is_ok() {
13311                self.sync_needed = value;
13312                let batch = ChangeAccumulator {
13313                    status: true,
13314                    ..ChangeAccumulator::default()
13315                };
13316                if self.finish_observation("syncular_status", batch).is_err() {
13317                    self.rollback_observation("syncular_status");
13318                }
13319            } else {
13320                self.sync_needed = value;
13321            }
13322        }
13323        if value && interactive {
13324            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13325        }
13326    }
13327
13328    fn sqlite_failure(
13329        storage_failure: &RefCell<Option<QueryReadFailure>>,
13330        error: rusqlite::Error,
13331    ) -> String {
13332        let message = error.to_string();
13333        let failure = QueryReadFailure::from(error);
13334        let code = failure.code;
13335        if code.is_some() && storage_failure.borrow().is_none() {
13336            *storage_failure.borrow_mut() = Some(failure);
13337        }
13338        match code {
13339            Some(code) => format!("{code}: {message}"),
13340            None => message,
13341        }
13342    }
13343
13344    fn begin_observation(&self, name: &str) -> Result<(), String> {
13345        if let Some(failure) = &self.coalesced_failure {
13346            return Err(failure.clone());
13347        }
13348        // SQLite rolls a whole transaction back on some I/O and capacity
13349        // errors; later frames must not commit on their own after that.
13350        if self.coalesced.is_some() && self.conn.is_autocommit() {
13351            return Err("the coalesced apply transaction was rolled back".to_owned());
13352        }
13353        self.conn
13354            .execute_batch(&format!("SAVEPOINT {name}"))
13355            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13356    }
13357
13358    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
13359    /// already owns one.
13360    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13361        if self.coalesced.is_some() || !self.conn.is_autocommit() {
13362            return Ok(());
13363        }
13364        self.conn
13365            .execute_batch("BEGIN")
13366            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13367        self.coalesced = Some(CoalescedApply {
13368            rows: 0,
13369            subs: self.subs.clone(),
13370            outbox: self.outbox.clone(),
13371            failed_commits: self.failed_commits.clone(),
13372            conflicts: self.conflicts.clone(),
13373            rejections: self.rejections.clone(),
13374            schema_floor: self.schema_floor.clone(),
13375            lease_state: self.lease_state.clone(),
13376            stopped: self.stopped,
13377            upgrading: self.upgrading,
13378            sync_needed: self.sync_needed,
13379            overlay_dirty: self.overlay_dirty.snapshot(),
13380            change_queue: self.change_queue.len(),
13381            sync_intent_queue: self.sync_intent_queue.len(),
13382            last_change: self.last_change.clone(),
13383        });
13384        Ok(())
13385    }
13386
13387    /// Account the rows of the next frame or block. A unit that would take the
13388    /// coalesced transaction past the bound commits the earlier units first.
13389    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13390        let Some(open) = &self.coalesced else {
13391            return Ok(());
13392        };
13393        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13394            self.commit_coalesced_apply()?;
13395            self.begin_coalesced_apply()?;
13396        }
13397        if let Some(open) = &mut self.coalesced {
13398            open.rows += rows;
13399        }
13400        Ok(())
13401    }
13402
13403    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
13404    /// frame back, restores the in-memory state of its `BEGIN`, drops the
13405    /// change batches and sync intents queued since, and fails the rest of the
13406    /// response.
13407    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13408        let Some(open) = self.coalesced.take() else {
13409            return Ok(());
13410        };
13411        let committed = if self.conn.is_autocommit() {
13412            Err("the coalesced apply transaction was rolled back".to_owned())
13413        } else {
13414            self.conn
13415                .execute_batch("COMMIT")
13416                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13417        };
13418        let Err(error) = committed else {
13419            return Ok(());
13420        };
13421        if !self.conn.is_autocommit() {
13422            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13423                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13424            }
13425        }
13426        self.subs = open.subs;
13427        self.outbox = open.outbox;
13428        self.failed_commits = open.failed_commits;
13429        self.conflicts = open.conflicts;
13430        self.rejections = open.rejections;
13431        self.schema_floor = open.schema_floor;
13432        self.lease_state = open.lease_state;
13433        self.stopped = open.stopped;
13434        self.upgrading = open.upgrading;
13435        self.sync_needed = open.sync_needed;
13436        self.overlay_dirty.restore(open.overlay_dirty);
13437        self.change_queue.truncate(open.change_queue);
13438        self.sync_intent_queue.truncate(open.sync_intent_queue);
13439        self.last_change = open.last_change;
13440        self.coalesced_failure = Some(error.clone());
13441        Err(error)
13442    }
13443
13444    /// End the response's coalescing: COMMIT what is open and clear a failure.
13445    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13446        let committed = self.commit_coalesced_apply();
13447        match self.coalesced_failure.take() {
13448            Some(failure) => Err(failure),
13449            None => committed,
13450        }
13451    }
13452
13453    fn rollback_observation(&self, name: &str) {
13454        if let Err(error) = self
13455            .conn
13456            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13457        {
13458            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13459                if failure.rollback_failure.is_none() {
13460                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13461                }
13462            }
13463        }
13464    }
13465
13466    fn finish_observation(
13467        &mut self,
13468        name: &str,
13469        mut batch: ChangeAccumulator,
13470    ) -> Result<(), String> {
13471        #[cfg(feature = "bench-internals")]
13472        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13473        if self.failed_commits.iter().any(|(commit, _)| {
13474            commit
13475                .ops
13476                .iter()
13477                .any(|operation| batch.tables.contains_key(&operation.table))
13478        }) {
13479            batch.outcomes = true;
13480        }
13481        if !batch.touched() {
13482            self.conn
13483                .execute_batch(&format!("RELEASE {name}"))
13484                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13485            return Ok(());
13486        }
13487        let revision = self
13488            .local_revision()
13489            .checked_add(1)
13490            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13491        self.conn
13492            .execute(
13493                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13494                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13495            )
13496            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13497        let status = batch.status.then(|| self.status_snapshot());
13498        let event = ClientChangeBatch {
13499            revision: revision.to_string(),
13500            tables: batch
13501                .tables
13502                .into_iter()
13503                .map(|(table, scope_keys)| TableChange {
13504                    table,
13505                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13506                })
13507                .collect(),
13508            windows: batch
13509                .windows
13510                .into_iter()
13511                .map(|((base_key, table), units)| WindowChange {
13512                    base_key,
13513                    table,
13514                    units: units.into_iter().collect(),
13515                })
13516                .collect(),
13517            status,
13518            conflicts_changed: batch.conflicts,
13519            rejections_changed: batch.rejections,
13520            outcomes_changed: batch.outcomes,
13521        };
13522        self.conn
13523            .execute_batch(&format!("RELEASE {name}"))
13524            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13525        let mut diagnostic_tables = event
13526            .tables
13527            .iter()
13528            .map(|entry| entry.table.clone())
13529            .collect::<BTreeSet<_>>()
13530            .into_iter()
13531            .collect::<Vec<_>>();
13532        let mut diagnostic_windows = event
13533            .windows
13534            .iter()
13535            .map(|entry| entry.table.clone())
13536            .collect::<BTreeSet<_>>()
13537            .into_iter()
13538            .collect::<Vec<_>>();
13539        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13540            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13541        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13542        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13543        self.last_change = Some(DiagnosticLastChange {
13544            revision: event.revision.clone(),
13545            recorded_at_ms: self.clock_now_ms(),
13546            tables: diagnostic_tables,
13547            windows: diagnostic_windows,
13548            domains_truncated,
13549            status_changed: event.status.is_some(),
13550            conflicts_changed: event.conflicts_changed,
13551            rejections_changed: event.rejections_changed,
13552            outcomes_changed: event.outcomes_changed,
13553        });
13554        self.change_queue.push_back(event);
13555        Ok(())
13556    }
13557
13558    fn record_scope_map(
13559        &self,
13560        batch: &mut ChangeAccumulator,
13561        table_name: &str,
13562        scopes: &[(String, Vec<String>)],
13563    ) {
13564        let Some(table) = self.schema.table(table_name) else {
13565            return;
13566        };
13567        for (variable, values) in scopes {
13568            let Some(scope) = table
13569                .scope_variables
13570                .iter()
13571                .find(|scope| &scope.variable == variable)
13572            else {
13573                continue;
13574            };
13575            for value in values {
13576                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13577            }
13578        }
13579    }
13580
13581    /// Record a row's current scope keys from the base or visible table.
13582    fn record_row_scopes(
13583        &self,
13584        batch: &mut ChangeAccumulator,
13585        table_name: &str,
13586        row_id: &str,
13587        base: bool,
13588    ) -> bool {
13589        let Some(table) = self.schema.table(table_name) else {
13590            return false;
13591        };
13592        if table.scope_variables.is_empty() {
13593            return false;
13594        }
13595        let columns = table
13596            .scope_variables
13597            .iter()
13598            .map(|scope| quote_ident(&scope.column))
13599            .collect::<Vec<_>>()
13600            .join(", ");
13601        let full_table = if base {
13602            base_table(table_name)
13603        } else {
13604            visible_table(table_name)
13605        };
13606        let sql = format!(
13607            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13608            row_id_predicate(table)
13609        );
13610        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13611            return false;
13612        };
13613        let values = stmt.query_row(rusqlite::params![row_id], |row| {
13614            let mut values = Vec::with_capacity(table.scope_variables.len());
13615            for index in 0..table.scope_variables.len() {
13616                values.push(row.get::<_, Option<String>>(index)?);
13617            }
13618            Ok(values)
13619        });
13620        let Ok(values) = values else {
13621            return false;
13622        };
13623        let mut recorded = false;
13624        for (scope, value) in table.scope_variables.iter().zip(values) {
13625            if let Some(value) = value {
13626                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13627                recorded = true;
13628            }
13629        }
13630        recorded
13631    }
13632
13633    fn record_commit_changes(
13634        &self,
13635        batch: &mut ChangeAccumulator,
13636        tables: &[String],
13637        changes: &[ssp2::model::Change],
13638    ) {
13639        #[cfg(feature = "bench-internals")]
13640        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13641        for change in changes {
13642            let Some(table_name) = tables.get(change.table_index as usize) else {
13643                continue;
13644            };
13645            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13646            if let Some(table) = self.schema.table(table_name) {
13647                for (variable, value) in &change.scopes {
13648                    if let Some(scope) = table
13649                        .scope_variables
13650                        .iter()
13651                        .find(|scope| &scope.variable == variable)
13652                    {
13653                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
13654                        precise = true;
13655                    }
13656                }
13657            }
13658            if !precise {
13659                batch.table(table_name);
13660            }
13661        }
13662    }
13663
13664    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13665        if effective.is_empty() {
13666            return false;
13667        }
13668        let Some(table) = self.schema.table(table_name) else {
13669            return false;
13670        };
13671        let mut clauses = Vec::new();
13672        let mut params = Vec::new();
13673        for (variable, values) in effective {
13674            let Some(column) = table.scope_column(variable) else {
13675                return false;
13676            };
13677            if values.is_empty() {
13678                return false;
13679            }
13680            let placeholders = values
13681                .iter()
13682                .map(|value| {
13683                    params.push(SqlValue::Text(value.clone()));
13684                    "?"
13685                })
13686                .collect::<Vec<_>>()
13687                .join(", ");
13688            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13689        }
13690        let sql = format!(
13691            "SELECT 1 FROM {} WHERE {} LIMIT 1",
13692            base_table(table_name),
13693            clauses.join(" AND ")
13694        );
13695        self.conn
13696            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13697            .is_ok()
13698    }
13699
13700    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
13701    pub fn upgrading(&self) -> bool {
13702        self.upgrading
13703    }
13704
13705    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
13706    /// keeping this client's local database (identity, outbox, tables). The
13707    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
13708    /// version increases. Recreation runs the same marker guard as opening
13709    /// a durable replica, holds the guard and every write it authorizes under
13710    /// one write transaction, and preserves the current client (compiled
13711    /// schema, outbox, tables) on refusal.
13712    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13713        let new_schema = parse_schema_json(schema_json)?;
13714        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13715        self.conn
13716            .execute_batch("BEGIN IMMEDIATE")
13717            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13718        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13719        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
13720        // state (dropped outbox commits, rejections, upgrade and floor flags,
13721        // overlay dirtiness) and appends change/intent batches. A COMMIT that
13722        // fails after the reset succeeded rolls the database back, so every
13723        // one of those fields is captured here and restored with it.
13724        let prior_subs = self.subs.clone();
13725        let prior_outbox = self.outbox.clone();
13726        let prior_rejections = self.rejections.clone();
13727        let prior_upgrading = self.upgrading;
13728        let prior_stopped = self.stopped;
13729        let prior_schema_floor = self.schema_floor.clone();
13730        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13731        let prior_sync_needed = self.sync_needed;
13732        let prior_active_round = self.active_round;
13733        let prior_changes = self.change_queue.len();
13734        let prior_intents = self.sync_intent_queue.len();
13735        let prior_last_change = self.last_change.clone();
13736        let guarded = (|| -> Result<(), String> {
13737            // §7.4.2: the marker is re-read under the write lock the caller
13738            // holds, so a replica another process upgraded meanwhile refuses
13739            // this recreation instead of having its marker and tables
13740            // downgraded.
13741            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13742            self.create_bookkeeping_tables()?;
13743            match marker {
13744                Some(version) if version < self.schema.version => self.run_schema_reset()?,
13745                None => {
13746                    self.create_synced_tables()?;
13747                    self.persist_schema_version()?;
13748                }
13749                _ => {}
13750            }
13751            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
13752            // durable outbox the same-version path is about to replay. Only the
13753            // outbox is reloaded: the recovery below reads it, and the caller's
13754            // rollback snapshot already covers this field.
13755            self.outbox = self.load_outbox()?;
13756            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
13757            // this guarded boundary once the schema and marker agree. A bump went
13758            // through `run_schema_reset`, which performs the same recovery after
13759            // it creates the new physical tables and before its final replay.
13760            self.drop_unencodable_outbox(false, None)?;
13761            if marker == Some(self.schema.version)
13762                && (!self.outbox.is_empty()
13763                    || !self.failed_commits.is_empty()
13764                    || self.has_acknowledged_rows())
13765            {
13766                self.overlay_dirty.set(true);
13767                self.rebuild_overlay()?;
13768            }
13769            self.prune_unknown_subscriptions(false)?;
13770            self.save_subscription_scope_schema();
13771            // The conformance recreate is the in-memory equivalent of reopening
13772            // a durable client. Apply the same startup catch-up contract even
13773            // when the schema itself did not change.
13774            self.enqueue_startup_sync_if_needed()?;
13775            Ok(())
13776        })();
13777        let outcome = match guarded {
13778            Ok(()) => self
13779                .conn
13780                .execute_batch("COMMIT")
13781                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13782            Err(error) => Err(error),
13783        };
13784        if let Err(error) = outcome {
13785            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13786                // Keep a failed rollback's typed storage classification.
13787                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13788            }
13789            self.schema = prior_schema;
13790            self.subs = prior_subs;
13791            self.outbox = prior_outbox;
13792            self.rejections = prior_rejections;
13793            self.upgrading = prior_upgrading;
13794            self.stopped = prior_stopped;
13795            self.schema_floor = prior_schema_floor;
13796            self.overlay_dirty.restore(prior_overlay_dirty);
13797            self.sync_needed = prior_sync_needed;
13798            self.active_round = prior_active_round;
13799            self.change_queue.truncate(prior_changes);
13800            self.sync_intent_queue.truncate(prior_intents);
13801            self.last_change = prior_last_change;
13802            // Prepared insert statements were derived from the reverted schema.
13803            self.insert_sql.borrow_mut().clear();
13804            return Err(error);
13805        }
13806        Ok(())
13807    }
13808
13809    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13810        let startup_work = !self.stopped
13811            && (!self.outbox.is_empty()
13812                || self.has_acknowledged_rows()
13813                || !self.load_pending_evictions()?.is_empty()
13814                || self.subs.iter().any(|sub| sub.state == SubState::Active));
13815        if startup_work {
13816            self.sync_needed = true;
13817            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13818        }
13819        Ok(())
13820    }
13821
13822    /// A native client persists schema-floor stops across process restarts.
13823    /// Once the running app already satisfies that floor, the persisted stop
13824    /// is only stale evidence from an older server round (for example, the
13825    /// server was restarted after catching up to an app that was ahead).
13826    /// Clear it and let the normal startup pull re-negotiate. If the server is
13827    /// still incompatible it will return the floor again in that first round.
13828    fn clear_satisfied_persisted_schema_floor(&mut self) {
13829        let satisfied = self
13830            .schema_floor
13831            .as_ref()
13832            .and_then(|floor| floor.required_schema_version)
13833            .is_some_and(|required| self.schema.version >= required);
13834        if !satisfied {
13835            return;
13836        }
13837        self.schema_floor = None;
13838        self.stopped = false;
13839        self.delete_meta(SCHEMA_FLOOR_KEY);
13840    }
13841
13842    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
13843    /// path already swept it; this covers a same-version or fresh-install open,
13844    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
13845    /// an aware binary can apply.
13846    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13847        let Some(replica_path) = self.previous_version_replica_path() else {
13848            return Ok(());
13849        };
13850        let max_age_ms = self
13851            .previous_version
13852            .filter(|config| config.enabled)
13853            .map(|config| config.max_age_ms);
13854        reconcile_previous_version_at_boot(
13855            &self.conn,
13856            &replica_path,
13857            self.schema.version,
13858            max_age_ms,
13859            self.clock_now_ms(),
13860        )
13861    }
13862
13863    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
13864    /// no file — an in-memory replica returns an empty path, and a `file:` URI
13865    /// has no plain sibling path — so the feature resolves as off there.
13866    fn previous_version_replica_path(&self) -> Option<String> {
13867        let path = self.conn.path()?;
13868        if path.is_empty() || path.starts_with("file:") {
13869            return None;
13870        }
13871        Some(path.to_owned())
13872    }
13873
13874    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
13875    /// and leaseState. Drops/recreates every synced table from the new
13876    /// schema, resets subscription sync-state (keeping registrations), clears
13877    /// the schema-floor stop state, rewrites the marker, drops outbox commits
13878    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
13879    fn run_schema_reset(&mut self) -> Result<(), String> {
13880        self.begin_observation("syncular_schema_reset")?;
13881        let prior_subs = self.subs.clone();
13882        let prior_outbox = self.outbox.clone();
13883        let prior_rejections = self.rejections.clone();
13884        let prior_upgrading = self.upgrading;
13885        let prior_stopped = self.stopped;
13886        let prior_schema_floor = self.schema_floor.clone();
13887        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13888        let prior_sync_needed = self.sync_needed;
13889        let prior_active_round = self.active_round;
13890        let mut batch = ChangeAccumulator::default();
13891        let result = self
13892            .run_schema_reset_observed(&mut batch, true)
13893            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13894        if let Err(error) = result {
13895            self.rollback_observation("syncular_schema_reset");
13896            self.subs = prior_subs;
13897            self.outbox = prior_outbox;
13898            self.rejections = prior_rejections;
13899            self.upgrading = prior_upgrading;
13900            self.stopped = prior_stopped;
13901            self.schema_floor = prior_schema_floor;
13902            self.overlay_dirty.restore(prior_overlay_dirty);
13903            self.sync_needed = prior_sync_needed;
13904            self.active_round = prior_active_round;
13905            return Err(error);
13906        }
13907        Ok(())
13908    }
13909
13910    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13911        if self.get_meta(LOG_EPOCH_KEY).is_none() {
13912            self.begin_observation("syncular_epoch_acquisition")?;
13913            let previous_sync_needed = self.sync_needed;
13914            if let Err(error) = self.conn.execute(
13915                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13916                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13917            ) {
13918                self.rollback_observation("syncular_epoch_acquisition");
13919                return Err(Self::sqlite_failure(&self.storage_failure, error));
13920            }
13921            self.sync_needed = true;
13922            let batch = ChangeAccumulator {
13923                status: true,
13924                ..ChangeAccumulator::default()
13925            };
13926            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13927                self.rollback_observation("syncular_epoch_acquisition");
13928                self.sync_needed = previous_sync_needed;
13929                return Err(error);
13930            }
13931            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13932            return Ok(Vec::new());
13933        }
13934        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13935        self.begin_observation("syncular_log_epoch_reset")?;
13936        let prior_subs = self.subs.clone();
13937        let prior_outbox = self.outbox.clone();
13938        let prior_rejections = self.rejections.clone();
13939        let prior_upgrading = self.upgrading;
13940        let prior_stopped = self.stopped;
13941        let prior_schema_floor = self.schema_floor.clone();
13942        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13943        let prior_sync_needed = self.sync_needed;
13944        let prior_active_round = self.active_round;
13945        let mut batch = ChangeAccumulator::default();
13946        let result = self
13947            .run_schema_reset_observed(&mut batch, false)
13948            .and_then(|()| {
13949                self.conn
13950                    .execute(
13951                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13952                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13953                    )
13954                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13955                self.sync_needed = true;
13956                batch.status = true;
13957                self.finish_observation("syncular_log_epoch_reset", batch)
13958            });
13959        if let Err(error) = result {
13960            self.rollback_observation("syncular_log_epoch_reset");
13961            self.subs = prior_subs;
13962            self.outbox = prior_outbox;
13963            self.rejections = prior_rejections;
13964            self.upgrading = prior_upgrading;
13965            self.stopped = prior_stopped;
13966            self.schema_floor = prior_schema_floor;
13967            self.overlay_dirty.restore(prior_overlay_dirty);
13968            self.sync_needed = prior_sync_needed;
13969            self.active_round = prior_active_round;
13970            return Err(error);
13971        }
13972        self.sync_intent_queue.push_back(SyncIntent::Interactive);
13973        Ok(resets)
13974    }
13975
13976    fn run_schema_reset_observed(
13977        &mut self,
13978        batch: &mut ChangeAccumulator,
13979        capture: bool,
13980    ) -> Result<(), String> {
13981        // §7.4.2: the reset is the only path that wipes local tables for a
13982        // version bump, and it rewrites the marker last. Re-read the marker
13983        // under this reset's transaction: a replica another process upgraded
13984        // meanwhile refuses here instead of being reset to this client's
13985        // older schema.
13986        read_local_schema_version(&self.conn, self.schema.version)?;
13987        self.conn
13988            .execute_batch(
13989                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13990            )
13991            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13992        self.cancel_sync_round();
13993        self.upgrading = true;
13994        batch.status = true;
13995        // RFC 0005 D6: classify the pending outbox against the NEW compiled
13996        // schema BEFORE the wipe, and record the advisory audit. This pass
13997        // drops nothing, so the audit sees the original pending intent; the
13998        // §7.4.4 send-time drop and the intrinsic value recovery later in this
13999        // reset are the paths that remove a commit. The classification is
14000        // shared with the send-time drop so the two cannot disagree about what
14001        // is incompatible.
14002        if self.previous_version.is_some_and(|config| config.enabled) {
14003            let previous_version = self
14004                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
14005                .and_then(|value| value.parse::<i32>().ok())
14006                .unwrap_or(self.schema.version);
14007            let pending = self
14008                .outbox
14009                .iter()
14010                .map(|commit| PendingCommitAudit {
14011                    commit_id: &commit.client_commit_id,
14012                    operations: commit_audit_operations(commit),
14013                })
14014                .collect::<Vec<_>>();
14015            crate::previous_version::write_previous_version_audit(
14016                &self.conn,
14017                &crate::previous_version::build_previous_version_audit(
14018                    &self.schema,
14019                    &pending,
14020                    previous_version,
14021                    self.schema.version,
14022                    self.clock_now_ms(),
14023                ),
14024            );
14025        }
14026        // RFC 0005 D5: the container lives in its own FILE, so the reset can
14027        // never reach it. Sweep first (unconditional — also on the log-epoch
14028        // reset, which captures nothing), then capture BEFORE the wipe drops
14029        // any row. A refused capture records its reason and leaves no file.
14030        if let Some(replica_path) = self.previous_version_replica_path() {
14031            sweep_previous_version_container(&self.conn, &replica_path)?;
14032            if capture {
14033                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
14034                    let previous_version = self
14035                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
14036                        .and_then(|value| value.parse::<i32>().ok())
14037                        .unwrap_or(self.schema.version);
14038                    capture_previous_version_from_replica(
14039                        &self.conn,
14040                        &replica_path,
14041                        previous_version,
14042                        self.schema.version,
14043                        &config,
14044                        self.clock_now_ms(),
14045                    )?;
14046                }
14047            }
14048        }
14049        for table in &self.schema.tables {
14050            batch.table(&table.name);
14051        }
14052        for (base_key, unit, table) in self.load_registered_window_units() {
14053            batch.window(&base_key, &table, &unit);
14054        }
14055        if capture {
14056            self.prune_unknown_subscriptions(true)?;
14057        }
14058        // The per-table insert SQL is derived from the OLD column lists.
14059        self.insert_sql.borrow_mut().clear();
14060        self.overlay_dirty.set(true);
14061        // Drop every synced table (base + visible) that currently exists —
14062        // discovered from sqlite_master so a bump that adds/removes tables is
14063        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
14064        // and the blob cache) are preserved; base tables are `_syncular_base_*`
14065        // so they are matched explicitly, not by the bookkeeping filter.
14066        // Drop virtual tables first so SQLite removes every FTS shadow table
14067        // atomically instead of the generic discovery tearing it apart.
14068        let virtual_tables: Vec<String> = {
14069            let mut stmt = self
14070                .conn
14071                .prepare(
14072                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
14073                )
14074                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14075            let rows = stmt
14076                .query_map([], |row| row.get::<_, String>(0))
14077                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14078            rows.filter_map(Result::ok)
14079                .filter(|name| is_synced_table_name(name))
14080                .collect()
14081        };
14082        for name in virtual_tables {
14083            self.conn
14084                .execute_batch(&format!(
14085                    "DROP TABLE IF EXISTS {}",
14086                    quote_ident(&format!("_syncular_fts_{name}"))
14087                ))
14088                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14089            self.conn
14090                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14091                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14092        }
14093        let existing: Vec<String> = {
14094            let mut stmt = self
14095                .conn
14096                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
14097                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14098            let rows = stmt
14099                .query_map([], |row| row.get::<_, String>(0))
14100                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14101            rows.filter_map(Result::ok)
14102                .filter(|name| is_synced_table_name(name))
14103                .collect()
14104        };
14105        for name in &existing {
14106            if let Some(table) = name.strip_prefix("_syncular_base_") {
14107                batch.table(table);
14108            } else if !name.starts_with("_syncular_") {
14109                batch.table(name);
14110            }
14111        }
14112        for name in existing {
14113            self.conn
14114                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14115                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14116        }
14117        // Recreate the synced tables from the NEW schema.
14118        self.create_synced_tables()?;
14119        // Reset every subscription's sync-state, keeping the registration.
14120        for sub in &mut self.subs {
14121            sub.cursor = -1;
14122            sub.bootstrap_state = None;
14123            sub.effective = None;
14124            sub.state = SubState::Active;
14125            sub.reason_code = None;
14126            sub.synced_once = false;
14127        }
14128        let subs = self.subs.clone();
14129        for sub in &subs {
14130            self.persist_sub(sub)?;
14131        }
14132        // The stop state is over: this client now ships a servable schema.
14133        self.stopped = false;
14134        self.schema_floor = None;
14135        self.delete_meta(SCHEMA_FLOOR_KEY);
14136        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
14137        self.persist_schema_version()?;
14138        self.save_subscription_scope_schema();
14139        // §7.1/§7.4.4: the previous-version capture and audit above saw the
14140        // original pending intent; the synced tables now carry the new schema, so
14141        // recover intrinsically codec-invalid persisted values here, before the
14142        // final replay. A pending upsert that names a column the new schema
14143        // removed stays pending for the send-time `drop_incompatible_outbox`
14144        // prepass. The reset wrapper snapshots and restores the outbox,
14145        // rejections, and change queues, so a failed commit rolls this recovery
14146        // back with the rest of the reset.
14147        // §7.4.4: the enclosing reset observation owns the publication, so the
14148        // recovery contributes its rejection, outcome, and table domains to it.
14149        self.drop_unencodable_outbox(false, Some(batch))?;
14150        // Re-apply the surviving outbox optimistically over the empty tables.
14151        self.rebuild_overlay()?;
14152        Ok(())
14153    }
14154
14155    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
14156    /// column the current schema lacks (or a removed table) cannot be encoded
14157    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
14158    /// encodable and is validated by the server), nor can an insert-intent
14159    /// upsert that lacks a non-nullable column (§7.1, the audit's
14160    /// `missing-required-column`). Drop the commit, undo
14161    /// its optimistic projection, and raise a client-local
14162    /// `sync.outbox_incompatible` rejection.
14163    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
14164        let incompatible = self
14165            .outbox
14166            .iter()
14167            .filter(|commit| {
14168                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14169            })
14170            .cloned()
14171            .collect::<Vec<_>>();
14172        if incompatible.is_empty() {
14173            return Ok(false);
14174        }
14175        // §7.4.4: one observation covers the durable outcome writes, the outbox
14176        // removal, the in-memory drop, and the overlay rebuild, so a storage
14177        // fault leaves the replica and the client unchanged and publishes no
14178        // change; a successful drop publishes exactly one batch carrying the
14179        // rejections, outcomes, status, and the tables whose projection changed.
14180        let prior_outbox = self.outbox.clone();
14181        let prior_rejections = self.rejections.clone();
14182        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14183        let owns_transaction = self.conn.is_autocommit();
14184        self.begin_observation("syncular_outbox_drop")?;
14185        let mut batch = ChangeAccumulator::default();
14186        let dropped = (|| -> Result<(), String> {
14187            let mut rejections = Vec::new();
14188            for commit in &incompatible {
14189                let results = commit
14190                    .ops
14191                    .iter()
14192                    .enumerate()
14193                    .map(|(op_index, operation)| {
14194                        let rejection = RejectionRecord {
14195                            client_commit_id: commit.client_commit_id.clone(),
14196                            op_index: op_index as i32,
14197                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14198                            message: "the persisted commit cannot encode under the current schema"
14199                                .to_owned(),
14200                            retryable: false,
14201                            details: None,
14202                            operation: Some(CommitOperation::from(operation)),
14203                        };
14204                        rejections.push(rejection.clone());
14205                        CommitOperationOutcome::Error { rejection }
14206                    })
14207                    .collect::<Vec<_>>();
14208                self.persist_commit_outcome(
14209                    &commit.client_commit_id,
14210                    CommitOutcomeStatus::Rejected,
14211                    &results,
14212                    Some(&commit.ops),
14213                )?;
14214                self.delete_outbox_persisted(&commit.client_commit_id)?;
14215                // A removed table has no local projection to invalidate; every
14216                // surviving table the dropped commit touched does.
14217                for operation in &commit.ops {
14218                    if self.schema.table(&operation.table).is_some() {
14219                        batch.table(&operation.table);
14220                    }
14221                }
14222            }
14223            self.prune_commit_outcomes()?;
14224            let incompatible_ids = incompatible
14225                .iter()
14226                .map(|commit| commit.client_commit_id.as_str())
14227                .collect::<BTreeSet<_>>();
14228            self.outbox
14229                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14230            self.rejections.extend(rejections);
14231            // The dropped commits' purely-optimistic rows are undone: the
14232            // visible projection is re-derived from the surviving base + outbox.
14233            self.overlay_dirty.set(true);
14234            self.rebuild_overlay()?;
14235            batch.status = true;
14236            batch.rejections = true;
14237            batch.outcomes = true;
14238            Ok(())
14239        })();
14240        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14241        if let Err(error) = result {
14242            // An outer RELEASE is the COMMIT. Roll back that transaction
14243            // directly: retrying RELEASE after ROLLBACK TO still needs the
14244            // reader's lock. A nested observation only rolls back its savepoint.
14245            if owns_transaction {
14246                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14247                    let mut failure = self.storage_failure.borrow_mut();
14248                    let failure =
14249                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14250                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14251                }
14252            } else {
14253                self.rollback_observation("syncular_outbox_drop");
14254            }
14255            self.outbox = prior_outbox;
14256            self.rejections = prior_rejections;
14257            self.overlay_dirty.restore(prior_overlay_dirty);
14258            return Err(error);
14259        }
14260        Ok(true)
14261    }
14262
14263    /// §7.4.3: (re)create the base + visible table pair for every synced
14264    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
14265    fn create_synced_tables(&self) -> Result<(), String> {
14266        for table in &self.schema.tables {
14267            // The base half + the visible half form the synced-table pair. An
14268            // index name is global in SQLite, so the base half's indexes are
14269            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
14270            for (full, index_prefix) in [
14271                (base_table(&table.name), "_syncular_base_"),
14272                (visible_table(&table.name), ""),
14273            ] {
14274                let mut cols: Vec<String> =
14275                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14276                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14277                let sql = format!(
14278                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14279                    cols.join(", "),
14280                    quote_ident(&table.primary_key)
14281                );
14282                self.conn
14283                    .execute(&sql, [])
14284                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14285                // Local secondary indexes (CREATE INDEX subset). Created on
14286                // both halves so mirror reads hit an index on either. Runs on
14287                // both the initial create and the §7.4.3 reset recreate path.
14288                for index in &table.indexes {
14289                    let unique = if index.unique { "UNIQUE " } else { "" };
14290                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14291                    let cols_sql = index
14292                        .columns
14293                        .iter()
14294                        .map(|c| quote_ident(c))
14295                        .collect::<Vec<_>>()
14296                        .join(", ");
14297                    let index_sql = format!(
14298                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14299                    );
14300                    self.conn
14301                        .execute(&index_sql, [])
14302                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14303                }
14304            }
14305        }
14306        // FTS exists only on the visible half. It is a local search
14307        // projection, never a synced/base table or a wire-schema member.
14308        for table in &self.schema.tables {
14309            for index in &table.fts_indexes {
14310                self.create_fts_projection(table, index)?;
14311            }
14312        }
14313        Ok(())
14314    }
14315
14316    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14317        let count: i64 = self
14318            .conn
14319            .query_row(
14320                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14321                rusqlite::params![index.name],
14322                |row| row.get(0),
14323            )
14324            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14325        Ok(count > 0)
14326    }
14327
14328    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14329        for suffix in ["ai", "ad", "au"] {
14330            self.conn
14331                .execute(
14332                    &format!(
14333                        "DROP TRIGGER IF EXISTS {}",
14334                        quote_ident(&format!("{}_{suffix}", index.name))
14335                    ),
14336                    [],
14337                )
14338                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14339        }
14340        Ok(())
14341    }
14342
14343    fn create_fts_triggers(
14344        &self,
14345        table: &TableSchema,
14346        index: &FtsIndexSchema,
14347    ) -> Result<(), String> {
14348        let fts = quote_ident(&index.name);
14349        let source = visible_table(&table.name);
14350        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14351        let pk = quote_ident(&table.primary_key);
14352        let projection_columns = std::iter::once(source_id.clone())
14353            .chain(index.columns.iter().map(|column| quote_ident(column)))
14354            .collect::<Vec<_>>()
14355            .join(", ");
14356        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14357            .chain(
14358                index
14359                    .columns
14360                    .iter()
14361                    .map(|column| format!("new.{}", quote_ident(column))),
14362            )
14363            .collect::<Vec<_>>()
14364            .join(", ");
14365        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14366        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)");
14367        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)");
14368        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})");
14369        let bi = quote_ident(&format!("{}_bi", index.name));
14370        let ai = quote_ident(&format!("{}_ai", index.name));
14371        let ad = quote_ident(&format!("{}_ad", index.name));
14372        let au = quote_ident(&format!("{}_au", index.name));
14373        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14374        let sql = format!(
14375            "DROP TRIGGER IF EXISTS {bi};
14376             DROP TRIGGER IF EXISTS {ai};
14377             DROP TRIGGER IF EXISTS {ad};
14378             DROP TRIGGER IF EXISTS {au};
14379             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14380             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14381             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14382             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14383        );
14384        self.conn
14385            .execute_batch(&sql)
14386            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14387    }
14388
14389    fn rebuild_fts_projection(
14390        &self,
14391        table: &TableSchema,
14392        index: &FtsIndexSchema,
14393    ) -> Result<(), String> {
14394        let fts = quote_ident(&index.name);
14395        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14396        let pk = quote_ident(&table.primary_key);
14397        let indexed_columns = index
14398            .columns
14399            .iter()
14400            .map(|column| quote_ident(column))
14401            .collect::<Vec<_>>();
14402        let projection_columns = std::iter::once(source_id.clone())
14403            .chain(indexed_columns.iter().cloned())
14404            .collect::<Vec<_>>()
14405            .join(", ");
14406        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14407        let sql = format!(
14408            "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};",
14409            columns = indexed_columns.join(", "),
14410            source = visible_table(&table.name),
14411        );
14412        self.conn
14413            .execute_batch(&sql)
14414            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14415    }
14416
14417    fn create_fts_projection(
14418        &self,
14419        table: &TableSchema,
14420        index: &FtsIndexSchema,
14421    ) -> Result<(), String> {
14422        self.begin_observation("syncular_fts_schema")?;
14423        let result = (|| {
14424            let existed = self.fts_projection_exists(index)?;
14425            let tokenizer = index.tokenize.replace('\'', "''");
14426            let columns = index
14427                .columns
14428                .iter()
14429                .map(|column| quote_ident(column))
14430                .collect::<Vec<_>>()
14431                .join(", ");
14432            let sql = format!(
14433            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14434            fts = quote_ident(&index.name),
14435            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14436        );
14437            self.conn.execute(&sql, []).map_err(|error| {
14438                format!(
14439                    "cannot create local FTS5 projection {:?}: {error}",
14440                    index.name
14441                )
14442            })?;
14443            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14444            let mapped: bool = self
14445                .conn
14446                .query_row(
14447                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14448                    [format!("_syncular_fts_{}", index.name)],
14449                    |row| row.get(0),
14450                )
14451                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14452            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))?;
14453            if !mapped && existed {
14454                self.conn
14455                    .execute_batch(&format!(
14456                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14457                        quote_ident(FTS_SOURCE_ID_COLUMN),
14458                        quote_ident(&index.name)
14459                    ))
14460                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14461            }
14462            self.create_fts_triggers(table, index)?;
14463            if !existed {
14464                self.rebuild_fts_projection(table, index)?;
14465            }
14466            Ok(())
14467        })();
14468        if let Err(error) = result {
14469            self.rollback_observation("syncular_fts_schema");
14470            return Err(error);
14471        }
14472        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14473            self.rollback_observation("syncular_fts_schema");
14474            return Err(error.to_string());
14475        }
14476        Ok(())
14477    }
14478
14479    // -- persistence write-through --------------------------------------------
14480
14481    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14482        let state = serde_json::json!({
14483            "requested": scope_map_to_json(&sub.requested),
14484            "params": sub.params,
14485            "cursor": sub.cursor,
14486            "bootstrapState": sub.bootstrap_state,
14487            "status": sub.state.name(),
14488            "reasonCode": sub.reason_code,
14489            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14490            "syncedOnce": sub.synced_once,
14491        });
14492        self.conn.execute(
14493            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14494            rusqlite::params![sub.id, sub.table, state.to_string()],
14495        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14496    }
14497
14498    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14499        let ops: Vec<Value> = commit
14500            .ops
14501            .iter()
14502            .map(|op| {
14503                let mut entry = serde_json::json!({
14504                    "op": if op.upsert { "upsert" } else { "delete" },
14505                    "table": op.table,
14506                    "rowId": op.row_id,
14507                    "baseVersion": op.base_version,
14508                    "values": op.values.clone().map(Value::Object),
14509                });
14510                if op.insert {
14511                    entry["insert"] = Value::Bool(true);
14512                }
14513                entry
14514            })
14515            .collect();
14516        self.conn
14517            .execute(
14518                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14519                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14520            )
14521            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14522        for operation in &commit.ops {
14523            let Some(values) = &operation.values else {
14524                continue;
14525            };
14526            let Some(table) = self.schema.table(&operation.table) else {
14527                continue;
14528            };
14529            for column in table
14530                .columns
14531                .iter()
14532                .filter(|column| column.ty == ColumnType::BlobRef)
14533            {
14534                let Some(Value::String(raw)) = values.get(&column.name) else {
14535                    continue;
14536                };
14537                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14538                    continue;
14539                };
14540                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14541                    continue;
14542                };
14543                self.conn
14544                    .execute(
14545                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14546                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14547                        rusqlite::params![commit.client_commit_id, blob_id],
14548                    )
14549                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14550            }
14551        }
14552        Ok(())
14553    }
14554
14555    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14556        if let Some(commit) = self
14557            .outbox
14558            .iter()
14559            .find(|commit| commit.client_commit_id == client_commit_id)
14560        {
14561            for operation in &commit.ops {
14562                self.overlay_dirty.table(&operation.table);
14563            }
14564        }
14565
14566        self.conn
14567            .execute(
14568                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14569                rusqlite::params![client_commit_id],
14570            )
14571            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14572        self.conn
14573            .execute(
14574                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14575                rusqlite::params![client_commit_id],
14576            )
14577            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14578        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)", [])
14579            .map(|_| ())
14580            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14581    }
14582
14583    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14584        match status {
14585            CommitOutcomeStatus::Applied => "applied",
14586            CommitOutcomeStatus::Cached => "cached",
14587            CommitOutcomeStatus::Conflict => "conflict",
14588            CommitOutcomeStatus::Rejected => "rejected",
14589        }
14590    }
14591
14592    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14593        match resolution {
14594            CommitOutcomeResolution::Active => "active",
14595            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14596            CommitOutcomeResolution::Superseded => "superseded",
14597            CommitOutcomeResolution::Dismissed => "dismissed",
14598        }
14599    }
14600
14601    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14602        match value {
14603            "applied" => Ok(CommitOutcomeStatus::Applied),
14604            "cached" => Ok(CommitOutcomeStatus::Cached),
14605            "conflict" => Ok(CommitOutcomeStatus::Conflict),
14606            "rejected" => Ok(CommitOutcomeStatus::Rejected),
14607            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14608        }
14609    }
14610
14611    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14612        match value {
14613            "active" => Ok(CommitOutcomeResolution::Active),
14614            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14615            "superseded" => Ok(CommitOutcomeResolution::Superseded),
14616            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14617            _ => Err(format!(
14618                "invalid persisted commit outcome resolution {value:?}"
14619            )),
14620        }
14621    }
14622
14623    fn persist_commit_outcome(
14624        &self,
14625        client_commit_id: &str,
14626        status: CommitOutcomeStatus,
14627        results: &[CommitOperationOutcome],
14628        operations: Option<&[OutboxOp]>,
14629    ) -> Result<(), String> {
14630        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14631        let operations_json = operations
14632            .map(|items| {
14633                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14634            })
14635            .transpose()
14636            .map_err(|error| error.to_string())?;
14637        self.conn
14638            .execute(
14639                "INSERT INTO _syncular_commit_outcomes (
14640                   client_commit_id, status, recorded_at_ms, results_json,
14641                   operations_json, resolution
14642                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14643                rusqlite::params![
14644                    client_commit_id,
14645                    Self::outcome_status_name(status),
14646                    self.clock_now_ms(),
14647                    results_json,
14648                    operations_json
14649                ],
14650            )
14651            .map(|_| ())
14652            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14653    }
14654
14655    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
14656    /// the byte-envelope rule refuses cannot be journaled verbatim.
14657    fn stored_cell_is_representable(cell: &Value) -> bool {
14658        cell.is_null()
14659            || cell.is_string()
14660            || cell.is_boolean()
14661            || cell.is_number()
14662            || is_canonical_bytes_envelope(cell)
14663    }
14664
14665    fn stored_row_is_representable(value: &Value) -> bool {
14666        value
14667            .as_object()
14668            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14669    }
14670
14671    fn stored_operation_is_representable(value: &Value) -> bool {
14672        value.get("table").is_some_and(Value::is_string)
14673            && value.get("rowId").is_some_and(Value::is_string)
14674            && value.get("baseVersion").is_none_or(|v| {
14675                v.as_i64()
14676                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14677            })
14678            && match value.get("op").and_then(Value::as_str) {
14679                Some("upsert") => {
14680                    value
14681                        .get("values")
14682                        .is_some_and(Self::stored_row_is_representable)
14683                        && value
14684                            .get("insert")
14685                            .is_none_or(|insert| *insert == Value::Bool(true))
14686                }
14687                Some("delete") => value.get("values").is_none() && value.get("insert").is_none(),
14688                _ => false,
14689            }
14690    }
14691
14692    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
14693    /// only when the journal reader accepts the serialized operation, which is the
14694    /// rule `outcome_from_row` applies to this same row later.
14695    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14696        serde_json::to_value(CommitOperation::from(operation))
14697            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14698    }
14699
14700    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14701        let StoredCommitOutcomeRow {
14702            sequence,
14703            client_commit_id,
14704            status,
14705            recorded_at_ms,
14706            results_json,
14707            operations_json,
14708            resolution,
14709            resolved_at_ms,
14710            replacement_client_commit_id,
14711        } = row;
14712        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14713            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14714            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14715        {
14716            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14717        }
14718        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14719        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14720        for result in &stored {
14721            let nested = match result.get("status").and_then(Value::as_str) {
14722                Some("conflict") => result.get("conflict"),
14723                Some("error") => result.get("rejection"),
14724                _ => None,
14725            };
14726            if nested
14727                .and_then(|record| record.get("operation"))
14728                .is_some_and(|op| !Self::stored_operation_is_representable(op))
14729                || (result.get("status").and_then(Value::as_str) == Some("conflict")
14730                    && nested
14731                        .and_then(|record| record.get("serverRow"))
14732                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
14733            {
14734                return Err(invalid());
14735            }
14736        }
14737        let results: Vec<CommitOperationOutcome> =
14738            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14739        let has_conflict = results
14740            .iter()
14741            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14742        let has_error = results
14743            .iter()
14744            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14745        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14746        let redacted = results.is_empty() && operations_json.is_none();
14747        if match status {
14748            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14749            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14750            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14751        } {
14752            return Err(invalid());
14753        }
14754        let operations: Option<Vec<CommitOperation>> = operations_json
14755            .map(|raw| {
14756                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14757                if !stored.iter().all(Self::stored_operation_is_representable) {
14758                    return Err(invalid());
14759                }
14760                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14761            })
14762            .transpose()?;
14763        for result in &results {
14764            match result {
14765                CommitOperationOutcome::Conflict { conflict }
14766                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14767                {
14768                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14769                }
14770                CommitOperationOutcome::Error { rejection } => {
14771                    if let Some(details) = &rejection.details {
14772                        let encoded = serde_json::to_string(details).map_err(|_| {
14773                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14774                        })?;
14775                        RejectionDetails::parse(&encoded).map_err(|_| {
14776                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14777                        })?;
14778                    }
14779                }
14780                _ => {}
14781            }
14782        }
14783        Ok(CommitOutcome {
14784            sequence,
14785            client_commit_id,
14786            status,
14787            recorded_at_ms,
14788            results,
14789            operations,
14790            retained_rows: None,
14791            resolution: Self::parse_outcome_resolution(&resolution)?,
14792            resolved_at_ms,
14793            replacement_client_commit_id,
14794        })
14795    }
14796
14797    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
14798    fn unique_conflicts(
14799        &self,
14800        table: &TableSchema,
14801        values: &Map<String, Value>,
14802        base: bool,
14803    ) -> Result<Vec<RetainedUniqueConflict>, String> {
14804        let source = if base {
14805            base_table(&table.name)
14806        } else {
14807            visible_table(&table.name)
14808        };
14809        let mut conflicts = Vec::new();
14810        for index in table.indexes.iter().filter(|index| index.unique) {
14811            let mut params = vec![json_param_to_sql(
14812                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14813            )?];
14814            for name in &index.columns {
14815                let column = table
14816                    .columns
14817                    .iter()
14818                    .find(|column| column.name == *name)
14819                    .ok_or("sync.local_corrupt")?;
14820                let cell = json_to_column_value(column, values.get(name))?;
14821                params.push(owned_sql_value(RowParam::Cell(&cell))?);
14822            }
14823            let sql = format!(
14824                "SELECT * FROM {source} WHERE {} != ? AND {}",
14825                quote_ident(&table.primary_key),
14826                index
14827                    .columns
14828                    .iter()
14829                    .map(|column| format!("{} = ?", quote_ident(column)))
14830                    .collect::<Vec<_>>()
14831                    .join(" AND ")
14832            );
14833            let mut statement = self
14834                .conn
14835                .prepare_cached(&sql)
14836                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14837            let records = statement
14838                .query_map(rusqlite::params_from_iter(params), |record| {
14839                    let values = table
14840                        .columns
14841                        .iter()
14842                        .enumerate()
14843                        .map(|(index, column)| {
14844                            let value = sql_ref_to_json(column, record.get_ref(index)?);
14845                            json_to_column_value(column, Some(&value)).map_err(|message| {
14846                                rusqlite::Error::ToSqlConversionFailure(message.into())
14847                            })
14848                        })
14849                        .collect::<Result<Row, _>>()?;
14850                    Ok((values, record.get::<_, i64>(table.columns.len())?))
14851                })
14852                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14853            for record in records {
14854                let (row, version) =
14855                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14856                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14857                conflicts.push(RetainedUniqueConflict {
14858                    index: index.name.clone(),
14859                    columns: index.columns.clone(),
14860                    row_id,
14861                    server_row: table
14862                        .columns
14863                        .iter()
14864                        .zip(&row)
14865                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14866                        .collect(),
14867                    server_version: version,
14868                });
14869            }
14870        }
14871        Ok(conflicts)
14872    }
14873
14874    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14875        if let Some((commit, initial)) = self
14876            .failed_commits
14877            .iter()
14878            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14879        {
14880            let mut rows = Vec::new();
14881            for operation in &commit.ops {
14882                let table = self
14883                    .schema
14884                    .table(&operation.table)
14885                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
14886                let key =
14887                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14888                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14889                let base = self.stored_row(table, &operation.row_id, true)?;
14890                rows.push(RetainedCommitRow {
14891                    table: operation.table.clone(),
14892                    row_id: operation.row_id.clone(),
14893                    unique_conflicts: if operation.upsert {
14894                        self.unique_conflicts(
14895                            table,
14896                            initial
14897                                .get(&key)
14898                                .and_then(Value::as_object)
14899                                .or(operation.values.as_ref())
14900                                .ok_or("sync.local_corrupt")?,
14901                            true,
14902                        )?
14903                    } else {
14904                        Vec::new()
14905                    },
14906                    local_row: if operation.upsert {
14907                        initial
14908                            .get(&key)
14909                            .and_then(Value::as_object)
14910                            .cloned()
14911                            .or_else(|| operation.values.clone())
14912                    } else {
14913                        None
14914                    },
14915                    server_row: base.as_ref().map(|(values, _)| {
14916                        table
14917                            .columns
14918                            .iter()
14919                            .zip(values)
14920                            .map(|(column, value)| {
14921                                (column.name.clone(), column_value_to_json(value))
14922                            })
14923                            .collect()
14924                    }),
14925                    server_version: base.map(|(_, version)| version),
14926                });
14927            }
14928            outcome.retained_rows = Some(rows);
14929        }
14930        Ok(outcome)
14931    }
14932
14933    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14934        let row = self
14935            .conn
14936            .query_row(
14937                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14938                        resolution, resolved_at_ms, replacement_client_commit_id
14939                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14940                rusqlite::params![client_commit_id],
14941                |row| {
14942                    Ok(StoredCommitOutcomeRow {
14943                        sequence: row.get(0)?,
14944                        client_commit_id: row.get(1)?,
14945                        status: row.get(2)?,
14946                        recorded_at_ms: row.get(3)?,
14947                        results_json: row.get(4)?,
14948                        operations_json: row.get(5)?,
14949                        resolution: row.get(6)?,
14950                        resolved_at_ms: row.get(7)?,
14951                        replacement_client_commit_id: row.get(8)?,
14952                    })
14953                },
14954            )
14955            .optional()
14956            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14957        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14958            .transpose()
14959    }
14960
14961    /// Internal host step. The host must run its idle drive before finishing.
14962    #[doc(hidden)]
14963    pub fn prepare_current_base(
14964        &self,
14965        client_commit_id: &str,
14966        sequence: i64,
14967    ) -> Result<CurrentBasePreparation, CurrentBaseProof> {
14968        let outcome = self
14969            .commit_outcome(client_commit_id)
14970            .map_err(|_| CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed))?;
14971        let valid = outcome.as_ref().is_some_and(|outcome| {
14972            outcome.sequence == sequence
14973                && outcome.resolution == CommitOutcomeResolution::Active
14974                && matches!(
14975                    outcome.status,
14976                    CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
14977                )
14978                && outcome
14979                    .operations
14980                    .as_ref()
14981                    .zip(outcome.retained_rows.as_ref())
14982                    .is_some_and(|(operations, rows)| {
14983                        !rows.is_empty() && operations.len() == rows.len()
14984                    })
14985        });
14986        if !valid {
14987            return Err(CurrentBaseProof::failure(
14988                CurrentBaseProofReason::OutcomeUnavailable,
14989            ));
14990        }
14991        if !self.transport_enabled {
14992            return Err(CurrentBaseProof::failure(CurrentBaseProofReason::Offline));
14993        }
14994        Ok(CurrentBasePreparation {
14995            client_commit_id: client_commit_id.to_owned(),
14996            sequence,
14997            subscriptions: self.subs.clone(),
14998            transport_epoch: self.transport_epoch,
14999            subscription_epoch: self.subscription_epoch,
15000            security_epoch: self.security_epoch,
15001        })
15002    }
15003
15004    /// Internal host step after the completed post-rejection idle drive.
15005    #[doc(hidden)]
15006    pub fn finish_current_base(
15007        &mut self,
15008        prepared: CurrentBasePreparation,
15009        drive: SyncOutcome,
15010    ) -> CurrentBaseProof {
15011        if !self.transport_enabled || self.transport_epoch != prepared.transport_epoch {
15012            return CurrentBaseProof::failure(CurrentBaseProofReason::Offline);
15013        }
15014        if self.subscription_epoch != prepared.subscription_epoch {
15015            return CurrentBaseProof::failure(CurrentBaseProofReason::MissingCoverage);
15016        }
15017        if self.security_preflight || self.security_epoch != prepared.security_epoch {
15018            return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed);
15019        }
15020        let report = match drive {
15021            SyncOutcome::Ok(report) => report,
15022            SyncOutcome::BudgetExhausted(_) => {
15023                return CurrentBaseProof::failure(CurrentBaseProofReason::BudgetExhausted)
15024            }
15025            _ => return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed),
15026        };
15027        if report.schema_floor.is_some() {
15028            return CurrentBaseProof::failure(CurrentBaseProofReason::SchemaFloor);
15029        }
15030        if self
15031            .conn
15032            .execute_batch("SAVEPOINT syncular_base_proof")
15033            .is_err()
15034        {
15035            return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
15036        }
15037        let proof = (|| {
15038            let snapshot = snapshot_read_connection(
15039                &self.conn,
15040                &SnapshotReadRequest {
15041                    subscriptions: prepared
15042                        .subscriptions
15043                        .iter()
15044                        .map(|sub| sub.id.clone())
15045                        .collect(),
15046                    commit_ids: vec![prepared.client_commit_id.clone()],
15047                    ..Default::default()
15048                },
15049            )
15050            .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
15051            let current = self
15052                .commit_outcome(&prepared.client_commit_id)
15053                .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
15054            let unavailable = if report.revoked.is_empty() {
15055                CurrentBaseProofReason::OutcomeUnavailable
15056            } else {
15057                CurrentBaseProofReason::Revoked
15058            };
15059            let Some(outcome) = current else {
15060                return Err(unavailable);
15061            };
15062            if !matches!(
15063                snapshot.deliveries.first(),
15064                Some(CommitDelivery::Known { .. })
15065            ) || outcome.sequence != prepared.sequence
15066                || outcome.resolution != CommitOutcomeResolution::Active
15067                || !matches!(
15068                    outcome.status,
15069                    CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
15070                )
15071            {
15072                return Err(unavailable);
15073            }
15074            let (Some(operations), Some(rows)) = (outcome.operations, outcome.retained_rows) else {
15075                return Err(unavailable);
15076            };
15077            if rows.is_empty() || operations.len() != rows.len() {
15078                return Err(unavailable);
15079            }
15080            let after: HashMap<&str, &Subscription> =
15081                self.subs.iter().map(|sub| (sub.id.as_str(), sub)).collect();
15082            let complete: HashSet<&str> = snapshot
15083                .subscriptions
15084                .iter()
15085                .filter_map(|sub| match sub {
15086                    SubscriptionCatchup::Known {
15087                        id,
15088                        bootstrap_complete: true,
15089                        ..
15090                    } => Some(id.as_str()),
15091                    _ => None,
15092                })
15093                .collect();
15094            let candidates: Vec<_> = prepared
15095                .subscriptions
15096                .iter()
15097                .filter_map(|before| {
15098                    let after = after.get(before.id.as_str())?;
15099                    if before.table != after.table
15100                        || before.params != after.params
15101                        || canonical_scope_json(&before.requested)
15102                            != canonical_scope_json(&after.requested)
15103                        || !complete.contains(before.id.as_str())
15104                    {
15105                        return None;
15106                    }
15107                    after
15108                        .effective
15109                        .as_ref()
15110                        .map(|scopes| (after.table.as_str(), scopes))
15111                })
15112                .collect();
15113            let covered = |table_name: &str, row: &Map<String, Value>| {
15114                let Some(table) = self.schema.table(table_name) else {
15115                    return false;
15116                };
15117                candidates.iter().any(|(candidate_table, scopes)| {
15118                    *candidate_table == table_name
15119                        && !scopes.is_empty()
15120                        && scopes.iter().all(|(variable, allowed)| {
15121                            table
15122                                .scope_column(variable)
15123                                .and_then(|column| match row.get(column) {
15124                                    Some(Value::String(value)) => Some(value.clone()),
15125                                    Some(Value::Bool(value)) => Some(value.to_string()),
15126                                    Some(Value::Number(value)) => value.as_f64().map(|number| {
15127                                        ryu_js::Buffer::new().format_finite(number).to_owned()
15128                                    }),
15129                                    _ => None,
15130                                })
15131                                .is_some_and(|value| !value.is_empty() && allowed.contains(&value))
15132                        })
15133                })
15134            };
15135            for row in &rows {
15136                let Some(base) = row.server_row.as_ref().or(row.local_row.as_ref()) else {
15137                    return Err(if report.revoked.is_empty() {
15138                        CurrentBaseProofReason::MissingCoverage
15139                    } else {
15140                        CurrentBaseProofReason::Revoked
15141                    });
15142                };
15143                if !covered(&row.table, base)
15144                    || row
15145                        .unique_conflicts
15146                        .iter()
15147                        .any(|conflict| !covered(&row.table, &conflict.server_row))
15148                {
15149                    return Err(if report.revoked.is_empty() {
15150                        CurrentBaseProofReason::MissingCoverage
15151                    } else {
15152                        CurrentBaseProofReason::Revoked
15153                    });
15154                }
15155            }
15156            Ok(rows)
15157        })();
15158        if self
15159            .conn
15160            .execute_batch("RELEASE syncular_base_proof")
15161            .is_err()
15162        {
15163            return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
15164        }
15165        match proof {
15166            Ok(rows) => CurrentBaseProof {
15167                proven: true,
15168                rows: Some(rows),
15169                reason: None,
15170            },
15171            Err(reason) => CurrentBaseProof::failure(reason),
15172        }
15173    }
15174
15175    pub fn prove_current_base(
15176        &mut self,
15177        transport: &mut dyn Transport,
15178        client_commit_id: &str,
15179        sequence: i64,
15180        max_rounds: Option<u32>,
15181    ) -> CurrentBaseProof {
15182        let prepared = match self.prepare_current_base(client_commit_id, sequence) {
15183            Ok(prepared) => prepared,
15184            Err(proof) => return proof,
15185        };
15186        let drive = self.sync_until_idle(transport, max_rounds);
15187        self.finish_current_base(prepared, drive)
15188    }
15189
15190    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
15191        if query.limit == Some(0) {
15192            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
15193        }
15194        let mut sql = String::from(
15195            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
15196                    resolution, resolved_at_ms, replacement_client_commit_id
15197               FROM _syncular_commit_outcomes",
15198        );
15199        if query.active_only {
15200            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
15201        }
15202        sql.push_str(" ORDER BY seq DESC");
15203        if let Some(limit) = query.limit {
15204            sql.push_str(&format!(" LIMIT {limit}"));
15205        }
15206        let mut stmt = self
15207            .conn
15208            .prepare(&sql)
15209            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15210        let rows = stmt
15211            .query_map([], |row| {
15212                Ok(StoredCommitOutcomeRow {
15213                    sequence: row.get(0)?,
15214                    client_commit_id: row.get(1)?,
15215                    status: row.get(2)?,
15216                    recorded_at_ms: row.get(3)?,
15217                    results_json: row.get(4)?,
15218                    operations_json: row.get(5)?,
15219                    resolution: row.get(6)?,
15220                    resolved_at_ms: row.get(7)?,
15221                    replacement_client_commit_id: row.get(8)?,
15222                })
15223            })
15224            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15225        let mut outcomes = Vec::new();
15226        for row in rows {
15227            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
15228                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
15229            )?)?);
15230        }
15231        Ok(outcomes)
15232    }
15233
15234    fn prune_commit_outcomes(&self) -> Result<(), String> {
15235        #[cfg(test)]
15236        self.outcome_prune_count
15237            .set(self.outcome_prune_count.get() + 1);
15238        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
15239        let count = self
15240            .conn
15241            .query_row(
15242                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
15243                [],
15244                |row| row.get::<_, i64>(0),
15245            )
15246            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15247            as usize;
15248        let excess = count.saturating_sub(max_entries);
15249        if excess == 0 {
15250            return Ok(());
15251        }
15252        let mut stmt = self
15253            .conn
15254            .prepare(
15255                "SELECT seq FROM _syncular_commit_outcomes
15256                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
15257                  ORDER BY seq ASC LIMIT ?1",
15258            )
15259            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15260        let rows = stmt
15261            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
15262            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15263        let sequences = rows
15264            .collect::<Result<Vec<_>, _>>()
15265            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15266        drop(stmt);
15267        for sequence in sequences {
15268            self.conn
15269                .execute(
15270                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
15271                    rusqlite::params![sequence],
15272                )
15273                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15274        }
15275        Ok(())
15276    }
15277
15278    // -- driver surface ---------------------------------------------------------
15279
15280    pub fn subscribe(
15281        &mut self,
15282        id: String,
15283        table: String,
15284        scopes: Vec<(String, Vec<String>)>,
15285        params: Option<String>,
15286    ) -> Result<(), String> {
15287        if self.schema.table(&table).is_none() {
15288            return Err(format!("unknown table {table:?}"));
15289        }
15290        let registered_at = self
15291            .subs
15292            .iter()
15293            .position(|subscription| subscription.id == id);
15294        if let Some(index) = registered_at {
15295            let existing = &self.subs[index];
15296            let same_intent = existing.table == table
15297                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
15298                && existing.params == params;
15299            if !same_intent {
15300                return Err(
15301                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
15302                        .to_owned(),
15303                );
15304            }
15305            // §3.3: a revoked registration keeps its id and intent but no
15306            // progress. Subscribing again with the same intent re-arms it and
15307            // asks for a pull, which resolves its scopes from scratch. A
15308            // `failed` registration is a configuration error and stays put.
15309            if existing.state != SubState::Revoked {
15310                return Ok(());
15311            }
15312        }
15313        let sub = Subscription {
15314            id: id.clone(),
15315            table,
15316            requested: scopes,
15317            params,
15318            cursor: -1,
15319            bootstrap_state: None,
15320            state: SubState::Active,
15321            reason_code: None,
15322            effective: None,
15323            synced_once: false,
15324        };
15325        self.persist_sub(&sub)?;
15326        match registered_at {
15327            Some(index) => self.subs[index] = sub,
15328            None => self.subs.push(sub),
15329        }
15330        self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
15331        // Only a re-arm asks for a sync here: a fresh registration reaches the
15332        // next round through the host's schedule, and `set_window` (§4.8)
15333        // batches its own intent for the units it registers.
15334        if registered_at.is_some() {
15335            self.set_sync_needed(true, true);
15336        }
15337        Ok(())
15338    }
15339
15340    /// Register several subscriptions in one local transaction: the same
15341    /// rules as `subscribe` for each request, and one commit for all of them
15342    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
15343    /// registrations pays it once). An error rolls back every registration
15344    /// of the call.
15345    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
15346        let prior_subs = self.subs.clone();
15347        self.begin_observation("syncular_subscribe")?;
15348        let registered = (|| {
15349            for request in requests {
15350                self.subscribe(request.id, request.table, request.scopes, request.params)?;
15351            }
15352            self.conn
15353                .execute_batch("RELEASE syncular_subscribe")
15354                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15355        })();
15356        if let Err(error) = registered {
15357            self.rollback_observation("syncular_subscribe");
15358            self.subs = prior_subs;
15359            return Err(error);
15360        }
15361        Ok(())
15362    }
15363
15364    pub fn unsubscribe(&mut self, id: &str) {
15365        if self.subs.iter().any(|s| s.id == id) {
15366            self.cancel_sync_round();
15367            self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
15368        }
15369        self.subs.retain(|s| s.id != id);
15370        let _ = self.conn.execute(
15371            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15372            rusqlite::params![id],
15373        );
15374    }
15375
15376    // -- windowed subscriptions (§4.8) ------------------------------------------
15377
15378    /// §4.8: set the live window units for a base — a value-sharded family
15379    /// of subscriptions, one per unit. Added units get fresh subscriptions
15380    /// (image-lane bootstrap on the next sync); removed units are
15381    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
15382    /// Idempotent; re-entry cancels any deferred eviction.
15383    pub fn set_window(
15384        &mut self,
15385        base: &WindowBase,
15386        units: &[String],
15387    ) -> Result<CommandEffects, String> {
15388        let table = self
15389            .schema
15390            .table(&base.table)
15391            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
15392        if table.scope_column(&base.variable).is_none() {
15393            return Err(format!(
15394                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
15395                base.table, base.variable
15396            ));
15397        }
15398        let base_key = window_base_key(base);
15399        let wanted: std::collections::HashSet<&String> = units.iter().collect();
15400        let live = self.load_window_units(&base_key);
15401        self.begin_observation("syncular_window")?;
15402        let mut batch = ChangeAccumulator::default();
15403        let mut changed = false;
15404
15405        let prior_subs = self.subs.clone();
15406        let widened = (|| {
15407            // Widen: units wanted but not live → fresh subscription + registry row.
15408            for unit in units {
15409                if live.iter().any(|(u, _)| u == unit) {
15410                    continue;
15411                }
15412                let sub_id = derive_sub_id(base, unit);
15413                self.delete_pending_evict(&sub_id)?;
15414                self.insert_window_unit(&base_key, unit, &sub_id)?;
15415                self.subscribe(
15416                    sub_id,
15417                    base.table.clone(),
15418                    unit_scopes(base, unit),
15419                    base.params.clone(),
15420                )?;
15421                batch.window(&base_key, &base.table, unit);
15422                changed = true;
15423            }
15424
15425            self.finish_observation("syncular_window", batch)
15426        })();
15427        if let Err(error) = widened {
15428            self.rollback_observation("syncular_window");
15429            self.subs = prior_subs;
15430            return Err(error);
15431        }
15432        for (unit, sub_id) in live {
15433            if wanted.contains(&unit) {
15434                continue;
15435            }
15436            self.rebuild_overlay_if_dirty()?;
15437            let prior_subs = self.subs.clone();
15438            self.begin_observation("syncular_window")?;
15439            let effective = self
15440                .subs
15441                .iter()
15442                .find(|sub| sub.id == sub_id)
15443                .and_then(|sub| sub.effective.clone())
15444                .unwrap_or_else(|| unit_scopes(base, &unit));
15445            let mut batch = ChangeAccumulator::default();
15446            self.record_scope_map(&mut batch, &base.table, &effective);
15447            batch.window(&base_key, &base.table, &unit);
15448            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15449            let remaining = match result {
15450                Ok(remaining) => remaining,
15451                Err(error) => {
15452                    self.rollback_observation("syncular_window");
15453                    self.subs = prior_subs;
15454                    return Err(error);
15455                }
15456            };
15457            if let Err(error) = self.finish_observation("syncular_window", batch) {
15458                self.rollback_observation("syncular_window");
15459                self.subs = prior_subs;
15460                return Err(error);
15461            }
15462            if remaining {
15463                self.drain_pending_evictions()?;
15464            }
15465            changed = true;
15466        }
15467        Ok(if changed {
15468            CommandEffects::interactive()
15469        } else {
15470            CommandEffects::none()
15471        })
15472    }
15473
15474    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
15475    /// the subset still bootstrap-pending. Registration alone is not
15476    /// completeness — a unit is pending until its subscription completes a
15477    /// bootstrap round (cursor advances past -1 with no resume token held).
15478    pub fn window_state(&self, base: &WindowBase) -> WindowState {
15479        let mut units = Vec::new();
15480        let mut pending = Vec::new();
15481        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15482            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15483                Some(sub) => {
15484                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15485                }
15486                None => true,
15487            };
15488            if is_pending {
15489                pending.push(unit.clone());
15490            }
15491            units.push(unit);
15492        }
15493        WindowState { units, pending }
15494    }
15495
15496    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15497        let mut stmt = match self
15498            .conn
15499            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15500        {
15501            Ok(stmt) => stmt,
15502            Err(_) => return Vec::new(),
15503        };
15504        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15505            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15506        });
15507        match rows {
15508            Ok(rows) => rows.filter_map(Result::ok).collect(),
15509            Err(_) => Vec::new(),
15510        }
15511    }
15512
15513    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15514        let mut stmt = match self.conn.prepare(
15515            "SELECT windows.base, windows.unit, subscriptions.tbl
15516               FROM _syncular_windows AS windows
15517               JOIN _syncular_subscriptions AS subscriptions
15518                 ON subscriptions.id = windows.sub_id
15519               ORDER BY windows.base, windows.unit",
15520        ) {
15521            Ok(stmt) => stmt,
15522            Err(_) => return Vec::new(),
15523        };
15524        let rows = stmt.query_map([], |row| {
15525            Ok((
15526                row.get::<_, String>(0)?,
15527                row.get::<_, String>(1)?,
15528                row.get::<_, String>(2)?,
15529            ))
15530        });
15531        match rows {
15532            Ok(rows) => rows.filter_map(Result::ok).collect(),
15533            Err(_) => Vec::new(),
15534        }
15535    }
15536
15537    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15538        self.conn
15539            .query_row(
15540                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15541                rusqlite::params![sub_id],
15542                |row| Ok((row.get(0)?, row.get(1)?)),
15543            )
15544            .ok()
15545    }
15546
15547    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15548        self.conn
15549            .execute(
15550                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15551                rusqlite::params![base_key, unit, sub_id],
15552            )
15553            .map(|_| ())
15554            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15555    }
15556
15557    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15558        self.conn
15559            .execute(
15560                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15561                rusqlite::params![base_key, unit],
15562            )
15563            .map(|_| ())
15564            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15565    }
15566
15567    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
15568    /// Deletes the unit's rows except those pinned by a pending outbox
15569    /// commit (E1); records a deferred eviction if any pin remains; discards
15570    /// the subscription's cursor/resume/effective-echo (E3) and version
15571    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
15572    /// nothing.
15573    fn evict_unit(
15574        &mut self,
15575        base_key: &str,
15576        base: &WindowBase,
15577        unit: &str,
15578        sub_id: &str,
15579    ) -> Result<bool, String> {
15580        let effective = self
15581            .subs
15582            .iter()
15583            .find(|s| s.id == sub_id)
15584            .and_then(|s| s.effective.clone())
15585            .unwrap_or_else(|| unit_scopes(base, unit));
15586        let pinned = self.pinned_row_ids(&base.table);
15587        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15588        self.delete_window_unit(base_key, unit)?;
15589        self.conn
15590            .execute(
15591                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15592                [sub_id],
15593            )
15594            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15595        self.subs.retain(|sub| sub.id != sub_id);
15596        self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
15597        if deferred {
15598            self.save_pending_evict(sub_id, &base.table, &effective)?;
15599        } else {
15600            self.delete_pending_evict(sub_id)?;
15601        }
15602        Ok(remaining)
15603    }
15604
15605    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
15606    /// primary keys. Returns (more unpinned work, any scoped rows remain).
15607    /// A missing scope-column mapping fails without deleting rows.
15608    fn evict_scope_rows(
15609        &self,
15610        table_name: &str,
15611        effective: &[(String, Vec<String>)],
15612        pinned: &std::collections::HashSet<String>,
15613    ) -> Result<(bool, bool), String> {
15614        if effective.is_empty() {
15615            return Ok((false, false));
15616        }
15617        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15618        let mut clauses = Vec::new();
15619        let mut params: Vec<SqlValue> = Vec::new();
15620        for (variable, values) in effective {
15621            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15622            if values.is_empty() {
15623                return Ok((false, false));
15624            }
15625            let holes = values
15626                .iter()
15627                .map(|v| {
15628                    params.push(SqlValue::Text(v.clone()));
15629                    "?"
15630                })
15631                .collect::<Vec<_>>()
15632                .join(", ");
15633            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15634        }
15635        let scope = clauses.join(" AND ");
15636        let scope_params = params.len();
15637        let pk = quote_ident(&table.primary_key);
15638        if !pinned.is_empty() {
15639            let holes = pinned
15640                .iter()
15641                .map(|id| {
15642                    params.push(SqlValue::Text(id.clone()));
15643                    "?"
15644                })
15645                .collect::<Vec<_>>()
15646                .join(", ");
15647            clauses.push(format!("{pk} NOT IN ({holes})"));
15648        }
15649        let predicate = clauses.join(" AND ");
15650        let base = base_table(table_name);
15651        let visible = visible_table(table_name);
15652        let mut ids: Vec<SqlValue> = Vec::new();
15653        for source in [&base, &visible] {
15654            let mut stmt = self
15655                .conn
15656                .prepare_cached(&format!(
15657                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15658                ))
15659                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15660            ids = stmt
15661                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
15662                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15663                .collect::<Result<_, _>>()
15664                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15665            if !ids.is_empty() {
15666                break;
15667            }
15668        }
15669        if !ids.is_empty() {
15670            for id in &ids {
15671                self.conn
15672                    .execute(
15673                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15674                        rusqlite::params![table_name, id],
15675                    )
15676                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15677            }
15678            let holes = vec!["?"; ids.len()].join(", ");
15679            for source in [&base, &visible] {
15680                self.conn
15681                    .execute(
15682                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15683                        rusqlite::params_from_iter(&ids),
15684                    )
15685                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15686            }
15687        }
15688        let mut remaining = false;
15689        let mut deferred = false;
15690        for source in [&base, &visible] {
15691            remaining |= self
15692                .conn
15693                .query_row(
15694                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15695                    rusqlite::params_from_iter(&params),
15696                    |row| row.get::<_, bool>(0),
15697                )
15698                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15699            deferred |= self
15700                .conn
15701                .query_row(
15702                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15703                    rusqlite::params_from_iter(&params[..scope_params]),
15704                    |row| row.get::<_, bool>(0),
15705                )
15706                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15707        }
15708        Ok((remaining, deferred))
15709    }
15710
15711    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
15712    fn drain_pending_evictions(&mut self) -> Result<(), String> {
15713        let pending = self.load_pending_evictions()?;
15714        if pending.is_empty() {
15715            return Ok(());
15716        }
15717        self.rebuild_overlay_if_dirty()?;
15718        for (sub_id, table_name, effective) in pending {
15719            if self.schema.table(&table_name).is_none() {
15720                self.delete_pending_evict(&sub_id)?;
15721                continue;
15722            }
15723            loop {
15724                std::thread::yield_now();
15725                self.begin_observation("syncular_evict")?;
15726                let pinned = self.pinned_row_ids(&table_name);
15727                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15728                let (remaining, deferred) = match result {
15729                    Ok(result) => result,
15730                    Err(error) => {
15731                        self.rollback_observation("syncular_evict");
15732                        return Err(error);
15733                    }
15734                };
15735                if !deferred {
15736                    if let Err(error) = self.delete_pending_evict(&sub_id) {
15737                        self.rollback_observation("syncular_evict");
15738                        return Err(error);
15739                    }
15740                }
15741                let mut batch = ChangeAccumulator::default();
15742                self.record_scope_map(&mut batch, &table_name, &effective);
15743                if let Err(error) = self.finish_observation("syncular_evict", batch) {
15744                    self.rollback_observation("syncular_evict");
15745                    return Err(error);
15746                }
15747                if !remaining {
15748                    break;
15749                }
15750            }
15751        }
15752        Ok(())
15753    }
15754
15755    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
15756    /// commit — rows that MUST NOT be evicted until the commit drains.
15757    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15758        let mut pinned = std::collections::HashSet::new();
15759        for commit in self
15760            .outbox
15761            .iter()
15762            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15763        {
15764            for op in &commit.ops {
15765                if op.table == table {
15766                    pinned.insert(op.row_id.clone());
15767                }
15768            }
15769        }
15770        pinned
15771    }
15772
15773    fn save_pending_evict(
15774        &self,
15775        sub_id: &str,
15776        table: &str,
15777        effective: &[(String, Vec<String>)],
15778    ) -> Result<(), String> {
15779        self.conn.execute(
15780            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15781            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15782        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15783    }
15784
15785    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15786        self.conn
15787            .execute(
15788                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15789                rusqlite::params![sub_id],
15790            )
15791            .map(|_| ())
15792            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15793    }
15794
15795    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15796        let mut stmt = self
15797            .conn
15798            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15799            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15800        let rows = stmt
15801            .query_map([], |row| {
15802                Ok((
15803                    row.get::<_, String>(0)?,
15804                    row.get::<_, String>(1)?,
15805                    row.get::<_, String>(2)?,
15806                ))
15807            })
15808            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15809        rows.map(|row| {
15810            let (sub_id, table, json) =
15811                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15812            let value: Value = serde_json::from_str(&json)
15813                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15814            let effective = json_to_scope_map(&value)
15815                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15816            Ok((sub_id, table, effective))
15817        })
15818        .collect()
15819    }
15820
15821    /// Select failed-intent retention; reload durable retained aggregates.
15822    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15823        self.retain_failed_commits = enabled;
15824        for (commit, _) in &self.failed_commits {
15825            for operation in &commit.ops {
15826                self.overlay_dirty.table(&operation.table);
15827            }
15828        }
15829        self.failed_commits = self.load_failed_commits()?;
15830        for (commit, _) in &self.failed_commits {
15831            for operation in &commit.ops {
15832                self.overlay_dirty.table(&operation.table);
15833            }
15834        }
15835        Ok(())
15836    }
15837
15838    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15839        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15840            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15841        let rows = statement
15842            .query_map([], |row| {
15843                Ok((
15844                    row.get::<_, String>(0)?,
15845                    row.get::<_, String>(1)?,
15846                    row.get::<_, String>(2)?,
15847                ))
15848            })
15849            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15850        rows.map(|row| {
15851            let (id, operations, initial) =
15852                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15853            let operations: Vec<CommitOperation> =
15854                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15855            let initial =
15856                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15857            Ok((
15858                OutboxCommit {
15859                    client_commit_id: id,
15860                    ops: operations
15861                        .into_iter()
15862                        .map(|op| OutboxOp {
15863                            upsert: op.op == "upsert",
15864                            table: op.table,
15865                            row_id: op.row_id,
15866                            base_version: op.base_version,
15867                            values: op.values,
15868                            insert: op.insert,
15869                        })
15870                        .collect(),
15871                },
15872                initial,
15873            ))
15874        })
15875        .collect()
15876    }
15877
15878    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15879        let mut initial = Map::new();
15880        for operation in operations {
15881            let table = self
15882                .schema
15883                .table(&operation.table)
15884                .ok_or_else(|| "sync.unknown_table".to_owned())?;
15885            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15886                Some(row) => Some(row),
15887                None => self.visible_row(table, &operation.row_id)?,
15888            };
15889            if let Some((values, _)) = initial_row {
15890                let mut intended: Map<String, Value> = table
15891                    .columns
15892                    .iter()
15893                    .zip(values.iter())
15894                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15895                    .collect();
15896                if let Some(patch) = &operation.values {
15897                    intended.extend(patch.clone());
15898                }
15899                initial.insert(
15900                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15901                        .map_err(|_| "sync.local_corrupt".to_owned())?,
15902                    Value::Object(intended),
15903                );
15904            }
15905        }
15906        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15907            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15908            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15909            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15910        self.failed_commits.push((
15911            OutboxCommit {
15912                client_commit_id: id.to_owned(),
15913                ops: operations.to_vec(),
15914            },
15915            initial,
15916        ));
15917        Ok(())
15918    }
15919
15920    fn drop_failed_commits(
15921        &mut self,
15922        matches: impl Fn(&str, &Map<String, Value>) -> bool,
15923    ) -> Result<Vec<OutboxCommit>, String> {
15924        let mut doomed = self.drop_acknowledged_rows(&matches)?;
15925        for (commit, initial) in &self.failed_commits {
15926            for operation in &commit.ops {
15927                let key =
15928                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15929                        .map_err(|_| "sync.local_corrupt".to_owned())?;
15930                if initial
15931                    .get(&key)
15932                    .and_then(Value::as_object)
15933                    .is_some_and(|values| matches(&operation.table, values))
15934                {
15935                    doomed.push(commit.clone());
15936                    break;
15937                }
15938            }
15939        }
15940        for commit in &doomed {
15941            self.conn
15942                .execute(
15943                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15944                    rusqlite::params![commit.client_commit_id],
15945                )
15946                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15947        }
15948        for commit in &doomed {
15949            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))?;
15950        }
15951        self.conflicts.retain(|record| {
15952            !doomed
15953                .iter()
15954                .any(|commit| commit.client_commit_id == record.client_commit_id)
15955        });
15956        self.rejections.retain(|record| {
15957            !doomed
15958                .iter()
15959                .any(|commit| commit.client_commit_id == record.client_commit_id)
15960        });
15961        self.failed_commits.retain(|(commit, _)| {
15962            !doomed
15963                .iter()
15964                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15965        });
15966        for commit in &doomed {
15967            for operation in &commit.ops {
15968                self.overlay_dirty.table(&operation.table);
15969            }
15970        }
15971        Ok(doomed)
15972    }
15973
15974    /// Record one commit of local mutations. The returned commit ID is the
15975    /// §2.3 idempotency key. Failures are structured so callers read
15976    /// `code`/`message`/`details` instead of parsing a string prefix.
15977    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15978        let prior = self.storage_failure.borrow_mut().take();
15979        let outcome = (|| -> Result<String, String> {
15980            if mutations.is_empty() {
15981                return Err(
15982                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15983                        .to_owned(),
15984                );
15985            }
15986            let mut ops = Vec::with_capacity(mutations.len());
15987            for mutation in mutations {
15988                match mutation {
15989                    Mutation::Patch {
15990                        table,
15991                        values,
15992                        base_version,
15993                    } => {
15994                        let schema_table = self
15995                            .schema
15996                            .table(&table)
15997                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
15998                        let values = normalize_values_casing(schema_table, values)?;
15999                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
16000                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
16001                    }
16002                    Mutation::Upsert {
16003                        table,
16004                        values,
16005                        base_version,
16006                    } => {
16007                        let schema_table = self.schema.table(&table).ok_or_else(|| {
16008                            format!("sync.unknown_table: unknown table {table:?}")
16009                        })?;
16010                        // §5: value keys are accepted in snake_case AND the
16011                        // generated row types' camelCase; normalize to SQL truth
16012                        // before the pk lookup / codec see them.
16013                        let values = normalize_values_casing(schema_table, values)?;
16014                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
16015                        // §6.7 `mutate` marks every column present: fill missing
16016                        // nullable columns with NULL before the payload is built.
16017                        let values = full_row_values(schema_table, values)?;
16018                        // §5.11: validate the payload encodes with the current
16019                        // codec. A key-selection or unknown-key failure is a
16020                        // durable rejection at the push seam (§10.3), never an
16021                        // author-time error. Full-row `mutate` presents every
16022                        // column, so no stored fallback is needed.
16023                        self.validate_author_encode(schema_table, &row_id, &values)?;
16024                        // §7.1 insert intent: the row is absent from the
16025                        // visible replica at call time, before this batch
16026                        // applies anything.
16027                        let insert = !self.visible_row_exists(schema_table, &row_id)?;
16028                        ops.push(OutboxOp {
16029                            upsert: true,
16030                            table,
16031                            row_id,
16032                            base_version,
16033                            values: Some(values),
16034                            insert,
16035                        });
16036                    }
16037                    Mutation::Delete {
16038                        table,
16039                        row_id,
16040                        base_version,
16041                    } => {
16042                        if self.schema.table(&table).is_none() {
16043                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
16044                        }
16045                        ops.push(OutboxOp {
16046                            upsert: false,
16047                            table,
16048                            row_id,
16049                            base_version,
16050                            values: None,
16051                            insert: false,
16052                        });
16053                    }
16054                }
16055            }
16056            self.record_outbox_commit(ops)
16057        })();
16058        self.finish_authoring(prior, outcome)
16059    }
16060
16061    /// Scope the retained `storage_failure` diagnostic to this call so an
16062    /// earlier operation's failure cannot classify a new one, and surface the
16063    /// call's own classified failure with its details. A fresh classified
16064    /// failure replaces the retained diagnostic; otherwise the earlier one is
16065    /// kept for the next sync.
16066    fn finish_authoring(
16067        &self,
16068        prior: Option<QueryReadFailure>,
16069        outcome: Result<String, String>,
16070    ) -> Result<String, ClientError> {
16071        let fresh = self.storage_failure.borrow_mut().take();
16072        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
16073        match outcome {
16074            Ok(id) => Ok(id),
16075            Err(message) => Err(fresh
16076                .map(ClientError::from)
16077                .unwrap_or_else(|| ClientError::from(message))),
16078        }
16079    }
16080
16081    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
16082        let commit = OutboxCommit {
16083            client_commit_id: uuid::Uuid::new_v4().to_string(),
16084            ops,
16085        };
16086        self.begin_observation("syncular_mutation")?;
16087        let mut batch = ChangeAccumulator::default();
16088        for op in &commit.ops {
16089            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
16090            if let Some(values) = &op.values {
16091                if let Some(table) = self.schema.table(&op.table) {
16092                    for scope in &table.scope_variables {
16093                        if let Some(Value::String(value)) = values.get(&scope.column) {
16094                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
16095                            precise = true;
16096                        }
16097                    }
16098                }
16099            }
16100            if !precise {
16101                batch.table(&op.table);
16102            }
16103        }
16104        if let Err(error) = self.persist_outbox_insert(&commit) {
16105            self.rollback_observation("syncular_mutation");
16106            return Err(error);
16107        }
16108        let was_dirty = self.overlay_dirty.snapshot();
16109        // A clean overlay contains the FIFO fold of every previous commit, so
16110        // the new commit applies over it. §7.1: an operation that does not
16111        // apply (a secondary unique collision) fails the whole commit; the
16112        // savepoint rollback removes the outbox entry and every visible write.
16113        if let Err(error) = self.rebuild_overlay_if_dirty() {
16114            self.rollback_observation("syncular_mutation");
16115            self.overlay_dirty.restore(was_dirty.clone());
16116            return Err(error);
16117        }
16118        if let Some(error) = commit
16119            .ops
16120            .iter()
16121            .find_map(|op| self.apply_outbox_op(op).err())
16122        {
16123            self.rollback_observation("syncular_mutation");
16124            self.overlay_dirty.restore(was_dirty.clone());
16125            return Err(error);
16126        }
16127        let id = commit.client_commit_id.clone();
16128        self.outbox.push(commit);
16129        batch.status = true;
16130        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
16131            self.rollback_observation("syncular_mutation");
16132            self.outbox.pop();
16133            self.overlay_dirty.restore(was_dirty.clone());
16134            return Err(error);
16135        }
16136        Ok(id)
16137    }
16138
16139    /// Record one §6.1 sparse upsert: the primary key plus the supplied
16140    /// non-scope columns are present; every other column is absent and stays
16141    /// untouched on the server and in the local overlay. An absent local base
16142    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
16143    /// Failures are structured so callers read `code`/`message`/`details`.
16144    pub fn patch(
16145        &mut self,
16146        table: &str,
16147        row_id: &str,
16148        partial: Map<String, Value>,
16149        base_version: Option<i64>,
16150    ) -> Result<String, ClientError> {
16151        let prior = self.storage_failure.borrow_mut().take();
16152        let outcome = self.perform_patch(table, row_id, partial, base_version);
16153        self.finish_authoring(prior, outcome)
16154    }
16155
16156    /// The unscoped patch body. Public authoring entry points scope
16157    /// `storage_failure` around the whole operation, including any pre-read.
16158    fn perform_patch(
16159        &mut self,
16160        table: &str,
16161        row_id: &str,
16162        partial: Map<String, Value>,
16163        base_version: Option<i64>,
16164    ) -> Result<String, String> {
16165        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
16166        self.record_outbox_commit(vec![operation])
16167    }
16168
16169    fn prepare_patch(
16170        &self,
16171        table: &str,
16172        row_id: &str,
16173        partial: Map<String, Value>,
16174        base_version: Option<i64>,
16175    ) -> Result<OutboxOp, String> {
16176        let schema_table = self
16177            .schema
16178            .table(table)
16179            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
16180        let partial = normalize_values_casing(schema_table, partial)?;
16181        if let Some(pk) = partial.get(&schema_table.primary_key) {
16182            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
16183                return Err(format!(
16184                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
16185                ));
16186            }
16187        }
16188        if !self.visible_row_exists(schema_table, row_id)? {
16189            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
16190        }
16191        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
16192        // server accepts a present scope column whose value equals the
16193        // stored row and applies it as a no-op, so a decoded envelope
16194        // round-trips. The local row is the only value the client can prove
16195        // equality against; when the row is absent locally the client cannot
16196        // prove equality against the server's stored row and fails closed.
16197        // A primary key that is also a scope column needs no stored-row
16198        // read: the primary key in a sparse payload is by construction the
16199        // row id being patched (§6.1), so its value is proven equal already.
16200        for scope in &schema_table.scope_variables {
16201            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
16202                continue;
16203            }
16204            let Some(column) = schema_table
16205                .columns
16206                .iter()
16207                .find(|column| column.name == scope.column)
16208            else {
16209                continue;
16210            };
16211            let stored = self
16212                .conn
16213                .query_row(
16214                    &format!(
16215                        "SELECT {} FROM {} WHERE {}",
16216                        quote_ident(&scope.column),
16217                        visible_table(&schema_table.name),
16218                        row_id_predicate(schema_table)
16219                    ),
16220                    rusqlite::params![row_id],
16221                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
16222                )
16223                .optional()
16224                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16225            // Decode the supplied value through the same column-type path the
16226            // rest of the patch encoding uses, so a value that only equals
16227            // the stored one in another number representation still compares
16228            // equal.
16229            let incoming = json_to_column_value(column, partial.get(&scope.column))
16230                .ok()
16231                .map(|value| column_value_to_json(&value));
16232            // A stored blob never equals a patch value, matching the server's
16233            // scope-column comparison (§3.4): bytes are unstorable as scope
16234            // values.
16235            let equal = stored.as_ref().is_some_and(|stored| {
16236                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
16237            });
16238            if !equal {
16239                return Err(format!(
16240                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
16241                    scope.column
16242                ));
16243            }
16244        }
16245        let mut values = partial;
16246        // Presence-set semantics: a proven-equal scope column is dropped, so
16247        // the patch leaves the stored value untouched (§6.2).
16248        for scope in &schema_table.scope_variables {
16249            values.remove(&scope.column);
16250        }
16251        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
16252            // §2.4: the rowId of an integer key is its canonical decimal form.
16253            ColumnType::Integer => match row_id.parse::<i64>() {
16254                Ok(v)
16255                    if v.to_string() == row_id
16256                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
16257                            .contains(&v) =>
16258                {
16259                    Value::from(v)
16260                }
16261                _ => {
16262                    return Err(format!(
16263                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
16264                ))
16265                }
16266            },
16267            ColumnType::Boolean => match row_id {
16268                "true" => Value::Bool(true),
16269                "false" => Value::Bool(false),
16270                _ => {
16271                    return Err(format!(
16272                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
16273                ))
16274                }
16275            },
16276            _ => Value::from(row_id.to_owned()),
16277        };
16278        values.insert(schema_table.primary_key.clone(), pk_value);
16279        // §5.11: structural validation only; an absent key-id selector is
16280        // resolved at the push seam from the stored local row, and a
16281        // key-selection or unknown-key failure becomes a durable
16282        // `client.encrypt_failed` rejection there (§10.3), never an
16283        // author-time error.
16284        self.validate_author_encode(schema_table, row_id, &values)?;
16285        Ok(OutboxOp {
16286            upsert: true,
16287            table: table.to_owned(),
16288            row_id: row_id.to_owned(),
16289            base_version,
16290            values: Some(values),
16291            insert: false,
16292        })
16293    }
16294
16295    /// §5.11 key-id fallback: the stored local row's selector value as a
16296    /// positional row (only the selector slot filled). Returns None when no
16297    /// selector is configured, the row is locally absent, or the stored
16298    /// value is not a usable key id.
16299    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
16300        let selector = self.encryption.key_id_columns.get(&table.name)?;
16301        let index = table
16302            .columns
16303            .iter()
16304            .position(|column| &column.name == selector)?;
16305        let sql = format!(
16306            "SELECT {} FROM {} WHERE {}",
16307            quote_ident(selector),
16308            visible_table(&table.name),
16309            row_id_predicate(table)
16310        );
16311        let key: Option<String> = self
16312            .conn
16313            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
16314            .ok()?;
16315        let key = key.filter(|key| !key.is_empty())?;
16316        let mut fallback: Row = vec![None; table.columns.len()];
16317        fallback[index] = Some(ColumnValue::String(key));
16318        Some(fallback)
16319    }
16320
16321    /// §5.11/§10.3 author-time encode check: a structural failure (a
16322    /// non-nullable NULL, a value the codec rejects) is an author-time
16323    /// error; a key-selection or unknown-key failure is deferred to the push
16324    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
16325    fn validate_author_encode(
16326        &self,
16327        table: &TableSchema,
16328        row_id: &str,
16329        values: &Map<String, Value>,
16330    ) -> Result<(), String> {
16331        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
16332            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
16333            result => result.map(|_| ()),
16334        }
16335    }
16336
16337    /// §5.11/§10.3: drop every pending commit whose push encode fails with
16338    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
16339    /// raise one durable rejection per commit. `sync()` never aborts for an
16340    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
16341    /// undoing the dropped commit's purely-optimistic rows.
16342    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
16343    /// (an envelope or a type an earlier version accepted). `with_encryption`
16344    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
16345    /// the startup boundary leaves those to the send seam, where hosts configure
16346    /// keys after open.
16347    /// Recovery runs as one observation. An enclosing observation (the guarded
16348    /// schema reset) passes its `batch`, so the rejection, outcome, and table
16349    /// domains join that change event instead of publishing a second one.
16350    fn drop_unencodable_outbox(
16351        &mut self,
16352        with_encryption: bool,
16353        batch: Option<&mut ChangeAccumulator>,
16354    ) -> Result<bool, String> {
16355        let failures = self
16356            .outbox
16357            .iter()
16358            .filter_map(|commit| {
16359                commit
16360                    .ops
16361                    .iter()
16362                    .find_map(|op| {
16363                        let table = self.schema.table(&op.table).filter(|_| op.upsert)?;
16364                        // An insert lacking a non-nullable column is left to
16365                        // `drop_incompatible_outbox` (§7.4.4).
16366                        let values = op.presented_values(table)?;
16367                        let fallback = self.stored_key_fallback(table, &op.row_id);
16368                        match encode_sparse_row_json(
16369                            table,
16370                            &op.row_id,
16371                            &values,
16372                            &self.encryption,
16373                            fallback.as_ref(),
16374                        ) {
16375                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
16376                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
16377                            }
16378                            // §7.1/§7.4.4: a value the current codec refuses was
16379                            // persisted by a version that accepted it; it leaves
16380                            // through the incompatible-outbox path, so the FIFO
16381                            // queue never wedges.
16382                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
16383                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
16384                                OUTBOX_INCOMPATIBLE_CODE,
16385                            )),
16386                            _ => None,
16387                        }
16388                    })
16389                    .map(|(error, code)| (commit.clone(), error, code))
16390            })
16391            .collect::<Vec<_>>();
16392        if failures.is_empty() {
16393            return Ok(false);
16394        }
16395        // §7.4.4: one observation covers the durable outcome writes, the outbox
16396        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
16397        // leaves the replica and the client unchanged, a successful recovery
16398        // publishes one batch with the rejection, outcome, and table domains, and
16399        // an ordinary open with nothing to recover publishes nothing.
16400        let prior_outbox = self.outbox.clone();
16401        let prior_rejections = self.rejections.clone();
16402        // §5.11 retention and a self-published observation mutate two more
16403        // in-memory fields before this transaction ends, so the rollback below
16404        // restores them with the rest.
16405        let prior_failed_commits = self.failed_commits.clone();
16406        let prior_changes = self.change_queue.len();
16407        let prior_intents = self.sync_intent_queue.len();
16408        let prior_last_change = self.last_change.clone();
16409        let prior_overlay_dirty = self.overlay_dirty.snapshot();
16410        let owns_observation = batch.is_none();
16411        let owns_transaction = self.conn.is_autocommit();
16412        let mut domains = ChangeAccumulator::default();
16413        if owns_observation {
16414            self.begin_observation("syncular_value_recovery")?;
16415        }
16416        let dropped = (|| -> Result<(), String> {
16417            for (commit, message, code) in failures {
16418                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16419                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16420                }
16421                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16422                let rejection = RejectionRecord {
16423                    client_commit_id: commit.client_commit_id.clone(),
16424                    op_index: 0,
16425                    code: code.to_owned(),
16426                    message,
16427                    retryable: false,
16428                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16429                        field_paths: None,
16430                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16431                        required_action: None,
16432                        references: None,
16433                    }),
16434                    operation: representable
16435                        .then(|| commit.ops.first())
16436                        .flatten()
16437                        .map(CommitOperation::from),
16438                };
16439                self.persist_commit_outcome(
16440                    &commit.client_commit_id,
16441                    CommitOutcomeStatus::Rejected,
16442                    &[CommitOperationOutcome::Error {
16443                        rejection: rejection.clone(),
16444                    }],
16445                    representable.then_some(commit.ops.as_slice()),
16446                )?;
16447                self.delete_outbox_persisted(&commit.client_commit_id)?;
16448                self.outbox
16449                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16450                self.rejections.push(rejection);
16451                for operation in &commit.ops {
16452                    domains.table(operation.table.as_str());
16453                }
16454            }
16455            domains.status = true;
16456            domains.rejections = true;
16457            domains.outcomes = true;
16458            self.prune_commit_outcomes()?;
16459            // The visible projection is re-derived from the surviving base + outbox.
16460            self.overlay_dirty.set(true);
16461            self.rebuild_overlay()?;
16462            Ok(())
16463        })();
16464        let result = match batch {
16465            Some(batch) => {
16466                if dropped.is_ok() {
16467                    for table in domains.tables.keys() {
16468                        batch.table(table.as_str());
16469                    }
16470                    batch.status |= domains.status;
16471                    batch.rejections |= domains.rejections;
16472                    batch.outcomes |= domains.outcomes;
16473                }
16474                dropped
16475            }
16476            None => {
16477                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16478            }
16479        };
16480        if let Err(error) = result {
16481            if owns_observation {
16482                if owns_transaction {
16483                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16484                        let mut failure = self.storage_failure.borrow_mut();
16485                        let failure =
16486                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16487                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16488                    }
16489                } else {
16490                    self.rollback_observation("syncular_value_recovery");
16491                }
16492            }
16493            self.outbox = prior_outbox;
16494            self.rejections = prior_rejections;
16495            self.failed_commits = prior_failed_commits;
16496            self.change_queue.truncate(prior_changes);
16497            self.sync_intent_queue.truncate(prior_intents);
16498            self.last_change = prior_last_change;
16499            self.overlay_dirty.restore(prior_overlay_dirty);
16500            return Err(error);
16501        }
16502        Ok(true)
16503    }
16504
16505    pub fn pending_commit_ids(&self) -> Vec<String> {
16506        self.outbox
16507            .iter()
16508            .map(|c| c.client_commit_id.clone())
16509            .collect()
16510    }
16511
16512    /// §6.1: the sparse push payload bytes of every pending upsert operation,
16513    /// FIFO — exactly what the next push round would put on the wire. Used by
16514    /// conformance B.21(h) to pin cross-core byte identity.
16515    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16516        let mut payloads = Vec::new();
16517        for commit in &self.outbox {
16518            for op in &commit.ops {
16519                let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
16520                    continue;
16521                };
16522                let Some(values) = op.presented_values(table) else {
16523                    continue;
16524                };
16525                let fallback = self.stored_key_fallback(table, &op.row_id);
16526                if let Ok(payload) = encode_sparse_row_json(
16527                    table,
16528                    &op.row_id,
16529                    &values,
16530                    &self.encryption,
16531                    fallback.as_ref(),
16532                ) {
16533                    payloads.push(payload);
16534                }
16535            }
16536        }
16537        payloads
16538    }
16539
16540    pub fn conflicts(&self) -> &[ConflictRecord] {
16541        &self.conflicts
16542    }
16543
16544    pub fn rejections(&self) -> &[RejectionRecord] {
16545        &self.rejections
16546    }
16547
16548    pub fn resolve_commit_outcome(
16549        &mut self,
16550        input: ResolveCommitOutcomeInput,
16551    ) -> Result<CommitOutcome, String> {
16552        let current = self
16553            .commit_outcome(&input.client_commit_id)?
16554            .ok_or_else(|| {
16555                format!(
16556                    "sync.outcome_not_found: no durable outcome exists for {:?}",
16557                    input.client_commit_id
16558                )
16559            })?;
16560        if current.resolution != CommitOutcomeResolution::Active {
16561            return Ok(current);
16562        }
16563        if input.resolution == CommitOutcomeResolution::Active {
16564            return Err("sync.invalid_request: resolution must leave active state".to_owned());
16565        }
16566        match input.resolution {
16567            CommitOutcomeResolution::Superseded => {
16568                let replacement = input
16569                    .replacement_client_commit_id
16570                    .as_deref()
16571                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16572                    .ok_or_else(|| {
16573                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16574                            .to_owned()
16575                    })?;
16576                let _ = replacement;
16577            }
16578            _ if input.replacement_client_commit_id.is_some() => {
16579                return Err(
16580                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16581                        .to_owned(),
16582                );
16583            }
16584            _ => {}
16585        }
16586        let allowed = match current.status {
16587            CommitOutcomeStatus::Conflict => matches!(
16588                input.resolution,
16589                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16590            ),
16591            CommitOutcomeStatus::Rejected => {
16592                matches!(
16593                    input.resolution,
16594                    CommitOutcomeResolution::Superseded
16595                        | CommitOutcomeResolution::ResolvedKeepServer
16596                )
16597            }
16598            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16599                input.resolution == CommitOutcomeResolution::Dismissed
16600            }
16601        };
16602        if !allowed {
16603            return Err(format!(
16604                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16605                input.resolution, current.status
16606            ));
16607        }
16608
16609        self.begin_observation("syncular_outcome_resolution")?;
16610        let previous_failed = self.failed_commits.clone();
16611        let previous_conflicts = self.conflicts.clone();
16612        let previous_rejections = self.rejections.clone();
16613        let result = (|| {
16614            self.conn
16615                .execute(
16616                    "UPDATE _syncular_commit_outcomes
16617                        SET resolution = ?1, resolved_at_ms = ?2,
16618                            replacement_client_commit_id = ?3
16619                      WHERE client_commit_id = ?4 AND resolution = 'active'",
16620                    rusqlite::params![
16621                        Self::outcome_resolution_name(input.resolution),
16622                        self.clock_now_ms(),
16623                        input.replacement_client_commit_id,
16624                        input.client_commit_id
16625                    ],
16626                )
16627                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16628            let resolved = self
16629                .commit_outcome(&current.client_commit_id)?
16630                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16631            self.conn
16632                .execute(
16633                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16634                    rusqlite::params![input.client_commit_id],
16635                )
16636                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16637            for (commit, _) in &self.failed_commits {
16638                if commit.client_commit_id == input.client_commit_id {
16639                    for operation in &commit.ops {
16640                        self.overlay_dirty.table(&operation.table);
16641                    }
16642                }
16643            }
16644            self.failed_commits
16645                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16646            self.prune_commit_outcomes()?;
16647            Ok(resolved)
16648        })();
16649        let resolved = match result {
16650            Ok(outcome) => outcome,
16651            Err(error) => {
16652                self.rollback_observation("syncular_outcome_resolution");
16653                self.failed_commits = previous_failed;
16654                self.conflicts = previous_conflicts;
16655                self.rejections = previous_rejections;
16656                return Err(error);
16657            }
16658        };
16659        self.conflicts
16660            .retain(|record| record.client_commit_id != current.client_commit_id);
16661        self.rejections
16662            .retain(|record| record.client_commit_id != current.client_commit_id);
16663        let mut batch = ChangeAccumulator {
16664            conflicts: current.status == CommitOutcomeStatus::Conflict,
16665            rejections: current.status == CommitOutcomeStatus::Rejected,
16666            outcomes: true,
16667            ..ChangeAccumulator::default()
16668        };
16669        if let Some(operations) = &current.operations {
16670            for operation in operations {
16671                batch.table(&operation.table);
16672            }
16673        }
16674        if let Err(error) = self
16675            .rebuild_overlay_if_dirty()
16676            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16677        {
16678            self.rollback_observation("syncular_outcome_resolution");
16679            self.failed_commits = previous_failed;
16680            self.conflicts = previous_conflicts;
16681            self.rejections = previous_rejections;
16682            return Err(error);
16683        }
16684        Ok(resolved)
16685    }
16686
16687    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16688        self.schema_floor.as_ref()
16689    }
16690
16691    /// §7.3.5: the client's opaque auth-lease state, if any.
16692    pub fn lease_state(&self) -> Option<&LeaseState> {
16693        self.lease_state.as_ref()
16694    }
16695
16696    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
16697    /// the two lease codes set it; other errors leave leaseState untouched.
16698    fn record_lease_error(&mut self, code: &str) {
16699        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16700            return;
16701        }
16702        let mut next = self.lease_state.clone().unwrap_or_default();
16703        next.error_code = Some(code.to_owned());
16704        self.set_lease_state(Some(next));
16705    }
16706
16707    fn set_lease_state(&mut self, next: Option<LeaseState>) {
16708        if self.lease_state == next {
16709            return;
16710        }
16711        if self.begin_observation("syncular_lease").is_err() {
16712            return;
16713        }
16714        self.lease_state = next;
16715        if let Some(lease) = &self.lease_state {
16716            if let Ok(json) = serde_json::to_string(lease) {
16717                self.set_meta(LEASE_STATE_KEY, &json);
16718            }
16719        } else {
16720            self.delete_meta(LEASE_STATE_KEY);
16721        }
16722        let batch = ChangeAccumulator {
16723            status: true,
16724            ..ChangeAccumulator::default()
16725        };
16726        if self.finish_observation("syncular_lease", batch).is_err() {
16727            self.rollback_observation("syncular_lease");
16728        }
16729    }
16730
16731    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16732        if self.schema_floor == next {
16733            return;
16734        }
16735        if self.begin_observation("syncular_schema_floor").is_err() {
16736            return;
16737        }
16738        self.schema_floor = next;
16739        self.stopped = self.schema_floor.is_some();
16740        if let Some(floor) = &self.schema_floor {
16741            if let Ok(json) = serde_json::to_string(floor) {
16742                self.set_meta(SCHEMA_FLOOR_KEY, &json);
16743            }
16744        } else {
16745            self.delete_meta(SCHEMA_FLOOR_KEY);
16746        }
16747        let batch = ChangeAccumulator {
16748            status: true,
16749            ..ChangeAccumulator::default()
16750        };
16751        if self
16752            .finish_observation("syncular_schema_floor", batch)
16753            .is_err()
16754        {
16755            self.rollback_observation("syncular_schema_floor");
16756        }
16757    }
16758
16759    fn set_upgrading(&mut self, value: bool) {
16760        if self.upgrading == value {
16761            return;
16762        }
16763        if self.begin_observation("syncular_upgrading").is_err() {
16764            return;
16765        }
16766        self.upgrading = value;
16767        let batch = ChangeAccumulator {
16768            status: true,
16769            ..ChangeAccumulator::default()
16770        };
16771        if self
16772            .finish_observation("syncular_upgrading", batch)
16773            .is_err()
16774        {
16775            self.rollback_observation("syncular_upgrading");
16776        }
16777    }
16778
16779    pub fn sync_needed(&self) -> bool {
16780        self.sync_needed
16781    }
16782
16783    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16784        let sub = self.subs.iter().find(|s| s.id == id)?;
16785        Some(SubscriptionStateView {
16786            id: sub.id.clone(),
16787            table: sub.table.clone(),
16788            status: sub.state.name().to_owned(),
16789            cursor: sub.cursor,
16790            has_resume_token: sub.bootstrap_state.is_some(),
16791            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16792            reason_code: sub.reason_code.clone(),
16793        })
16794    }
16795
16796    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16797        let schema_table = self
16798            .schema
16799            .table(table)
16800            .ok_or_else(|| format!("unknown table {table:?}"))?;
16801        let sql = format!(
16802            "SELECT * FROM {} ORDER BY {} ASC",
16803            visible_table(table),
16804            quote_ident(&schema_table.primary_key)
16805        );
16806        let mut stmt = self
16807            .conn
16808            .prepare(&sql)
16809            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16810        let mut rows = stmt
16811            .query([])
16812            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16813        let mut out = Vec::new();
16814        while let Some(row) = rows
16815            .next()
16816            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16817        {
16818            let mut values = Map::new();
16819            for (i, column) in schema_table.columns.iter().enumerate() {
16820                let value = row
16821                    .get_ref(i)
16822                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16823                values.insert(column.name.clone(), sql_ref_to_json(column, value));
16824            }
16825            let version: i64 = row
16826                .get(schema_table.columns.len())
16827                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16828            let row_id = match values.get(&schema_table.primary_key) {
16829                Some(Value::String(s)) => s.clone(),
16830                Some(Value::Number(n)) => n.to_string(),
16831                Some(Value::Bool(b)) => b.to_string(),
16832                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16833            };
16834            out.push(RowState {
16835                row_id,
16836                version,
16837                values,
16838            });
16839        }
16840        Ok(out)
16841    }
16842
16843    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
16844    //
16845    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
16846    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
16847    // the op with `yrs`, re-encodes the whole doc state, and records a
16848    // baseVersion-less sparse upsert carrying only the primary key and the
16849    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
16850    // the overlay re-materializes the edit immediately (optimistic apply,
16851    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
16852    // Byte-compatible with `@syncular/crdt-yjs`.
16853
16854    /// The current stored value of a `crdt` column for one row — the visible
16855    /// (optimistic) bytes, or `None` when the row is absent or the column is
16856    /// NULL (the empty document, §5.10.1). Errors if the column is not a
16857    /// `crdt` column (guards the app against a typo'd column name).
16858    #[cfg(feature = "crdt-yjs")]
16859    fn crdt_column_bytes(
16860        &self,
16861        table: &str,
16862        row_id: &str,
16863        column: &str,
16864    ) -> Result<Option<Vec<u8>>, String> {
16865        let schema_table = self
16866            .schema
16867            .table(table)
16868            .ok_or_else(|| format!("unknown table {table:?}"))?;
16869        let col = schema_table
16870            .columns
16871            .iter()
16872            .find(|c| c.name == column)
16873            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16874        if col.ty != ColumnType::Crdt {
16875            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16876        }
16877        let sql = format!(
16878            "SELECT {} FROM {} WHERE {}",
16879            quote_ident(column),
16880            visible_table(table),
16881            row_id_predicate(schema_table)
16882        );
16883        let bytes: Option<Vec<u8>> = self
16884            .conn
16885            .prepare_cached(&sql)
16886            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16887            .query_row(rusqlite::params![row_id], |row| {
16888                row.get::<_, Option<Vec<u8>>>(0)
16889            })
16890            .map_err(|e| match e {
16891                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16892                other => Self::sqlite_failure(&self.storage_failure, other),
16893            })?;
16894        Ok(bytes)
16895    }
16896
16897    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
16898    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
16899    /// An absent row / NULL column is the empty document (empty string).
16900    #[cfg(feature = "crdt-yjs")]
16901    pub fn crdt_text(
16902        &self,
16903        table: &str,
16904        row_id: &str,
16905        column: &str,
16906        name: &str,
16907    ) -> Result<String, String> {
16908        let bytes = self
16909            .crdt_column_bytes(table, row_id, column)?
16910            .unwrap_or_default();
16911        crate::crdt::text(&bytes, name)
16912    }
16913
16914    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
16915    /// record the resulting crdt-only sparse operation. Returns the enqueued
16916    /// `clientCommitId`.
16917    #[cfg(feature = "crdt-yjs")]
16918    pub fn crdt_insert_text(
16919        &mut self,
16920        table: &str,
16921        row_id: &str,
16922        column: &str,
16923        name: &str,
16924        index: u32,
16925        value: &str,
16926    ) -> Result<String, ClientError> {
16927        let prior = self.storage_failure.borrow_mut().take();
16928        let outcome = (|| -> Result<String, String> {
16929            let current = self
16930                .crdt_column_bytes(table, row_id, column)?
16931                .unwrap_or_default();
16932            let update = crate::crdt::insert_text(&current, name, index, value)?;
16933            self.perform_crdt_push_update(table, row_id, column, &update)
16934        })();
16935        self.finish_authoring(prior, outcome)
16936    }
16937
16938    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
16939    /// record the resulting crdt-only sparse operation. Returns the enqueued
16940    /// `clientCommitId`.
16941    #[cfg(feature = "crdt-yjs")]
16942    pub fn crdt_delete_text(
16943        &mut self,
16944        table: &str,
16945        row_id: &str,
16946        column: &str,
16947        name: &str,
16948        index: u32,
16949        len: u32,
16950    ) -> Result<String, ClientError> {
16951        let prior = self.storage_failure.borrow_mut().take();
16952        let outcome = (|| -> Result<String, String> {
16953            let current = self
16954                .crdt_column_bytes(table, row_id, column)?
16955                .unwrap_or_default();
16956            let update = crate::crdt::delete_text(&current, name, index, len)?;
16957            self.perform_crdt_push_update(table, row_id, column, &update)
16958        })();
16959        self.finish_authoring(prior, outcome)
16960    }
16961
16962    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
16963    /// `crdt` column's current state and record the resulting crdt-only sparse
16964    /// operation. The app authored the update with its own `yrs` model.
16965    /// Returns the enqueued `clientCommitId`.
16966    #[cfg(feature = "crdt-yjs")]
16967    pub fn crdt_apply_update(
16968        &mut self,
16969        table: &str,
16970        row_id: &str,
16971        column: &str,
16972        update: &[u8],
16973    ) -> Result<String, ClientError> {
16974        let prior = self.storage_failure.borrow_mut().take();
16975        let outcome = (|| -> Result<String, String> {
16976            let current = self
16977                .crdt_column_bytes(table, row_id, column)?
16978                .unwrap_or_default();
16979            let next = crate::crdt::apply_update(&current, update)?;
16980            self.perform_crdt_push_update(table, row_id, column, &next)
16981        })();
16982        self.finish_authoring(prior, outcome)
16983    }
16984
16985    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
16986    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
16987    /// enqueue it. The server merges the crdt column and leaves every other
16988    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
16989    /// clobber a concurrent edit to another column. A locally absent row
16990    /// records the same partial operation; the server answers per §6.2.
16991    #[cfg(feature = "crdt-yjs")]
16992    fn perform_crdt_push_update(
16993        &mut self,
16994        table: &str,
16995        row_id: &str,
16996        column: &str,
16997        crdt_bytes: &[u8],
16998    ) -> Result<String, String> {
16999        let mut bytes_obj = Map::new();
17000        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
17001        let mut partial = Map::new();
17002        partial.insert(column.to_owned(), Value::Object(bytes_obj));
17003        self.perform_patch(table, row_id, partial, None)
17004    }
17005
17006    /// Run an arbitrary read-only SQL query against the local database and
17007    /// return each row as a `column-name → JSON value` map. This is the seam
17008    /// the React `useSyncQuery` live-query API needs (it takes app-authored
17009    /// SQL over the visible tables/views, not a fixed table read like
17010    /// [`read_rows`]).
17011    ///
17012    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
17013    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
17014    /// same envelope the command surface uses everywhere else. Output BLOB
17015    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
17016    ///
17017    /// The result column typing is dynamic (SQLite's stored affinity), because
17018    /// arbitrary SQL can alias, join, and compute — there is no schema column
17019    /// to consult per output cell, unlike [`read_rows`].
17020    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
17021        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
17022    }
17023
17024    /// Repository benchmark and conformance access, present only with `bench-internals`.
17025    #[cfg(feature = "bench-internals")]
17026    #[doc(hidden)]
17027    pub fn benchmark_connection(&mut self) -> &mut Connection {
17028        &mut self.conn
17029    }
17030
17031    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
17032    #[cfg(feature = "bench-internals")]
17033    #[doc(hidden)]
17034    pub fn benchmark_phases(
17035        &self,
17036        enabled: Option<bool>,
17037        reset: bool,
17038    ) -> Result<Option<Value>, String> {
17039        self.benchmark_phases.configure(enabled, reset)?;
17040        Ok(self.benchmark_phases.snapshot())
17041    }
17042
17043    /// One serialized, read-only authority observation, including durable coverage.
17044    pub fn authority_snapshot(&self) -> Result<Value, String> {
17045        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
17046        if self.limits.authority_reads.is_empty() {
17047            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
17048        }
17049        self.conn
17050            .execute_batch("SAVEPOINT authority_snapshot")
17051            .map_err(|e| e.to_string())?;
17052        let result = (|| {
17053            let revision = self
17054                .get_meta(LOCAL_REVISION_KEY)
17055                .ok_or("sync.local_corrupt: authority revision is missing")?;
17056            let parsed_revision = revision
17057                .parse::<u64>()
17058                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
17059            if parsed_revision.to_string() != revision {
17060                return Err("sync.local_corrupt: authority revision is invalid".into());
17061            }
17062            let mut tables = Vec::new();
17063            for read in &self.limits.authority_reads {
17064                let table = self
17065                    .schema
17066                    .table(&read.table)
17067                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
17068                let scalar = |column: &str, value: &Value| -> Result<String, String> {
17069                    let ty = table
17070                        .columns
17071                        .iter()
17072                        .find(|c| c.name == column)
17073                        .expect("validated authority column")
17074                        .ty;
17075                    if ty == ColumnType::Boolean {
17076                        if let Some(value) = value.as_bool() {
17077                            return Ok(value.to_string());
17078                        }
17079                        return match value.as_i64() {
17080                            Some(0) => Ok("false".into()),
17081                            Some(1) => Ok("true".into()),
17082                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
17083                        };
17084                    }
17085                    match value {
17086                        Value::String(s) => Ok(s.clone()),
17087                        Value::Number(n) => Ok(n.to_string()),
17088                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
17089                            .get("$bigint")
17090                            .and_then(Value::as_str)
17091                            .map(str::to_owned)
17092                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
17093                        _ => {
17094                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
17095                        }
17096                    }
17097                };
17098                let matches = |row: &QueryRow| -> Result<bool, String> {
17099                    for (variable, values) in &read.scopes {
17100                        let column = table
17101                            .scope_column(variable)
17102                            .expect("validated authority scope");
17103                        let value = row
17104                            .get(column)
17105                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
17106                        if value.is_null() || !values.contains(&scalar(column, value)?) {
17107                            return Ok(false);
17108                        }
17109                    }
17110                    Ok(true)
17111                };
17112                let selected = read
17113                    .columns
17114                    .iter()
17115                    .map(|c| quote_ident(c))
17116                    .collect::<Vec<_>>()
17117                    .join(",");
17118                let mut intent = BTreeSet::new();
17119                let mut relevant = BTreeSet::new();
17120                for row in query_connection(&self.conn,
17121                    "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=?",
17122                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
17123                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
17124                    intent.insert(id.clone());
17125                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
17126                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
17127                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
17128                    }
17129                }
17130                let mut rows = BTreeMap::new();
17131                for full in [base_table(&read.table), visible_table(&read.table)] {
17132                    for mut values in query_connection(
17133                        &self.conn,
17134                        &format!(
17135                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
17136                        ),
17137                        &[],
17138                    )
17139                    .map_err(|e| e.to_string())?
17140                    {
17141                        if !matches(&values)? {
17142                            continue;
17143                        }
17144                        let id = scalar(
17145                            &table.primary_key,
17146                            values
17147                                .get(&table.primary_key)
17148                                .expect("selected primary key"),
17149                        )?;
17150                        relevant.insert(id.clone());
17151                        if full == base_table(&read.table) {
17152                            let version = values
17153                                .remove("authority_version")
17154                                .expect("selected version");
17155                            let version = version
17156                                .as_i64()
17157                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
17158                            if version >= 0 {
17159                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
17160                            }
17161                        }
17162                    }
17163                }
17164                intent.retain(|id| relevant.contains(id));
17165                let rows = rows.into_values().collect::<Vec<_>>();
17166                let mut persisted = Vec::new();
17167                for row in query_connection(
17168                    &self.conn,
17169                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
17170                    &[Value::String(read.table.clone())],
17171                )
17172                .map_err(|e| e.message)?
17173                {
17174                    let state: Value = serde_json::from_str(
17175                        row.get("state_json")
17176                            .and_then(Value::as_str)
17177                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
17178                    )
17179                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
17180                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
17181                    let status = state
17182                        .get("status")
17183                        .and_then(Value::as_str)
17184                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
17185                    let cursor = state
17186                        .get("cursor")
17187                        .and_then(Value::as_i64)
17188                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
17189                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
17190                        && status == "active"
17191                        && cursor >= 0
17192                        && state.get("bootstrapState").is_none_or(Value::is_null)
17193                        && state.get("params").is_none_or(Value::is_null);
17194                    let exposed = effective.map(|s| {
17195                        read.scopes
17196                            .keys()
17197                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
17198                            .collect::<Map<_, _>>()
17199                    });
17200                    let requested = read
17201                        .scopes
17202                        .keys()
17203                        .map(|v| {
17204                            (
17205                                v.clone(),
17206                                state
17207                                    .get("requested")
17208                                    .and_then(|s| s.get(v))
17209                                    .cloned()
17210                                    .unwrap_or(json!([])),
17211                            )
17212                        })
17213                        .collect::<Map<_, _>>();
17214                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
17215                }
17216                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
17217                    match selectors.split_first() {
17218                        None => !candidates.is_empty(),
17219                        Some(((variable, values), rest)) => values.iter().all(|value| {
17220                            covered(
17221                                rest,
17222                                &candidates
17223                                    .iter()
17224                                    .copied()
17225                                    .filter(|p| {
17226                                        p["effectiveScopes"][variable.as_str()]
17227                                            .as_array()
17228                                            .is_some_and(|held| {
17229                                                held.contains(&Value::String(value.clone()))
17230                                                    || held.contains(&Value::String("*".into()))
17231                                            })
17232                                    })
17233                                    .collect::<Vec<_>>(),
17234                            )
17235                        }),
17236                    }
17237                }
17238                let coverage = if covered(
17239                    &read.scopes.iter().collect::<Vec<_>>(),
17240                    &persisted
17241                        .iter()
17242                        .filter(|p| p["complete"] == true)
17243                        .collect::<Vec<_>>(),
17244                ) {
17245                    "complete"
17246                } else if persisted.iter().any(|p| {
17247                    p["status"] == "active"
17248                        && read.scopes.iter().all(|(v, values)| {
17249                            values.iter().any(|value| {
17250                                p["requestedScopes"][v].as_array().is_some_and(|held| {
17251                                    held.contains(&Value::String(value.clone()))
17252                                        || held.contains(&Value::String("*".into()))
17253                                })
17254                            })
17255                        })
17256                }) {
17257                    "pending"
17258                } else {
17259                    "missing"
17260                };
17261                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
17262            }
17263            Ok(
17264                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
17265            )
17266        })();
17267        match result {
17268            Ok(snapshot) => {
17269                self.conn
17270                    .execute_batch("RELEASE authority_snapshot")
17271                    .map_err(|e| e.to_string())?;
17272                Ok(snapshot)
17273            }
17274            Err(error) => {
17275                self.conn
17276                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
17277                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
17278                Err(error)
17279            }
17280        }
17281    }
17282
17283    /// Rows, coverage, and local revision from one SQLite read snapshot.
17284    /// §7.5 atomic snapshot read. An `owner` records a failure in
17285    /// `diagnostics_snapshot().query_failures` until its next successful read.
17286    pub fn query_snapshot(
17287        &mut self,
17288        sql: &str,
17289        params: &[QueryValue],
17290        coverage: &[WindowCoverage],
17291        owner: Option<QueryOwner<'_>>,
17292    ) -> Result<QuerySnapshot, String> {
17293        let result = snapshot_connection(&self.conn, sql, params, coverage);
17294        if let Some(owner) = owner {
17295            self.record_query_read(owner, result.as_ref().err());
17296        }
17297        result.map_err(|failure| failure.to_string())
17298    }
17299
17300    /// §7.6: record the outcome of one owned snapshot read, including reads a
17301    /// host ran on a separate connection. A success removes the owner's entry;
17302    /// an identical failure leaves it unchanged; a changed failure moves last.
17303    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
17304        let position = self
17305            .query_failures
17306            .iter()
17307            .position(|entry| entry.id == owner.id);
17308        let Some(failure) = failure else {
17309            if let Some(index) = position {
17310                self.query_failures.remove(index);
17311            }
17312            return;
17313        };
17314        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
17315            .into_iter()
17316            .map(str::to_owned)
17317            .collect();
17318        let code = failure.code.unwrap_or("client.query_failed").to_owned();
17319        if position.is_some_and(|index| {
17320            let entry = &self.query_failures[index];
17321            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
17322        }) {
17323            return;
17324        }
17325        if let Some(index) = position {
17326            self.query_failures.remove(index);
17327        }
17328        self.query_failures.push(DiagnosticQueryFailure {
17329            id: owner.id.to_owned(),
17330            tables,
17331            code,
17332            sqlite_code: failure.sqlite_code,
17333            at_ms: self.clock_now_ms(),
17334        });
17335        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
17336            self.query_failures.remove(0);
17337        }
17338    }
17339
17340    // -- request building ---------------------------------------------------------
17341
17342    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
17343        #[cfg(feature = "bench-internals")]
17344        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
17345        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
17346        let max_commits = self.limits.max_push_commits_per_request;
17347        let max_ops = self
17348            .limits
17349            .max_push_operations_per_request
17350            .unwrap_or(PUSH_OPS_PER_REQUEST);
17351        let byte_cap = self.limits.max_push_request_bytes;
17352        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
17353        // bare URL — capability negotiation, decided per transport.
17354        let accept = self.limits.accept.unwrap_or(if url_capable {
17355            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
17356        } else {
17357            DEFAULT_ACCEPT
17358        });
17359        let header = Frame::ReqHeader {
17360            client_id: self.client_id.clone(),
17361            schema_version: self.schema.version,
17362            log_epoch: log_epoch.clone(),
17363        };
17364        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
17365        // which stays ahead of every push, pull and subscription frame; the
17366        // byte-capacity probes below measure this exact sequence.
17367        let mut lead = vec![header];
17368        lead.extend(
17369            self.round_failure
17370                .iter()
17371                .map(|code| Frame::RoundFailure { code: code.clone() }),
17372        );
17373        let pull = Frame::PullHeader {
17374            limit_commits: self.limits.limit_commits.unwrap_or(0),
17375            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
17376            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
17377            accept,
17378        };
17379        let mut fresh = Vec::new();
17380        let mut sub_frames = Vec::new();
17381        for sub in &self.subs {
17382            if sub.state != SubState::Active {
17383                continue;
17384            }
17385            let mut scopes = sub.requested.clone();
17386            sort_scope_map(&mut scopes);
17387            sub_frames.push(Frame::Subscription {
17388                id: sub.id.clone(),
17389                table: sub.table.clone(),
17390                scopes,
17391                params: sub.params.clone().map(RawJson),
17392                cursor: sub.cursor,
17393                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
17394            });
17395            fresh.push((
17396                sub.id.clone(),
17397                sub.cursor < 0 && sub.bootstrap_state.is_none(),
17398            ));
17399        }
17400        // The fixed frames (order-independent size) and the header+pull probe
17401        // both encode canonically, so the byte budget is the exact wire size.
17402        let fixed_bytes = match byte_cap {
17403            Some(_) => {
17404                let mut frames = lead.clone();
17405                frames.push(pull.clone());
17406                frames.extend(sub_frames.iter().cloned());
17407                encode_message(&Message {
17408                    wire_version: WIRE_VERSION,
17409                    msg_kind: MsgKind::Request,
17410                    frames,
17411                })
17412                .len()
17413            }
17414            None => 0,
17415        };
17416        if let Some(cap) = byte_cap {
17417            if fixed_bytes > cap {
17418                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17419            }
17420        }
17421        let base_bytes = match byte_cap {
17422            Some(_) => {
17423                let mut frames = lead.clone();
17424                frames.push(pull.clone());
17425                encode_message(&Message {
17426                    wire_version: WIRE_VERSION,
17427                    msg_kind: MsgKind::Request,
17428                    frames,
17429                })
17430                .len()
17431            }
17432            None => 0,
17433        };
17434        let total_outbox = if log_epoch.is_some() {
17435            self.outbox.len()
17436        } else {
17437            0
17438        };
17439        let mut push_frames: Vec<Frame> = Vec::new();
17440        let mut pushed_ids = Vec::new();
17441        let mut ops_in_request = 0usize;
17442        let mut bytes_in_request = 0usize;
17443        let mut deferred_commits = 0usize;
17444        #[cfg(feature = "bench-internals")]
17445        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17446        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17447            // §6.1/§7.1: the commit-count and operation caps are hard. A
17448            // first commit over the operation cap is a typed capacity error
17449            // and stays queued; a later one defers with the complete suffix.
17450            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17451                deferred_commits = total_outbox - index;
17452                break;
17453            }
17454            if ops_in_request + commit.ops.len() > max_ops {
17455                if push_frames.is_empty() {
17456                    return Err(Self::push_capacity_outcome(
17457                        "operations",
17458                        max_ops,
17459                        commit.ops.len(),
17460                        Some(commit.client_commit_id.as_str()),
17461                    ));
17462                }
17463                deferred_commits = total_outbox - index;
17464                break;
17465            }
17466            let operations = commit
17467                .ops
17468                .iter()
17469                .map(|op| {
17470                    let payload = self.schema.table(&op.table).and_then(|table| {
17471                        let values = op.presented_values(table)?;
17472                        // §0: outbox entries encode at send time with the
17473                        // current codec (validated at mutate()). §6.1: the
17474                        // values map's keys are the sparse presence set; an
17475                        // insert-intent operation presents every column (§7.1).
17476                        // §5.11: encrypted columns are encrypted here, with
17477                        // an absent key-id selector read from the stored row.
17478                        // `sync_inner` runs `drop_unencodable_outbox` first,
17479                        // so an encrypt failure cannot reach this encode; the
17480                        // author seam validated every other failure.
17481                        let fallback = self.stored_key_fallback(table, &op.row_id);
17482                        encode_sparse_row_json(
17483                            table,
17484                            &op.row_id,
17485                            &values,
17486                            &self.encryption,
17487                            fallback.as_ref(),
17488                        )
17489                        .ok()
17490                    });
17491                    ssp2::model::Operation {
17492                        table: op.table.clone(),
17493                        row_id: op.row_id.clone(),
17494                        op: if op.upsert { Op::Upsert } else { Op::Delete },
17495                        base_version: op.base_version,
17496                        payload,
17497                    }
17498                })
17499                .collect();
17500            let frame = Frame::PushCommit {
17501                client_commit_id: commit.client_commit_id.clone(),
17502                operations,
17503            };
17504            let frame_bytes = match byte_cap {
17505                Some(_) => {
17506                    let mut frames = lead.clone();
17507                    frames.push(frame.clone());
17508                    frames.push(pull.clone());
17509                    encode_message(&Message {
17510                        wire_version: WIRE_VERSION,
17511                        msg_kind: MsgKind::Request,
17512                        frames,
17513                    })
17514                    .len()
17515                        - base_bytes
17516                }
17517                None => 0,
17518            };
17519            // The head is always encoded so the caller can report the full
17520            // projected request size; a later commit defers once it no longer
17521            // fits.
17522            if let Some(cap) = byte_cap {
17523                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17524                    deferred_commits = total_outbox - index;
17525                    break;
17526                }
17527            }
17528            push_frames.push(frame);
17529            pushed_ids.push(commit.client_commit_id.clone());
17530            ops_in_request += commit.ops.len();
17531            bytes_in_request += frame_bytes;
17532        }
17533        #[cfg(feature = "bench-internals")]
17534        drop(outbox_phase);
17535        if let Some(cap) = byte_cap {
17536            if fixed_bytes + bytes_in_request > cap {
17537                // The head alone does not fit this budget; the commit stays
17538                // queued and no transport round is attempted.
17539                let blocked = if push_frames.is_empty() {
17540                    None
17541                } else {
17542                    self.outbox.first().map(|c| c.client_commit_id.as_str())
17543                };
17544                return Err(Self::push_capacity_outcome(
17545                    "bytes",
17546                    cap,
17547                    fixed_bytes + bytes_in_request,
17548                    blocked,
17549                ));
17550            }
17551        }
17552        let mut frames = lead;
17553        frames.extend(push_frames);
17554        frames.push(pull);
17555        frames.extend(sub_frames);
17556        let message = Message {
17557            wire_version: WIRE_VERSION,
17558            msg_kind: MsgKind::Request,
17559            frames,
17560        };
17561        Ok((
17562            message,
17563            RequestMeta {
17564                pushed_ids,
17565                fresh,
17566                accept,
17567                deferred_commits,
17568            },
17569        ))
17570    }
17571
17572    /// §7.1: a configured push capacity limit cannot carry this request.
17573    fn push_capacity_outcome(
17574        kind: &str,
17575        limit: usize,
17576        size: usize,
17577        client_commit_id: Option<&str>,
17578    ) -> Box<SyncOutcome> {
17579        let mut details = Map::new();
17580        details.insert("kind".to_owned(), Value::from(kind));
17581        details.insert("limit".to_owned(), Value::from(limit as u64));
17582        details.insert("size".to_owned(), Value::from(size as u64));
17583        if let Some(id) = client_commit_id {
17584            details.insert("clientCommitId".to_owned(), Value::from(id));
17585        }
17586        Box::new(SyncOutcome::Failed {
17587            error_code: "client.push_request_too_large".into(),
17588            message: "the push request exceeds a configured capacity limit".into(),
17589            details: Some(Value::Object(details)),
17590            recommended_action: None,
17591        })
17592    }
17593
17594    // -- sync -------------------------------------------------------------------
17595
17596    /// Cloneable observation handle; no client lock is needed to read it.
17597    pub fn progress(&self) -> ProgressObserver {
17598        self.progress.clone()
17599    }
17600
17601    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17602        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17603            Ok(prepared) => prepared,
17604            Err(outcome) => return *outcome,
17605        };
17606        // The round's network deadline rides `PreparedSyncRound` and is scoped
17607        // to each exchange, so uploads, continuations, the main request, and
17608        // every segment fetch share one monotonic anchor without leaving
17609        // deadline state on a reused transport.
17610        let mut next = prepared;
17611        loop {
17612            match self.apply_sync_round(next.exchange(transport)) {
17613                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17614                crate::AppliedSyncRound::Complete {
17615                    outcome, controls, ..
17616                } => {
17617                    if self.transport_enabled {
17618                        for text in controls {
17619                            let _ = transport.realtime_send(&text);
17620                        }
17621                    } else {
17622                        self.disconnect_realtime(transport);
17623                    }
17624                    return outcome;
17625                }
17626            }
17627        }
17628    }
17629
17630    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17631        if let SyncOutcome::Failed {
17632            error_code,
17633            message,
17634            details,
17635            ..
17636        } = &mut outcome
17637        {
17638            if let Some(failure) = self.storage_failure.borrow().as_ref() {
17639                let code = failure
17640                    .code
17641                    .expect("retained storage failures are classified");
17642                *error_code = code.to_owned();
17643                *message = match code {
17644                    "client.storage_busy" => "local SQLite storage is busy",
17645                    "client.storage_full" => "local SQLite storage is full",
17646                    "client.storage_io" => "local SQLite storage I/O failed",
17647                    "client.storage_corrupt" => "local SQLite storage is corrupt",
17648                    _ => unreachable!("unrecognized classified storage failure"),
17649                }
17650                .to_owned();
17651                *details = failure.details();
17652            }
17653        }
17654        let retry_delay_ms = self.round_retry_delay_ms;
17655        self.progress.update(|p| match &outcome {
17656            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17657                if report.failed.is_empty() =>
17658            {
17659                p.state = ProgressState::Complete
17660            }
17661            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17662                p.state = ProgressState::Failed;
17663                p.error_code = Some("sync.scope_revoked".into());
17664            }
17665            SyncOutcome::Failed { error_code, .. } => {
17666                p.state = ProgressState::Failed;
17667                p.error_code = Some(Self::diagnostic_code(error_code));
17668                p.retry_delay_ms = retry_delay_ms;
17669            }
17670            SyncOutcome::RealtimeUnavailable { .. } => {
17671                p.state = ProgressState::Failed;
17672                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17673                p.retry_delay_ms = retry_delay_ms;
17674            }
17675        });
17676        let completed_at_ms = self.clock_now_ms();
17677        self.last_round = Some(match &outcome {
17678            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17679                status: "succeeded".to_owned(),
17680                started_at_ms,
17681                completed_at_ms,
17682                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17683                counters: Some(DiagnosticRoundCounters {
17684                    pushed: report.pushed,
17685                    applied: report.applied.len(),
17686                    rejected: report.rejected.len(),
17687                    retryable: report.retryable.len(),
17688                    conflicts: report.conflicts,
17689                    commits_applied: report.commits_applied,
17690                    segment_rows_applied: report.segment_rows_applied,
17691                    bootstrapping: report.bootstrapping.len(),
17692                    resets: report.resets.len(),
17693                    revoked: report.revoked.len(),
17694                    failed: report.failed.len(),
17695                    deferred_commits: report.deferred_commits,
17696                }),
17697                error_code: None,
17698            },
17699            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17700                status: "failed".to_owned(),
17701                started_at_ms,
17702                completed_at_ms,
17703                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17704                counters: None,
17705                error_code: Some(Self::diagnostic_code(error_code)),
17706            },
17707            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17708                status: "failed".to_owned(),
17709                started_at_ms,
17710                completed_at_ms,
17711                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17712                counters: None,
17713                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17714            },
17715        });
17716        outcome
17717    }
17718
17719    /// Capture one request on the client owner. Mutations made after this
17720    /// call remain in the outbox for a later round; no network I/O occurs here.
17721    pub fn prepare_sync_round(
17722        &mut self,
17723        url_capable: bool,
17724    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17725        if !self.transport_enabled {
17726            return Err(Box::new(SyncOutcome::Failed {
17727                error_code: "sync.offline".into(),
17728                message: "transport gate is closed".into(),
17729                details: None,
17730                recommended_action: None,
17731            }));
17732        }
17733        if self.active_round.is_some() {
17734            return Err(Box::new(SyncOutcome::Failed {
17735                error_code: "client.round_in_progress".into(),
17736                message: "a sync round is already in flight".into(),
17737                details: None,
17738                recommended_action: None,
17739            }));
17740        }
17741        self.storage_failure.borrow_mut().take();
17742        self.progress.start();
17743        self.round_retry_delay_ms = None;
17744        let started_at_ms = self.clock_now_ms();
17745        let prepared = (|| {
17746            if self.stopped {
17747                return Err(Box::new(SyncOutcome::Ok(SyncReport {
17748                    schema_floor: self.schema_floor.clone(),
17749                    ..SyncReport::default()
17750                })));
17751            }
17752            if self.security_preflight {
17753                return Err(Box::new(SyncOutcome::Failed {
17754                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17755                    message: "security preflight must complete before syncing".into(),
17756                    details: None,
17757                    recommended_action: None,
17758                }));
17759            }
17760            self.set_sync_needed(false, false);
17761            self.drain_pending_evictions()
17762                .and_then(|_| self.drop_incompatible_outbox())
17763                .and_then(|_| self.drop_unencodable_outbox(true, None))
17764                .map_err(|message| {
17765                    Box::new(SyncOutcome::Failed {
17766                        error_code: "storage.failed".into(),
17767                        message,
17768                        details: None,
17769                        recommended_action: None,
17770                    })
17771                })?;
17772            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17773                self.pending_blob_uploads().map_err(|e| {
17774                    Box::new(SyncOutcome::Failed {
17775                        error_code: e.code,
17776                        message: e.message,
17777                        details: e.details,
17778                        recommended_action: None,
17779                    })
17780                })?
17781            } else {
17782                Vec::new()
17783            };
17784            if self.realtime_state != RealtimeState::Connected
17785                && self.realtime_policy == RealtimePolicy::Required
17786            {
17787                let retry_delay_ms = self.schedule_background_retry();
17788                self.realtime_retry_delay_ms = Some(retry_delay_ms);
17789                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17790                    state: self.realtime_state,
17791                    reason_code: self.realtime_reason_code.clone(),
17792                    retry_delay_ms,
17793                }));
17794            }
17795            let (message, meta) = self.build_request(url_capable)?;
17796            let id = uuid::Uuid::new_v4();
17797            self.active_round = Some(id);
17798            let phase = if uploads.is_empty() {
17799                crate::round::RoundPhase::Sync
17800            } else {
17801                crate::round::RoundPhase::UploadGrant
17802            };
17803            Ok(crate::PreparedSyncRound {
17804                id,
17805                started_at_ms,
17806                message,
17807                meta,
17808                uploads: uploads.into(),
17809                phase,
17810                encoded: None,
17811                continuation: None,
17812                completed: None,
17813                realtime: self.realtime_state == RealtimeState::Connected,
17814                scopes: self
17815                    .subs
17816                    .iter()
17817                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17818                    .collect(),
17819                #[cfg(feature = "bench-internals")]
17820                benchmark_phases: self.benchmark_phases.clone(),
17821                progress: self.progress.clone(),
17822                fixed_now: self.now_ms,
17823                round_deadline_at: None,
17824            })
17825        })();
17826        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17827    }
17828
17829    /// Discard the reply of an old authorization/subscription context. Its
17830    /// submitted commits stay durable and can be retried with the same ids.
17831    pub fn cancel_sync_round(&mut self) {
17832        self.active_round = None;
17833    }
17834
17835    /// Apply captured network results on the owner, preserving all normal
17836    /// version, schema, content-address and cursor checks. No network I/O.
17837    pub fn apply_sync_round(
17838        &mut self,
17839        completed: crate::CompletedSyncRound,
17840    ) -> crate::AppliedSyncRound {
17841        let prepared = completed.prepared;
17842        if self.active_round != Some(prepared.id) || self.security_preflight {
17843            return crate::AppliedSyncRound::Complete {
17844                outcome: SyncOutcome::Failed {
17845                    error_code: "client.round_cancelled".into(),
17846                    message: "sync round belongs to a released client context".into(),
17847                    details: None,
17848                    recommended_action: None,
17849                },
17850                controls: Vec::new(),
17851                more: false,
17852                bootstrap_advanced: false,
17853            };
17854        }
17855        self.storage_failure.borrow_mut().take();
17856        let (response, mut downloads, transport_failed) = match completed.exchange {
17857            crate::round::ExchangeResult::Upload { id, result } => {
17858                let result = result.and_then(|()| {
17859                    self.conn
17860                        .execute(
17861                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17862                            rusqlite::params![id],
17863                        )
17864                        .map(|_| ())
17865                        .map_err(|error| {
17866                            TransportError::new(
17867                                "client.failed",
17868                                Self::sqlite_failure(&self.storage_failure, error),
17869                            )
17870                        })
17871                });
17872                match result {
17873                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17874                    Err(TransportError {
17875                        code,
17876                        message,
17877                        details,
17878                    }) => {
17879                        self.active_round = None;
17880                        if Self::retryable_failure_code(&code)
17881                            && !Self::unreachable_transport(&details)
17882                        {
17883                            self.schedule_background_retry();
17884                        }
17885                        let recommended_action = recommended_action_of(&details);
17886                        let outcome = self.finish_sync_round(
17887                            prepared.started_at_ms,
17888                            SyncOutcome::Failed {
17889                                error_code: code,
17890                                message,
17891                                details,
17892                                recommended_action,
17893                            },
17894                        );
17895                        return crate::AppliedSyncRound::Complete {
17896                            outcome,
17897                            controls: Vec::new(),
17898                            more: false,
17899                            bootstrap_advanced: false,
17900                        };
17901                    }
17902                }
17903            }
17904            crate::round::ExchangeResult::Reply {
17905                response,
17906                downloads,
17907                transport_failed,
17908            } => (response, downloads, transport_failed),
17909        };
17910        self.active_round = None;
17911        let before = self
17912            .subs
17913            .iter()
17914            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17915            .collect::<Vec<_>>();
17916        let outcome = (|| {
17917            let response = match response {
17918                Ok(response) => {
17919                    // §1.5: a complete, decodable response ends the report, an
17920                    // in-band ERROR included (the server already recorded it).
17921                    self.round_failure = None;
17922                    response
17923                }
17924                Err(TransportError {
17925                    code,
17926                    message,
17927                    details,
17928                }) => {
17929                    // §1.5: the server cannot see a round whose transport
17930                    // failed or whose response never decoded, so its code rides
17931                    // the following requests. A code the frame grammar rejects
17932                    // is not reportable, and the previous code stays.
17933                    if ssp2::model::valid_round_failure_code(&code) {
17934                        self.round_failure = Some(code.clone());
17935                    }
17936                    if transport_failed {
17937                        if prepared.realtime {
17938                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
17939                        }
17940                        self.record_lease_error(&code);
17941                    }
17942                    if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
17943                    {
17944                        let delay = self.schedule_background_retry();
17945                        if prepared.realtime && transport_failed {
17946                            self.realtime_retry_delay_ms = Some(delay);
17947                        }
17948                    }
17949                    let recommended_action = recommended_action_of(&details);
17950                    return SyncOutcome::Failed {
17951                        error_code: code,
17952                        message,
17953                        details,
17954                        recommended_action,
17955                    };
17956                }
17957            };
17958            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17959            if let SyncOutcome::Ok(report) = &mut outcome {
17960                report.deferred_commits = prepared.meta.deferred_commits;
17961            }
17962            match &outcome {
17963                SyncOutcome::Ok(_) => self.reset_background_retry(),
17964                SyncOutcome::Failed {
17965                    error_code,
17966                    details,
17967                    ..
17968                } if Self::retryable_failure_code(error_code)
17969                    && !Self::unreachable_transport(details) =>
17970                {
17971                    self.schedule_background_retry();
17972                }
17973                _ => {}
17974            }
17975            if prepared.meta.deferred_commits > 0 {
17976                self.set_sync_needed(true, true);
17977            }
17978            self.previous_version_lifetime_check();
17979            outcome
17980        })();
17981        let more = self.transport_enabled
17982            && matches!(&outcome, SyncOutcome::Ok(report)
17983                if report.schema_floor.is_none()
17984                    && (!report.bootstrapping.is_empty()
17985                        || report.commits_applied > 0
17986                        || report.segment_rows_applied > 0
17987                        || !report.resets.is_empty()
17988                        || report.deferred_commits > 0
17989                        || self.sync_needed));
17990        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17991            && before
17992                != self
17993                    .subs
17994                    .iter()
17995                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17996                    .collect::<Vec<_>>();
17997        crate::AppliedSyncRound::Complete {
17998            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17999            controls: if self.transport_enabled {
18000                downloads.controls
18001            } else {
18002                Vec::new()
18003            },
18004            more,
18005            bootstrap_advanced,
18006        }
18007    }
18008
18009    /// §7.7: one idle drive's default total deadline, the TS client's value.
18010    pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
18011
18012    pub fn sync_until_idle(
18013        &mut self,
18014        transport: &mut dyn Transport,
18015        max_rounds: Option<u32>,
18016    ) -> SyncOutcome {
18017        self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
18018    }
18019
18020    /// As [`SyncClient::sync_until_idle`], with an explicit total deadline in
18021    /// milliseconds (`None` disables it). The deadline is checked between
18022    /// rounds; one blocking transport call is bounded by the transport's own
18023    /// whole-round deadline (§1.8).
18024    pub fn sync_until_idle_with_deadline(
18025        &mut self,
18026        transport: &mut dyn Transport,
18027        max_rounds: Option<u32>,
18028        timeout_ms: Option<u64>,
18029    ) -> SyncOutcome {
18030        // §7.7: an explicit budget is a strict positive round count. Zero is a
18031        // caller error, not an exhaustion.
18032        if max_rounds == Some(0) {
18033            return SyncOutcome::Failed {
18034                details: None,
18035                error_code: "sync.invalid_request".into(),
18036                message: "maxRounds must be a positive integer".into(),
18037                recommended_action: None,
18038            };
18039        }
18040        if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
18041            return SyncOutcome::Failed {
18042                details: None,
18043                error_code: "sync.invalid_request".into(),
18044                message: "timeoutMs must be a positive integer".into(),
18045                recommended_action: None,
18046            };
18047        }
18048        let started_at_ms = self.clock_now_ms();
18049        let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
18050        self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
18051    }
18052
18053    fn sync_until_idle_bounded(
18054        &mut self,
18055        transport: &mut dyn Transport,
18056        max_rounds: Option<u32>,
18057        started_at_ms: i64,
18058        deadline_ms: Option<i64>,
18059    ) -> SyncOutcome {
18060        let rounds = max_rounds.unwrap_or(20);
18061        let mut aggregate = SyncReport::default();
18062        let mut spent = 0;
18063        while spent < rounds {
18064            if let Some(deadline_ms) = deadline_ms {
18065                if self.clock_now_ms() >= deadline_ms {
18066                    return SyncOutcome::Failed {
18067                        error_code: "sync.idle_timeout".into(),
18068                        message: "sync_until_idle exceeded its deadline".into(),
18069                        details: Some(serde_json::json!({
18070                            "timeoutMs": deadline_ms
18071                                .saturating_sub(started_at_ms) as u64,
18072                            "rounds": spent,
18073                        })),
18074                        recommended_action: Some("retryLater".into()),
18075                    };
18076                }
18077            }
18078            spent += 1;
18079            let before = self
18080                .subs
18081                .iter()
18082                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
18083                .collect::<Vec<_>>();
18084            match self.sync(transport) {
18085                SyncOutcome::Failed {
18086                    error_code,
18087                    message,
18088                    details,
18089                    recommended_action,
18090                } => {
18091                    return SyncOutcome::Failed {
18092                        details,
18093                        error_code,
18094                        message,
18095                        recommended_action,
18096                    };
18097                }
18098                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
18099                SyncOutcome::BudgetExhausted(_) => {
18100                    unreachable!("sync() never exhausts a round budget")
18101                }
18102                SyncOutcome::Ok(report) => {
18103                    if max_rounds.is_none()
18104                        && report.segment_rows_applied > 0
18105                        && !report.bootstrapping.is_empty()
18106                        && before
18107                            != self
18108                                .subs
18109                                .iter()
18110                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
18111                                .collect::<Vec<_>>()
18112                    {
18113                        spent = 0;
18114                    }
18115                    // TS parity: a disabled transport or a schema floor ends
18116                    // the loop. The latest round still merges first.
18117                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
18118                    aggregate.merge(&report);
18119                    if stop {
18120                        return SyncOutcome::Ok(aggregate);
18121                    }
18122                    // §4.5: pull again whenever the response contained
18123                    // commits or segments; resets re-bootstrap; a pending
18124                    // resume token continues paging (§4.7); deferred outbox
18125                    // commits and a raised sync-needed signal push on the next
18126                    // round.
18127                    let more = !report.bootstrapping.is_empty()
18128                        || report.commits_applied > 0
18129                        || report.segment_rows_applied > 0
18130                        || !report.resets.is_empty()
18131                        || report.deferred_commits > 0
18132                        || self.sync_needed;
18133                    if !more {
18134                        return SyncOutcome::Ok(aggregate);
18135                    }
18136                }
18137            }
18138        }
18139        // §7.7: partial success. The aggregate report is retained and the run
18140        // is explicitly not idle.
18141        SyncOutcome::BudgetExhausted(aggregate)
18142    }
18143
18144    fn process_response(
18145        &mut self,
18146        transport: &mut dyn Transport,
18147        response: Message,
18148        meta: &RequestMeta,
18149    ) -> SyncOutcome {
18150        #[cfg(feature = "bench-internals")]
18151        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
18152        let mut report = SyncReport {
18153            pushed: meta.pushed_ids.len() as u32,
18154            ..SyncReport::default()
18155        };
18156        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
18157            HashMap::new();
18158        let pushed_ids = meta
18159            .pushed_ids
18160            .iter()
18161            .map(String::as_str)
18162            .collect::<HashSet<_>>();
18163        let mut incomplete_subscriptions: HashSet<&str> =
18164            meta.fresh.iter().map(|(id, _)| id.as_str()).collect();
18165        let mut last_final_push_result_id: Option<String> = None;
18166        for frame in &response.frames {
18167            match frame {
18168                Frame::PushResultDetails {
18169                    client_commit_id,
18170                    entries,
18171                } => {
18172                    let details = rejection_details_by_commit
18173                        .entry(client_commit_id.clone())
18174                        .or_default();
18175                    for entry in entries {
18176                        let parsed = match RejectionDetails::parse(&entry.details.0) {
18177                            Ok(value) => value,
18178                            Err(message) => {
18179                                return SyncOutcome::Failed {
18180                                    details: None,
18181                                    error_code: "sync.invalid_request".to_owned(),
18182                                    message,
18183                                    recommended_action: None,
18184                                };
18185                            }
18186                        };
18187                        details.insert(entry.op_index, parsed);
18188                    }
18189                }
18190                Frame::PushResult {
18191                    client_commit_id,
18192                    status,
18193                    results,
18194                    ..
18195                } if pushed_ids.contains(client_commit_id.as_str())
18196                    && Self::push_result_is_final(*status, results) =>
18197                {
18198                    last_final_push_result_id = Some(client_commit_id.clone());
18199                }
18200                _ => {}
18201            }
18202        }
18203        if response.wire_version < 2 {
18204            return SyncOutcome::Failed {
18205                details: None,
18206                error_code: "client.invalid_host_response".to_owned(),
18207                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
18208                recommended_action: None,
18209            };
18210        }
18211        let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
18212            status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
18213        });
18214        let mut frames = response.frames.into_iter().peekable();
18215        match frames.next() {
18216            Some(Frame::RespHeader {
18217                required_schema_version,
18218                latest_schema_version,
18219                log_epoch,
18220                reset_required,
18221            }) => {
18222                let Some(log_epoch) = log_epoch else {
18223                    return SyncOutcome::Failed {
18224                        details: None,
18225                        error_code: "client.invalid_host_response".to_owned(),
18226                        message: "response header omits logEpoch".to_owned(),
18227                        recommended_action: None,
18228                    };
18229                };
18230                let Some(reset_required) = reset_required else {
18231                    return SyncOutcome::Failed {
18232                        details: None,
18233                        error_code: "client.invalid_host_response".to_owned(),
18234                        message: "response header omits resetRequired".to_owned(),
18235                        recommended_action: None,
18236                    };
18237                };
18238                if let Some(required) = required_schema_version {
18239                    // §1.6 schema-floor response: nothing else is processed;
18240                    // stop syncing and surface the upgrade requirement.
18241                    let floor = SchemaFloor {
18242                        required_schema_version: Some(required),
18243                        latest_schema_version,
18244                    };
18245                    self.set_schema_floor(Some(floor.clone()));
18246                    report.schema_floor = Some(floor);
18247                    return SyncOutcome::Ok(report);
18248                }
18249                if reset_required {
18250                    if frames.next().is_some() {
18251                        return SyncOutcome::Failed {
18252                            details: None,
18253                            error_code: "client.invalid_host_response".to_owned(),
18254                            message: "log-epoch reset response contains body frames".to_owned(),
18255                            recommended_action: None,
18256                        };
18257                    }
18258                    match self.run_log_epoch_reset(&log_epoch) {
18259                        Ok(resets) => {
18260                            report.resets = resets;
18261                            return SyncOutcome::Ok(report);
18262                        }
18263                        Err(message) => {
18264                            return SyncOutcome::Failed {
18265                                details: None,
18266                                error_code: "sync.local_corrupt".to_owned(),
18267                                message,
18268                                recommended_action: None,
18269                            };
18270                        }
18271                    }
18272                }
18273                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
18274                    return SyncOutcome::Failed {
18275                        details: None,
18276                        error_code: "client.invalid_host_response".to_owned(),
18277                        message: "server changed logEpoch without requiring a reset".to_owned(),
18278                        recommended_action: None,
18279                    };
18280                }
18281            }
18282            _ => {
18283                return SyncOutcome::Failed {
18284                    details: None,
18285                    error_code: "sync.invalid_request".to_owned(),
18286                    message: "response does not start with RESP_HEADER".to_owned(),
18287                    recommended_action: None,
18288                };
18289            }
18290        }
18291
18292        if let Err(message) = self.begin_coalesced_apply() {
18293            return SyncOutcome::Failed {
18294                details: None,
18295                error_code: "storage.failed".into(),
18296                message,
18297                recommended_action: None,
18298            };
18299        }
18300        let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
18301        while let Some(frame) = frames.next() {
18302            match frame {
18303                frame @ Frame::PushResult { .. } => {
18304                    let successful = matches!(
18305                        &frame,
18306                        Frame::PushResult {
18307                            status: PushStatus::Applied | PushStatus::Cached,
18308                            ..
18309                        }
18310                    );
18311                    let mut results = vec![frame];
18312                    if successful {
18313                        while matches!(
18314                            frames.peek(),
18315                            Some(Frame::PushResult {
18316                                status: PushStatus::Applied | PushStatus::Cached,
18317                                ..
18318                            })
18319                        ) {
18320                            if let Some(next) = frames.next() {
18321                                results.push(next);
18322                            }
18323                        }
18324                    }
18325                    if self
18326                        .handle_push_results(
18327                            &results,
18328                            &pushed_ids,
18329                            &rejection_details_by_commit,
18330                            &mut report,
18331                            last_final_push_result_id.as_deref(),
18332                        )
18333                        .is_err()
18334                    {
18335                        failure = Some((
18336                            "client.outcome_persistence_failed".to_owned(),
18337                            "local commit outcome could not be persisted".to_owned(),
18338                            None,
18339                            None,
18340                        ));
18341                        break;
18342                    }
18343                }
18344                Frame::PushResultDetails { .. } => {}
18345                Frame::SubStart {
18346                    id,
18347                    status,
18348                    reason_code,
18349                    effective_scopes,
18350                    bootstrap: _,
18351                } => {
18352                    let mut body = Vec::new();
18353                    let mut sub_end: Option<(i64, Option<String>)> = None;
18354                    for inner in frames.by_ref() {
18355                        match inner {
18356                            Frame::SubEnd {
18357                                next_cursor,
18358                                bootstrap_state,
18359                            } => {
18360                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
18361                                break;
18362                            }
18363                            Frame::Unknown { .. } => {}
18364                            other => body.push(other),
18365                        }
18366                    }
18367                    if sub_end.is_some() {
18368                        incomplete_subscriptions.remove(id.as_str());
18369                    }
18370                    if let Err(SectionError::Abort(code, message)) = self.process_section(
18371                        transport,
18372                        &id,
18373                        status,
18374                        &reason_code,
18375                        effective_scopes,
18376                        body,
18377                        sub_end,
18378                        meta,
18379                        &mut report,
18380                        &coalesced_deletes,
18381                    ) {
18382                        failure = Some((code, message, None, None));
18383                        break;
18384                    }
18385                }
18386                Frame::Lease {
18387                    lease_id,
18388                    expires_at_ms,
18389                } => {
18390                    // §7.3.5: persist the opaque lease; a fresh lease clears
18391                    // any prior lease error (the outage/revocation is over).
18392                    self.set_lease_state(Some(LeaseState {
18393                        lease_id: Some(lease_id),
18394                        expires_at_ms: Some(expires_at_ms),
18395                        error_code: None,
18396                    }));
18397                }
18398                Frame::Error {
18399                    code,
18400                    message,
18401                    recommended_action,
18402                    details,
18403                    ..
18404                } => {
18405                    // §1.6: the whole request failed; already-completed
18406                    // subscriptions keep their applied data and cursors.
18407                    let recommended_action =
18408                        (!recommended_action.is_empty()).then_some(recommended_action);
18409                    let details = details
18410                        .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
18411                        .filter(Value::is_object);
18412                    failure = Some((code, message, recommended_action, details));
18413                    break;
18414                }
18415                Frame::Unknown { .. } => {}
18416                _ => {
18417                    failure = Some((
18418                        "sync.invalid_request".to_owned(),
18419                        "unexpected frame in response".to_owned(),
18420                        None,
18421                        None,
18422                    ));
18423                    break;
18424                }
18425            }
18426        }
18427
18428        let applied = match failure {
18429            Some(failure) => Err(failure),
18430            // Local apply boundaries already reconciled their affected overlay.
18431            // Refuse round success if any remaining dirty work fails to
18432            // reconcile. §4.8 E1: the push half may have drained commits that
18433            // pinned rows of a shrunk window unit, so retry deferred evictions.
18434            None => self
18435                .rebuild_overlay_if_dirty()
18436                .and_then(|()| self.drain_pending_evictions())
18437                .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
18438        };
18439        // §1.4 rule 4: frames completed before a failure stay committed; a
18440        // failed COMMIT of the coalesced transaction fails the round.
18441        if let Err(message) = self.finish_coalesced_apply() {
18442            return SyncOutcome::Failed {
18443                details: None,
18444                error_code: "storage.failed".into(),
18445                message,
18446                recommended_action: None,
18447            };
18448        }
18449        if let Err((error_code, message, recommended_action, details)) = applied {
18450            return SyncOutcome::Failed {
18451                details,
18452                error_code,
18453                message,
18454                recommended_action,
18455            };
18456        }
18457        if !incomplete_subscriptions.is_empty() {
18458            self.set_sync_needed(true, true);
18459        }
18460        self.ack_after_pull(transport);
18461        // §7.4.5: the reset is over once the first post-reset pull round
18462        // leaves no subscription mid-bootstrap — the tables are rebuilt.
18463        if self.upgrading && report.bootstrapping.is_empty() {
18464            self.set_upgrading(false);
18465        }
18466        SyncOutcome::Ok(report)
18467    }
18468
18469    // -- push results (§6.3, §7.2) ------------------------------------------------
18470
18471    fn handle_push_results(
18472        &mut self,
18473        frames: &[Frame],
18474        pushed_ids: &HashSet<&str>,
18475        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18476        report: &mut SyncReport,
18477        last_final_push_result_id: Option<&str>,
18478    ) -> Result<(), String> {
18479        self.begin_observation("syncular_push_results")?;
18480        let previous_failed = self.failed_commits.clone();
18481        let previous_sync_needed = self.sync_needed;
18482        let conflict_count = self.conflicts.len();
18483        let rejection_count = self.rejections.len();
18484        let applied_count = report.applied.len();
18485        let rejected_count = report.rejected.len();
18486        let retryable_count = report.retryable.len();
18487        let was_dirty = self.overlay_dirty.snapshot();
18488        let mut removed = Vec::new();
18489        let mut batch = ChangeAccumulator::default();
18490        let persisted = (|| {
18491            for frame in frames {
18492                let Frame::PushResult {
18493                    client_commit_id,
18494                    status,
18495                    results,
18496                    commit_seq,
18497                } = frame
18498                else {
18499                    return Err("unexpected acknowledgement frame".to_owned());
18500                };
18501                if !pushed_ids.contains(client_commit_id.as_str()) {
18502                    continue;
18503                }
18504                let Some(index) = self
18505                    .outbox
18506                    .iter()
18507                    .position(|commit| commit.client_commit_id == *client_commit_id)
18508                else {
18509                    continue;
18510                };
18511                let status = *status;
18512                let rejection_details = rejection_details_by_commit.get(client_commit_id);
18513                let operations = self.outbox[index].ops.clone();
18514                match status {
18515                    PushStatus::Applied | PushStatus::Cached => {
18516                        // §7.2: a lost ack replays as `cached` — proceed as if the
18517                        // ack had arrived.
18518                        let sequence =
18519                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18520                        let affected: HashSet<_> = operations
18521                            .iter()
18522                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
18523                            .collect();
18524                        let prior = self.acknowledged_operations(Some(&affected), None)?;
18525                        for (op_index, operation) in operations.iter().enumerate() {
18526                            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))
18527                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18528                            if delivered {
18529                                continue;
18530                            }
18531                            let table = self
18532                                .schema
18533                                .table(&operation.table)
18534                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
18535                            let mut intended: Map<String, Value> = self
18536                                .stored_row(table, &operation.row_id, true)?
18537                                .map(|(row, _)| {
18538                                    table
18539                                        .columns
18540                                        .iter()
18541                                        .zip(&row)
18542                                        .map(|(column, value)| {
18543                                            (column.name.clone(), column_value_to_json(value))
18544                                        })
18545                                        .collect()
18546                                })
18547                                .unwrap_or_default();
18548                            let mut scope_image = intended.clone();
18549                            for op in prior
18550                                .iter()
18551                                .chain(
18552                                    self.failed_commits
18553                                        .iter()
18554                                        .flat_map(|(commit, _)| &commit.ops),
18555                                )
18556                                .chain(
18557                                    self.outbox
18558                                        .iter()
18559                                        .take(index)
18560                                        .flat_map(|commit| &commit.ops),
18561                                )
18562                                .chain(operations.iter().take(op_index + 1))
18563                            {
18564                                if op.table != operation.table || op.row_id != operation.row_id {
18565                                    continue;
18566                                }
18567                                if let Some(values) = &op.values {
18568                                    intended.extend(values.clone());
18569                                } else {
18570                                    scope_image = intended.clone();
18571                                    intended.clear();
18572                                }
18573                            }
18574                            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)",
18575                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18576                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18577                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18578                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18579                        }
18580                        let journal_results = results
18581                            .iter()
18582                            .map(|result| {
18583                                let op_index = match result {
18584                                    OpResult::Applied { op_index }
18585                                    | OpResult::Conflict { op_index, .. }
18586                                    | OpResult::Error { op_index, .. } => *op_index,
18587                                };
18588                                CommitOperationOutcome::Applied { op_index }
18589                            })
18590                            .collect::<Vec<_>>();
18591                        let outcome_status = if status == PushStatus::Applied {
18592                            CommitOutcomeStatus::Applied
18593                        } else {
18594                            CommitOutcomeStatus::Cached
18595                        };
18596                        let persisted = self
18597                            .persist_commit_outcome(
18598                                client_commit_id,
18599                                outcome_status,
18600                                &journal_results,
18601                                None,
18602                            )
18603                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18604                        persisted?;
18605                        for operation in &operations {
18606                            self.overlay_dirty.table(&operation.table);
18607                        }
18608                        self.sync_needed = true;
18609                        batch.status = true;
18610                        batch.outcomes = true;
18611                    }
18612                    PushStatus::Rejected => {
18613                        if results.iter().any(|result| {
18614                            matches!(
18615                                result,
18616                                OpResult::Error {
18617                                    code,
18618                                    retryable: true,
18619                                    ..
18620                                } if code == "sync.idempotency_cache_miss"
18621                            )
18622                        }) {
18623                            // §6.3/§7.2: a serving failure, not an outcome — keep the
18624                            // exact commit queued for an identical retry.
18625                            report.retryable.push(client_commit_id.to_owned());
18626                            continue;
18627                        }
18628
18629                        let mut journal_results = Vec::with_capacity(results.len());
18630                        let mut conflicts = Vec::new();
18631                        let mut rejections = Vec::new();
18632                        for result in results {
18633                            match result {
18634                                OpResult::Applied { op_index } => {
18635                                    journal_results.push(CommitOperationOutcome::Applied {
18636                                        op_index: *op_index,
18637                                    });
18638                                }
18639                                OpResult::Conflict {
18640                                    op_index,
18641                                    code,
18642                                    message,
18643                                    server_version,
18644                                    server_row,
18645                                    conflict_columns: conflict_columns_bitmap,
18646                                } => {
18647                                    let operation = operations
18648                                        .get(*op_index as usize)
18649                                        .map(CommitOperation::from);
18650                                    let (table, row_id) = operation
18651                                        .as_ref()
18652                                        .map(|op| (op.table.clone(), op.row_id.clone()))
18653                                        .unwrap_or_default();
18654                                    let server_row_json = self
18655                                        .schema
18656                                        .table(&table)
18657                                        .and_then(|t| {
18658                                            decode_row_bytes(t, server_row, &self.encryption)
18659                                                .ok()
18660                                                .map(|row| (t, row))
18661                                        })
18662                                        .map(|(t, row)| {
18663                                            let mut map = Map::new();
18664                                            for (i, column) in t.columns.iter().enumerate() {
18665                                                map.insert(
18666                                                    column.name.clone(),
18667                                                    column_value_to_json(
18668                                                        row.get(i).unwrap_or(&None),
18669                                                    ),
18670                                                );
18671                                            }
18672                                            map
18673                                        })
18674                                        .unwrap_or_default();
18675                                    // §6.3: decode the presence-layout bitmap
18676                                    // into the marked column names.
18677                                    let conflict_columns = self
18678                                        .schema
18679                                        .table(&table)
18680                                        .map(|t| {
18681                                            t.columns
18682                                                .iter()
18683                                                .enumerate()
18684                                                .filter(|(index, _)| {
18685                                                    conflict_columns_bitmap
18686                                                        .get(index / 8)
18687                                                        .is_some_and(|byte| {
18688                                                            byte & (1 << (index % 8)) != 0
18689                                                        })
18690                                                })
18691                                                .map(|(_, column)| column.name.clone())
18692                                                .collect::<Vec<_>>()
18693                                        })
18694                                        .unwrap_or_default();
18695                                    let conflict = ConflictRecord {
18696                                        client_commit_id: client_commit_id.to_owned(),
18697                                        op_index: *op_index,
18698                                        table,
18699                                        row_id,
18700                                        code: code.clone(),
18701                                        message: message.clone(),
18702                                        server_version: *server_version,
18703                                        server_row: server_row_json,
18704                                        conflict_columns,
18705                                        operation,
18706                                    };
18707                                    journal_results.push(CommitOperationOutcome::Conflict {
18708                                        conflict: conflict.clone(),
18709                                    });
18710                                    conflicts.push(conflict);
18711                                }
18712                                OpResult::Error {
18713                                    op_index,
18714                                    code,
18715                                    message,
18716                                    retryable,
18717                                } => {
18718                                    let rejection = RejectionRecord {
18719                                        client_commit_id: client_commit_id.to_owned(),
18720                                        op_index: *op_index,
18721                                        code: code.clone(),
18722                                        message: message.clone(),
18723                                        retryable: *retryable,
18724                                        details: rejection_details
18725                                            .and_then(|details| details.get(op_index))
18726                                            .cloned(),
18727                                        operation: operations
18728                                            .get(*op_index as usize)
18729                                            .map(CommitOperation::from),
18730                                    };
18731                                    journal_results.push(CommitOperationOutcome::Error {
18732                                        rejection: rejection.clone(),
18733                                    });
18734                                    rejections.push(rejection);
18735                                }
18736                            }
18737                        }
18738                        if self.retain_failed_commits {
18739                            self.retain_failed_commit(client_commit_id, &operations)?;
18740                        }
18741                        let outcome_status = if conflicts.is_empty() {
18742                            CommitOutcomeStatus::Rejected
18743                        } else {
18744                            CommitOutcomeStatus::Conflict
18745                        };
18746                        let persisted = self
18747                            .persist_commit_outcome(
18748                                client_commit_id,
18749                                outcome_status,
18750                                &journal_results,
18751                                Some(&operations),
18752                            )
18753                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18754                        persisted?;
18755                        batch.conflicts = !conflicts.is_empty();
18756                        batch.rejections = !rejections.is_empty();
18757                        batch.status = true;
18758                        batch.outcomes = true;
18759                        self.conflicts.extend(conflicts);
18760                        self.rejections.extend(rejections);
18761                        for operation in &operations {
18762                            self.overlay_dirty.table(&operation.table);
18763                        }
18764                    }
18765                }
18766                removed.push((index, self.outbox.remove(index)));
18767                if batch.status {
18768                    for operation in &operations {
18769                        if !self.record_row_scopes(
18770                            &mut batch,
18771                            &operation.table,
18772                            &operation.row_id,
18773                            false,
18774                        ) {
18775                            batch.table(&operation.table);
18776                        }
18777                    }
18778                }
18779                match status {
18780                    PushStatus::Applied | PushStatus::Cached => {
18781                        report.applied.push(client_commit_id.to_owned())
18782                    }
18783                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18784                }
18785            }
18786            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)) {
18787                self.prune_commit_outcomes()?;
18788            }
18789            self.rebuild_overlay_if_dirty()?;
18790            self.finish_observation("syncular_push_results", batch)
18791        })();
18792        if let Err(error) = persisted {
18793            self.rollback_observation("syncular_push_results");
18794            self.failed_commits = previous_failed;
18795            self.sync_needed = previous_sync_needed;
18796            for (index, commit) in removed.into_iter().rev() {
18797                self.outbox.insert(index, commit);
18798            }
18799            self.conflicts.truncate(conflict_count);
18800            self.rejections.truncate(rejection_count);
18801            self.overlay_dirty.restore(was_dirty.clone());
18802            report.applied.truncate(applied_count);
18803            report.rejected.truncate(rejected_count);
18804            report.retryable.truncate(retryable_count);
18805            return Err(error);
18806        }
18807        if !previous_sync_needed && self.sync_needed {
18808            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18809        }
18810        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18811        Ok(())
18812    }
18813
18814    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18815        status != PushStatus::Rejected
18816            || !results.iter().any(|result| {
18817                matches!(
18818                    result,
18819                    OpResult::Error {
18820                        code,
18821                        retryable: true,
18822                        ..
18823                    } if code == "sync.idempotency_cache_miss"
18824                )
18825            })
18826    }
18827
18828    // -- subscription sections ------------------------------------------------------
18829
18830    #[allow(clippy::too_many_arguments)]
18831    fn process_section(
18832        &mut self,
18833        transport: &mut dyn Transport,
18834        id: &str,
18835        status: SubStatus,
18836        reason_code: &str,
18837        effective_scopes: Vec<(String, Vec<String>)>,
18838        body: Vec<Frame>,
18839        sub_end: Option<(i64, Option<String>)>,
18840        meta: &RequestMeta,
18841        report: &mut SyncReport,
18842        coalesced_deletes: &HashSet<(i64, String, String)>,
18843    ) -> Result<(), SectionError> {
18844        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18845            return Ok(()); // unknown echo: ignore
18846        };
18847        if status != SubStatus::Active && sub_end.is_none() {
18848            return Err(SectionError::Abort(
18849                "sync.invalid_request".into(),
18850                "subscription section without SUB_END".into(),
18851            ));
18852        }
18853        match status {
18854            SubStatus::Revoked => {
18855                self.begin_observation("syncular_revocation")
18856                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18857                let previous = self.subs[sub_index].clone();
18858                let was_dirty = self.overlay_dirty.snapshot();
18859                let previous_outbox = self.outbox.clone();
18860                let previous_failed = self.failed_commits.clone();
18861                let previous_rejections = self.rejections.clone();
18862                let previous_conflicts = self.conflicts.clone();
18863                let revoked_count = report.revoked.len();
18864                let failed_count = report.failed.len();
18865                let result = (|| {
18866                    let mut batch = ChangeAccumulator::default();
18867                    let registered = self.window_unit_by_sub_id(id);
18868                    // §3.3: stop pulling, purge exactly the last effective grant.
18869                    let (table, effective) = {
18870                        let sub = &self.subs[sub_index];
18871                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18872                    };
18873                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18874                        SectionError::Abort(
18875                            "sync.unknown_table".to_owned(),
18876                            "revoked scope references an unknown table".to_owned(),
18877                        )
18878                    })?;
18879                    let dropped = self
18880                        .drop_failed_commits(|name, values| {
18881                            name == table
18882                                && !effective.is_empty()
18883                                && effective.iter().all(|(variable, allowed)| {
18884                                    scoped_table
18885                                        .scope_column(variable)
18886                                        .and_then(|column| values.get(column))
18887                                        .and_then(Value::as_str)
18888                                        .is_some_and(|value| {
18889                                            allowed.iter().any(|allowed| allowed == value)
18890                                        })
18891                                })
18892                        })
18893                        .map_err(|message| {
18894                            SectionError::Abort("storage.failed".to_owned(), message)
18895                        })?;
18896                    for commit in dropped {
18897                        batch.conflicts = true;
18898                        batch.rejections = true;
18899                        batch.outcomes = true;
18900                        for operation in commit.ops {
18901                            batch.table(&operation.table);
18902                        }
18903                    }
18904                    let purged = self.purge_scope_rows(&table, &effective);
18905                    match purged {
18906                        Ok(()) => {
18907                            self.record_scope_map(&mut batch, &table, &effective);
18908                            let sub = &mut self.subs[sub_index];
18909                            sub.state = SubState::Revoked;
18910                            sub.reason_code = Some(if reason_code.is_empty() {
18911                                "sync.scope_revoked".to_owned()
18912                            } else {
18913                                reason_code.to_owned()
18914                            });
18915                            report.revoked.push(id.to_owned());
18916                            let doomed_effective = effective;
18917                            let sub_table = table;
18918                            self.persist_sub(&self.subs[sub_index].clone())
18919                                .map_err(|message| {
18920                                    SectionError::Abort("storage.failed".into(), message)
18921                                })?;
18922                            let dropped = self
18923                                .drop_doomed_outbox(&sub_table, &doomed_effective)
18924                                .map_err(|message| {
18925                                    SectionError::Abort("storage.failed".to_owned(), message)
18926                                })?;
18927                            if dropped {
18928                                batch.status = true;
18929                                batch.rejections = true;
18930                                batch.outcomes = true;
18931                            }
18932                            // §5.9.7 B2: revocation deletes now-unauthorized blob
18933                            // bodies (evicted ≠ revoked).
18934                            self.delete_unreferenced_cached_blobs().map_err(|message| {
18935                                SectionError::Abort("storage.failed".into(), message)
18936                            })?;
18937                        }
18938                        Err(()) => {
18939                            // §3.3 fail closed: no local mapping — never clear by
18940                            // approximation; fatal configuration error.
18941                            let sub = &mut self.subs[sub_index];
18942                            sub.state = SubState::Failed;
18943                            sub.reason_code = Some("sync.scope_revoked".to_owned());
18944                            report.failed.push(id.to_owned());
18945                            self.persist_sub(&self.subs[sub_index].clone())
18946                                .map_err(|message| {
18947                                    SectionError::Abort("storage.failed".into(), message)
18948                                })?;
18949                        }
18950                    }
18951                    if let Some((base_key, unit)) = registered {
18952                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18953                    }
18954                    self.rebuild_overlay_if_dirty()
18955                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18956                    self.finish_observation("syncular_revocation", batch)
18957                        .map_err(|message| {
18958                            SectionError::Abort("storage.failed".to_owned(), message)
18959                        })?;
18960                    Ok(())
18961                })();
18962                if result.is_err() {
18963                    self.rollback_observation("syncular_revocation");
18964                    self.subs[sub_index] = previous;
18965                    self.overlay_dirty.restore(was_dirty.clone());
18966                    self.outbox = previous_outbox;
18967                    self.failed_commits = previous_failed;
18968                    self.rejections = previous_rejections;
18969                    self.conflicts = previous_conflicts;
18970                    report.revoked.truncate(revoked_count);
18971                    report.failed.truncate(failed_count);
18972                }
18973                result
18974            }
18975            SubStatus::Reset => {
18976                self.begin_observation("syncular_reset")
18977                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18978                let previous = self.subs[sub_index].clone();
18979                let was_dirty = self.overlay_dirty.snapshot();
18980                let reset_count = report.resets.len();
18981                let result = (|| {
18982                    let mut batch = ChangeAccumulator::default();
18983                    let registered = self.window_unit_by_sub_id(id);
18984                    // §4.6: discard cursor + bootstrap state, keep local rows —
18985                    // reset is a staleness signal, not a purge signal.
18986                    let sub = &mut self.subs[sub_index];
18987                    sub.cursor = -1;
18988                    sub.bootstrap_state = None;
18989                    sub.reason_code = Some(if reason_code.is_empty() {
18990                        "sync.cursor_expired".to_owned()
18991                    } else {
18992                        reason_code.to_owned()
18993                    });
18994                    report.resets.push(id.to_owned());
18995                    self.persist_sub(&self.subs[sub_index].clone())
18996                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18997                    if let Some((base_key, unit)) = registered {
18998                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18999                    }
19000                    self.finish_observation("syncular_reset", batch)
19001                        .map_err(|message| {
19002                            SectionError::Abort("storage.failed".to_owned(), message)
19003                        })?;
19004                    Ok(())
19005                })();
19006                if result.is_err() {
19007                    self.rollback_observation("syncular_reset");
19008                    self.subs[sub_index] = previous;
19009                    self.overlay_dirty.restore(was_dirty.clone());
19010                    report.resets.truncate(reset_count);
19011                }
19012                result
19013            }
19014            SubStatus::Active => {
19015                let fresh = meta
19016                    .fresh
19017                    .iter()
19018                    .find(|(fid, _)| fid == id)
19019                    .map(|(_, fresh)| *fresh)
19020                    .unwrap_or(false);
19021                let previous = self.subs[sub_index].clone();
19022                self.subs[sub_index].effective = Some(effective_scopes);
19023                let outcome = self
19024                    .apply_section_body(
19025                        transport,
19026                        sub_index,
19027                        body,
19028                        fresh,
19029                        meta,
19030                        report,
19031                        coalesced_deletes,
19032                    )
19033                    .and_then(|()| {
19034                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
19035                            SectionError::Abort(
19036                                "sync.invalid_request".into(),
19037                                "subscription section without SUB_END".into(),
19038                            )
19039                        })?;
19040                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
19041                            .map_err(|message| {
19042                                SectionError::Abort("storage.failed".into(), message)
19043                            })
19044                    });
19045                match outcome {
19046                    Ok(()) => {
19047                        if self.subs[sub_index].bootstrap_state.is_some() {
19048                            report.bootstrapping.push(id.to_owned());
19049                        }
19050                        Ok(())
19051                    }
19052                    Err(SectionError::FailClosed) => {
19053                        self.subs[sub_index] = previous.clone();
19054                        self.begin_observation("syncular_section_failure")
19055                            .map_err(|message| {
19056                                SectionError::Abort("storage.failed".into(), message)
19057                            })?;
19058                        let persisted = (|| {
19059                            let mut batch = ChangeAccumulator::default();
19060                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
19061                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
19062                            }
19063                            self.subs[sub_index].state = SubState::Failed;
19064                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
19065                            self.persist_sub(&self.subs[sub_index])?;
19066                            self.finish_observation("syncular_section_failure", batch)
19067                        })();
19068                        if let Err(message) = persisted {
19069                            self.rollback_observation("syncular_section_failure");
19070                            self.subs[sub_index] = previous;
19071                            return Err(SectionError::Abort("storage.failed".into(), message));
19072                        }
19073                        report.failed.push(id.to_owned());
19074                        Ok(())
19075                    }
19076                    Err(error) => {
19077                        // Frame/block transactions already completed. Preserve their
19078                        // rows and revisions, but leave this subscription cursor unchanged.
19079                        self.subs[sub_index] = previous;
19080                        Err(error)
19081                    }
19082                }
19083            }
19084        }
19085    }
19086
19087    fn apply_sub_end(
19088        &mut self,
19089        sub_index: usize,
19090        cursor: i64,
19091        bootstrap_state: Option<String>,
19092    ) -> Result<(), String> {
19093        #[cfg(feature = "bench-internals")]
19094        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
19095        let previous = self.subs[sub_index].clone();
19096        self.begin_observation("syncular_sub_end")?;
19097        let persisted = (|| {
19098            let mut batch = ChangeAccumulator::default();
19099            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
19100                && cursor >= 0
19101                && bootstrap_state.is_none();
19102            if completed {
19103                let table = self
19104                    .schema
19105                    .table(&previous.table)
19106                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
19107                let effective = previous.effective.as_deref().unwrap_or(&[]);
19108                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
19109                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19110                let rows = statement
19111                    .query_map(rusqlite::params![previous.table, cursor], |row| {
19112                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
19113                    })
19114                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19115                let mut covered = HashSet::new();
19116                for row in rows {
19117                    let (id, intent) =
19118                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19119                    let values: Map<String, Value> = serde_json::from_str(&intent)
19120                        .map_err(|_| "sync.local_corrupt".to_owned())?;
19121                    if !effective.is_empty()
19122                        && effective.iter().all(|(variable, allowed)| {
19123                            table
19124                                .scope_column(variable)
19125                                .and_then(|column| values.get(column))
19126                                .is_some_and(|value| {
19127                                    allowed.contains(
19128                                        &value
19129                                            .as_str()
19130                                            .map_or_else(|| value.to_string(), str::to_owned),
19131                                    )
19132                                })
19133                        })
19134                    {
19135                        covered.insert(id);
19136                    }
19137                }
19138                drop(statement);
19139                for id in covered {
19140                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
19141                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19142                    self.overlay_dirty.table(&previous.table);
19143                    batch.table(&previous.table);
19144                }
19145                self.rebuild_overlay_if_dirty()?;
19146                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
19147                    batch.window(&base_key, &previous.table, &unit);
19148                }
19149            }
19150            let sub = &mut self.subs[sub_index];
19151            sub.cursor = cursor;
19152            sub.bootstrap_state = bootstrap_state;
19153            // §4.6: a completed (or continuing) active end clears the reset /
19154            // revocation reason, matching the TS core's active-state persist.
19155            sub.reason_code = None;
19156            sub.synced_once = true;
19157            self.persist_sub(&self.subs[sub_index])?;
19158            self.finish_observation("syncular_sub_end", batch)
19159        })();
19160        if persisted.is_err() {
19161            self.rollback_observation("syncular_sub_end");
19162            self.subs[sub_index] = previous;
19163        }
19164        persisted
19165    }
19166
19167    #[allow(clippy::too_many_arguments)]
19168    fn apply_section_body(
19169        &mut self,
19170        transport: &mut dyn Transport,
19171        sub_index: usize,
19172        body: Vec<Frame>,
19173        fresh: bool,
19174        meta: &RequestMeta,
19175        report: &mut SyncReport,
19176        coalesced_deletes: &HashSet<(i64, String, String)>,
19177    ) -> Result<(), SectionError> {
19178        let mut saw_segment = false;
19179        for frame in body {
19180            match frame {
19181                Frame::Commit {
19182                    tables,
19183                    changes,
19184                    commit_seq,
19185                    ..
19186                } => {
19187                    if self
19188                        .apply_commit_frame_coalesced(
19189                            &tables,
19190                            changes,
19191                            commit_seq,
19192                            coalesced_deletes,
19193                        )
19194                        .map_err(|(c, m)| SectionError::Abort(c, m))?
19195                    {
19196                        report.commits_applied += 1;
19197                    }
19198                }
19199                Frame::SegmentInline { payload } => {
19200                    let segment = decode_rows_segment(&payload)
19201                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
19202                    self.progress.update(|p| {
19203                        p.phase = ProgressPhase::Import;
19204                        p.subscription_id = Some(self.subs[sub_index].id.clone());
19205                        p.table = Some(self.subs[sub_index].table.clone());
19206                        p.segment_id = None;
19207                        p.bytes_received = payload.len() as u64;
19208                        p.bytes_total = Some(payload.len() as u64);
19209                        p.rows_processed = 0;
19210                        p.rows_total =
19211                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
19212                    });
19213                    let first = !saw_segment;
19214                    saw_segment = true;
19215                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19216                    report.segment_rows_applied += applied;
19217                }
19218                Frame::SegmentRef {
19219                    segment_id,
19220                    byte_length,
19221                    media_type,
19222                    table,
19223                    row_count,
19224                    as_of_commit_seq,
19225                    scope_digest,
19226                    row_cursor,
19227                    next_row_cursor,
19228                    url,
19229                    url_expires_at_ms,
19230                    ..
19231                } => {
19232                    // §4.2: reject a descriptor whose mediaType was not
19233                    // advertised — never skip or guess.
19234                    let advertised = match media_type {
19235                        MediaType::Rows => {
19236                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
19237                                || meta.accept & ACCEPT_INLINE_ROWS != 0
19238                        }
19239                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
19240                    };
19241                    if !advertised {
19242                        return Err(SectionError::Abort(
19243                            "sync.invalid_request".to_owned(),
19244                            format!(
19245                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
19246                                media_type.name()
19247                            ),
19248                        ));
19249                    }
19250                    self.progress.update(|p| {
19251                        p.phase = ProgressPhase::Download;
19252                        p.subscription_id = Some(self.subs[sub_index].id.clone());
19253                        p.table = Some(table.clone());
19254                        p.segment_id = Some(segment_id.clone());
19255                        p.bytes_received = 0;
19256                        p.bytes_total = u64::try_from(byte_length).ok();
19257                        p.rows_processed = 0;
19258                        p.rows_total = u64::try_from(row_count).ok();
19259                    });
19260                    let observer = self.progress.clone();
19261                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
19262                    let bytes = if let Some(url) = url {
19263                        // §5.4: a url-carrying descriptor MUST be fetched
19264                        // from that URL; failure invalidates the whole
19265                        // descriptor (no fall-through to §5.5 — re-pull
19266                        // recovers, §1.4 rule 5).
19267                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
19268                            return Err(SectionError::Abort(
19269                                "sync.invalid_request".to_owned(),
19270                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
19271                                    .to_owned(),
19272                            ));
19273                        }
19274                        // §5.4: MUST NOT start a fetch at/past expiry.
19275                        if url_expires_at_ms.is_some_and(|exp| {
19276                            exp <= transport
19277                                .segment_fetch_started_at(&url)
19278                                .unwrap_or_else(|| self.clock_now_ms())
19279                        }) {
19280                            return Err(SectionError::Abort(
19281                                "sync.segment_expired".to_owned(),
19282                                format!(
19283                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
19284                                ),
19285                            ));
19286                        }
19287                        transport
19288                            .fetch_url(&url, &mut on_progress)
19289                            .map_err(|e| self.segment_fetch_failed(e))?
19290                    } else {
19291                        let requested_scopes_json =
19292                            canonical_scope_json(&self.subs[sub_index].requested);
19293                        transport
19294                            .download_segment(
19295                                &SegmentRequest {
19296                                    segment_id: segment_id.clone(),
19297                                    table,
19298                                    requested_scopes_json,
19299                                },
19300                                &mut on_progress,
19301                            )
19302                            .map_err(|e| self.segment_fetch_failed(e))?
19303                    };
19304                    // §5.1: verify the content address before applying.
19305                    on_progress(bytes.len() as u64);
19306                    let digest = Sha256::digest(&bytes);
19307                    let expected = segment_id
19308                        .strip_prefix("sha256:")
19309                        .unwrap_or(segment_id.as_str());
19310                    if bytes_to_hex(&digest) != expected {
19311                        // §5.1: the client discards the segment and re-pulls.
19312                        self.schedule_background_retry();
19313                        return Err(SectionError::Abort(
19314                            "sync.invalid_request".to_owned(),
19315                            "segment bytes do not match the content address (§5.1)".to_owned(),
19316                        ));
19317                    }
19318                    self.progress.update(|p| p.phase = ProgressPhase::Import);
19319                    if media_type == MediaType::Sqlite {
19320                        // §5.3: images are whole-table — a paged sqlite
19321                        // descriptor is invalid.
19322                        if row_cursor.is_some() || next_row_cursor.is_some() {
19323                            return Err(SectionError::Abort(
19324                                "sync.invalid_request".to_owned(),
19325                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
19326                                    .to_owned(),
19327                            ));
19328                        }
19329                        let first = !saw_segment;
19330                        saw_segment = true;
19331                        let applied = self.apply_sqlite_segment(
19332                            sub_index,
19333                            &bytes,
19334                            fresh && first,
19335                            row_count,
19336                            as_of_commit_seq,
19337                            &scope_digest,
19338                        )?;
19339                        report.segment_rows_applied += applied;
19340                    } else {
19341                        let segment = decode_rows_segment(&bytes).map_err(|e| {
19342                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
19343                        })?;
19344                        let first = row_cursor.is_none();
19345                        saw_segment = true;
19346                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19347                        report.segment_rows_applied += applied;
19348                    }
19349                }
19350                Frame::Unknown { .. } => {}
19351                Frame::Error { code, message, .. } => {
19352                    return Err(SectionError::Abort(code, message))
19353                }
19354                _ => {
19355                    return Err(SectionError::Abort(
19356                        "sync.invalid_request".to_owned(),
19357                        "unexpected frame inside a subscription section".to_owned(),
19358                    ));
19359                }
19360            }
19361        }
19362        Ok(())
19363    }
19364
19365    /// Apply one commit frame after dropping a scope move's duplicate delete
19366    /// when the same commit carries the row's upsert in another applied
19367    /// section (§2.2). A frame that carried both itself is untouched.
19368    fn apply_commit_frame_coalesced(
19369        &mut self,
19370        tables: &[String],
19371        changes: Vec<ssp2::model::Change>,
19372        commit_seq: i64,
19373        coalesced_deletes: &HashSet<(i64, String, String)>,
19374    ) -> Result<bool, (String, String)> {
19375        let changes: Vec<ssp2::model::Change> = changes
19376            .into_iter()
19377            .filter(|change| {
19378                if change.op != Op::Delete {
19379                    return true;
19380                }
19381                let Some(table) = tables.get(change.table_index as usize) else {
19382                    return true;
19383                };
19384                !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
19385            })
19386            .collect();
19387        if changes.is_empty() {
19388            return Ok(false);
19389        }
19390        self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
19391        Ok(true)
19392    }
19393
19394    // Both pull and realtime frames use the same durable observation boundary.
19395    fn apply_commit_frame(
19396        &mut self,
19397        tables: &[String],
19398        changes: &[ssp2::model::Change],
19399        commit_seq: Option<i64>,
19400    ) -> Result<(), (String, String)> {
19401        #[cfg(feature = "bench-internals")]
19402        let _phase = self.benchmark_phases.start(Phase::CommitApply);
19403        let was_dirty = self.overlay_dirty.snapshot();
19404        self.coalesce_unit(changes.len())
19405            .and_then(|()| self.begin_observation("syncular_commit"))
19406            .map_err(|message| ("storage.failed".into(), message))?;
19407        let applied = (|| {
19408            let mut batch = ChangeAccumulator::default();
19409            self.record_commit_changes(&mut batch, tables, changes);
19410            if let Some(sequence) = commit_seq {
19411                for change in changes {
19412                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
19413                        (
19414                            "sync.invalid_request".to_owned(),
19415                            "change tableIndex out of range".to_owned(),
19416                        )
19417                    })?;
19418                    if self
19419                        .outbox
19420                        .iter()
19421                        .flat_map(|commit| &commit.ops)
19422                        .any(|op| op.table == *table && op.row_id == change.row_id)
19423                    {
19424                        self.conn.execute("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)", rusqlite::params![table, change.row_id, sequence])
19425                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19426                    }
19427                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![table, change.row_id, sequence])
19428                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19429                }
19430            }
19431            self.apply_commit_changes(tables, changes)?;
19432            self.rebuild_overlay_if_dirty()
19433                .map_err(|message| ("storage.failed".into(), message))?;
19434            self.finish_observation("syncular_commit", batch)
19435                .map_err(|message| ("storage.failed".into(), message))
19436        })();
19437        if applied.is_err() {
19438            self.rollback_observation("syncular_commit");
19439            self.overlay_dirty.restore(was_dirty.clone());
19440        }
19441        applied
19442    }
19443
19444    fn apply_commit_changes(
19445        &mut self,
19446        tables: &[String],
19447        changes: &[ssp2::model::Change],
19448    ) -> Result<(), (String, String)> {
19449        // A clean overlay can reconcile changed primary keys independently
19450        // when their tables have no secondary unique constraints. Such a
19451        // constraint can change whether another row's pending write succeeds,
19452        // so those frames still require the complete FIFO replay below.
19453        let mirror_visible = !self.overlay_dirty.get()
19454            && self.failed_commits.is_empty()
19455            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
19456                || changes.iter().all(|change| {
19457                    tables
19458                        .get(change.table_index as usize)
19459                        .and_then(|name| self.schema.table(name))
19460                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
19461                }));
19462        for change in changes {
19463            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
19464                (
19465                    "sync.invalid_request".to_owned(),
19466                    "change tableIndex out of range".to_owned(),
19467                )
19468            })?;
19469            let table = self.schema.table(table_name).ok_or_else(|| {
19470                (
19471                    "sync.schema_mismatch".to_owned(),
19472                    format!("change targets unknown table {table_name:?}"),
19473                )
19474            })?;
19475            match change.op {
19476                Op::Upsert => {
19477                    let payload = change.row.as_ref().ok_or_else(|| {
19478                        (
19479                            "sync.invalid_request".to_owned(),
19480                            "upsert change without row payload".to_owned(),
19481                        )
19482                    })?;
19483                    // §5.11: decrypt encrypted columns on apply.
19484                    let row = {
19485                        #[cfg(feature = "bench-internals")]
19486                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
19487                        decode_row_bytes(table, payload, &self.encryption)
19488                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19489                    };
19490                    let version = change.row_version.unwrap_or(0);
19491                    let table_name = table.name.clone();
19492                    self.write_base_row(&table_name, &row, version)
19493                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19494                    if mirror_visible {
19495                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
19496                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19497                    }
19498                }
19499                Op::Delete => {
19500                    self.delete_base_row(table_name, &change.row_id)
19501                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19502                    if mirror_visible {
19503                        let sql = format!(
19504                            "DELETE FROM {} WHERE {}",
19505                            visible_table(table_name),
19506                            row_id_predicate(table)
19507                        );
19508                        self.conn
19509                            .prepare_cached(&sql)
19510                            .and_then(|mut statement| {
19511                                statement.execute(rusqlite::params![change.row_id])
19512                            })
19513                            .map_err(|error| {
19514                                ("sync.invalid_request".to_owned(), error.to_string())
19515                            })?;
19516                    }
19517                }
19518            }
19519        }
19520        if mirror_visible {
19521            {
19522                let changed_rows: HashSet<(&str, &str)> = changes
19523                    .iter()
19524                    .map(|change| {
19525                        (
19526                            tables[change.table_index as usize].as_str(),
19527                            change.row_id.as_str(),
19528                        )
19529                    })
19530                    .collect();
19531                let acknowledged = self
19532                    .acknowledged_operations(Some(&changed_rows), None)
19533                    .map_err(|error| ("storage.failed".to_owned(), error))?;
19534                self.apply_outbox_ops(&acknowledged)
19535                    .map_err(|message| ("storage.failed".into(), message))?;
19536                self.apply_outbox_ops(
19537                    self.outbox
19538                        .iter()
19539                        .flat_map(|commit| &commit.ops)
19540                        .filter(|op| {
19541                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19542                        }),
19543                )
19544                .map_err(|message| ("storage.failed".into(), message))?;
19545            }
19546            self.overlay_dirty.set(false);
19547        }
19548        Ok(())
19549    }
19550
19551    /// §5.6 segment application: validate against the generated schema,
19552    /// clear the grant on a fresh bootstrap's first page (fail closed
19553    /// without a mapping), then replace-or-upsert each row with its
19554    /// segment-carried server version (§5.2).
19555    fn apply_segment(
19556        &mut self,
19557        sub_index: usize,
19558        segment: &RowsSegment,
19559        first_fresh_page: bool,
19560    ) -> Result<u32, SectionError> {
19561        let (sub_table, effective) = {
19562            let sub = &self.subs[sub_index];
19563            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19564        };
19565        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19566            SectionError::Abort(
19567                "sync.schema_mismatch".to_owned(),
19568                format!("subscription table {sub_table:?} is not in the client schema"),
19569            )
19570        })?;
19571        // §5.2: the column table validates against the generated schema —
19572        // order, names, types, nullability; mismatch is fatal. §5.11: the
19573        // server sends the WIRE types (bytes for an encrypted column), so
19574        // validate against wire_columns.
19575        let matches = segment.table == table.name
19576            && segment.schema_version == self.schema.version
19577            && segment.columns.len() == table.wire_columns.len()
19578            && segment
19579                .columns
19580                .iter()
19581                .zip(table.wire_columns.iter())
19582                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19583        if !matches {
19584            return Err(SectionError::Abort(
19585                "sync.schema_mismatch".to_owned(),
19586                "segment column table does not match the generated schema (§5.2)".to_owned(),
19587            ));
19588        }
19589        let mut applied = 0u32;
19590        let empty = Vec::new();
19591        let blocks = if segment.blocks.is_empty() {
19592            std::slice::from_ref(&empty)
19593        } else {
19594            &segment.blocks
19595        };
19596        for (index, block) in blocks.iter().enumerate() {
19597            let clear = first_fresh_page && index == 0;
19598            if !clear && block.is_empty() {
19599                continue;
19600            }
19601            let was_dirty = self.overlay_dirty.snapshot();
19602            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19603                && self
19604                    .outbox
19605                    .iter()
19606                    .flat_map(|commit| &commit.ops)
19607                    .any(|op| op.table == table.name);
19608            let mirror_visible = !reconcile_pending
19609                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19610            self.coalesce_unit(block.len())
19611                .and_then(|()| self.begin_observation("syncular_segment_block"))
19612                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19613            let outcome = (|| {
19614                self.rebuild_overlay_if_dirty()
19615                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19616                let mut changed_keys = Vec::new();
19617                let mut batch = ChangeAccumulator::default();
19618                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19619                    batch.table(&table.name);
19620                }
19621                if clear {
19622                    self.purge_scope_rows(&table.name, &effective)
19623                        .map_err(|()| SectionError::FailClosed)?;
19624                }
19625                for (row_index, row) in block.iter().enumerate() {
19626                    let decrypted;
19627                    let values = if table.has_encrypted_columns() {
19628                        let mut values = row.values.clone();
19629                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19630                            .map_err(|message| {
19631                            SectionError::Abort("client.decrypt_failed".into(), message)
19632                        })?;
19633                        decrypted = values;
19634                        &decrypted
19635                    } else {
19636                        &row.values
19637                    };
19638                    self.write_base_row(&table.name, values, row.server_version)
19639                        .map_err(|message| {
19640                            SectionError::Abort("sync.invalid_request".into(), message)
19641                        })?;
19642                    if reconcile_pending {
19643                        changed_keys.push(
19644                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19645                                |message| SectionError::Abort("storage.failed".into(), message),
19646                            )?,
19647                        );
19648                    }
19649                    if mirror_visible {
19650                        self.write_row(
19651                            &visible_table(&table.name),
19652                            &table.name,
19653                            values,
19654                            row.server_version,
19655                        )
19656                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19657                    }
19658                    let processed = u64::from(applied) + row_index as u64 + 1;
19659                    if processed.is_multiple_of(1024) {
19660                        self.progress.update(|p| p.rows_processed = processed);
19661                    }
19662                }
19663                if reconcile_pending {
19664                    self.reconcile_imported_rows(&table, &mut changed_keys)
19665                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19666                }
19667                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19668                    self.apply_outbox_ops(
19669                        self.outbox
19670                            .iter()
19671                            .flat_map(|commit| &commit.ops)
19672                            .filter(|op| op.table == table.name),
19673                    )
19674                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19675                    self.overlay_dirty.set(false);
19676                } else {
19677                    self.rebuild_overlay_if_dirty()
19678                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19679                }
19680                self.finish_observation("syncular_segment_block", batch)
19681                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19682            })();
19683            if let Err(error) = outcome {
19684                self.rollback_observation("syncular_segment_block");
19685                self.overlay_dirty.restore(was_dirty.clone());
19686                return Err(error);
19687            }
19688            applied += block.len() as u32;
19689            self.progress
19690                .update(|p| p.rows_processed = u64::from(applied));
19691            std::thread::yield_now();
19692        }
19693        self.progress
19694            .update(|p| p.rows_processed = u64::from(applied));
19695        Ok(applied)
19696    }
19697
19698    /// §5.3 sqlite-image application: validate the in-file metadata
19699    /// against the descriptor, validate column names/order against the
19700    /// generated schema, run the §5.6 first-page clear when fresh, then
19701    /// replace-or-upsert every image row with its `_syncular_version`.
19702    /// Mechanics: the image lands in a temp file read through a second
19703    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
19704    /// Each image chunk commits its writes and revision before readers continue.
19705    fn apply_sqlite_segment(
19706        &mut self,
19707        sub_index: usize,
19708        bytes: &[u8],
19709        first_fresh_page: bool,
19710        row_count: i64,
19711        as_of_commit_seq: i64,
19712        scope_digest: &str,
19713    ) -> Result<u32, SectionError> {
19714        let invalid = |detail: &str| {
19715            SectionError::Abort(
19716                "sync.invalid_request".to_owned(),
19717                format!("sqlite segment rejected: {detail} (§5.3)"),
19718            )
19719        };
19720        let (sub_table, effective) = {
19721            let sub = &self.subs[sub_index];
19722            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19723        };
19724        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19725            SectionError::Abort(
19726                "sync.schema_mismatch".to_owned(),
19727                format!("subscription table {sub_table:?} is not in the client schema"),
19728            )
19729        })?;
19730
19731        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19732        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19733        let img = match rusqlite::Connection::open_with_flags(
19734            &path,
19735            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19736        ) {
19737            Ok(conn) => conn,
19738            Err(_) => {
19739                let _ = std::fs::remove_file(&path);
19740                return Err(invalid("bytes do not open as a SQLite database"));
19741            }
19742        };
19743        let outcome = self.apply_sqlite_image(
19744            &img,
19745            &table,
19746            first_fresh_page,
19747            &effective,
19748            row_count,
19749            as_of_commit_seq,
19750            scope_digest,
19751        );
19752        drop(img);
19753        let _ = std::fs::remove_file(&path);
19754        outcome
19755    }
19756
19757    #[allow(clippy::too_many_arguments)]
19758    fn apply_sqlite_image(
19759        &mut self,
19760        img: &rusqlite::Connection,
19761        table: &crate::schema::TableSchema,
19762        first_fresh_page: bool,
19763        effective: &[(String, Vec<String>)],
19764        row_count: i64,
19765        as_of_commit_seq: i64,
19766        scope_digest: &str,
19767    ) -> Result<u32, SectionError> {
19768        let invalid = |detail: String| {
19769            SectionError::Abort(
19770                "sync.invalid_request".to_owned(),
19771                format!("sqlite segment rejected: {detail} (§5.3)"),
19772            )
19773        };
19774
19775        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
19776        //    one row).
19777        type MetaRow = (i64, String, i64, i64, String, i64, i64);
19778        let meta: MetaRow = img
19779            .query_row(
19780                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19781                        \"scopeDigest\", \"rowCount\",
19782                        (SELECT count(*) FROM _syncular_segment)
19783                 FROM _syncular_segment",
19784                [],
19785                |row| {
19786                    Ok((
19787                        row.get(0)?,
19788                        row.get(1)?,
19789                        row.get(2)?,
19790                        row.get(3)?,
19791                        row.get(4)?,
19792                        row.get(5)?,
19793                        row.get(6)?,
19794                    ))
19795                },
19796            )
19797            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19798        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19799        if meta_count != 1 {
19800            return Err(invalid(format!(
19801                "_syncular_segment must contain exactly one row, found {meta_count}"
19802            )));
19803        }
19804        if format != 1 {
19805            return Err(invalid(format!("format {format}")));
19806        }
19807        if meta_table != table.name {
19808            return Err(invalid(format!("image table {meta_table:?}")));
19809        }
19810        if schema_version != i64::from(self.schema.version) {
19811            return Err(invalid(format!("schemaVersion {schema_version}")));
19812        }
19813        if pin != as_of_commit_seq {
19814            return Err(invalid(format!("asOfCommitSeq {pin}")));
19815        }
19816        if digest != scope_digest {
19817            return Err(invalid("scopeDigest mismatch".to_owned()));
19818        }
19819        if meta_rows != row_count {
19820            return Err(invalid(format!("rowCount {meta_rows}")));
19821        }
19822
19823        // 2. Column names and order vs the generated schema (§5.3 rule 3).
19824        let mut names: Vec<String> = Vec::new();
19825        let mut primary_keys: Vec<String> = Vec::new();
19826        {
19827            let mut stmt = img
19828                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19829                .map_err(|_| invalid("image data table missing".to_owned()))?;
19830            let mut rows = stmt
19831                .query([])
19832                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19833            while let Some(row) = rows
19834                .next()
19835                .map_err(|_| invalid("image data table unreadable".to_owned()))?
19836            {
19837                if row
19838                    .get::<_, i64>(5)
19839                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19840                    > 0
19841                {
19842                    primary_keys.push(row.get(1).map_err(|error| {
19843                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19844                    })?);
19845                }
19846                names.push(
19847                    row.get::<_, String>(1)
19848                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19849                );
19850            }
19851        }
19852        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19853        expected.push("_syncular_version");
19854        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19855            return Err(SectionError::Abort(
19856                "sync.schema_mismatch".to_owned(),
19857                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19858            ));
19859        }
19860
19861        if primary_keys != vec![table.primary_key.clone()] {
19862            return Err(invalid(
19863                "image primary key does not match generated schema".into(),
19864            ));
19865        }
19866        let actual_count: i64 = img
19867            .query_row(
19868                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19869                [],
19870                |row| row.get(0),
19871            )
19872            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19873        if actual_count != row_count {
19874            return Err(invalid("image row count does not match descriptor".into()));
19875        }
19876        let insert = self.insert_row_sql(&base_table(&table.name), table);
19877        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19878        let column_list = names
19879            .iter()
19880            .map(|n| quote_ident(n))
19881            .collect::<Vec<_>>()
19882            .join(", ");
19883        let mut stmt = img
19884            .prepare(&format!(
19885                "SELECT {column_list} FROM {} ORDER BY {}",
19886                quote_ident(&table.name),
19887                quote_ident(&table.primary_key)
19888            ))
19889            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19890        let mut rows = stmt
19891            .query([])
19892            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19893        let mut applied = 0u32;
19894        loop {
19895            let clear = applied == 0 && first_fresh_page;
19896            let was_dirty = self.overlay_dirty.snapshot();
19897            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19898                && self
19899                    .outbox
19900                    .iter()
19901                    .flat_map(|commit| &commit.ops)
19902                    .any(|op| op.table == table.name);
19903            let mirror_visible = !reconcile_pending
19904                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19905            // A chunk holds up to 1,024 rows (§5.3).
19906            self.coalesce_unit(1024)
19907                .and_then(|()| self.begin_observation("syncular_image_chunk"))
19908                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19909            let result = (|| {
19910                self.rebuild_overlay_if_dirty()
19911                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19912                let mut changed_keys = Vec::new();
19913                let mut batch = ChangeAccumulator::default();
19914                if clear {
19915                    if self.scoped_rows_exist(&table.name, effective) {
19916                        batch.table(&table.name);
19917                    }
19918                    self.purge_scope_rows(&table.name, effective)
19919                        .map_err(|()| SectionError::FailClosed)?;
19920                }
19921                let mut processed = 0u32;
19922                {
19923                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19924                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19925                    })?;
19926                    let mut mirror = if mirror_visible {
19927                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19928                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19929                        })?)
19930                    } else {
19931                        None
19932                    };
19933                    for _ in 0..1024 {
19934                        let Some(row) = rows.next().map_err(|error| {
19935                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19936                        })?
19937                        else {
19938                            break;
19939                        };
19940                        for (i, column) in table.columns.iter().enumerate() {
19941                            let cell = row.get_ref(i).map_err(|error| {
19942                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19943                            })?;
19944                            let param = image_cell_param(column, cell).map_err(invalid)?;
19945                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
19946                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19947                            })?;
19948                            if let Some(mirror) = &mut mirror {
19949                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
19950                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19951                                })?;
19952                            }
19953                        }
19954                        let version_index = table.columns.len();
19955                        let version: i64 = row.get(version_index).map_err(|error| {
19956                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19957                        })?;
19958                        if version < 1 {
19959                            return Err(invalid("image row version must be positive".into()));
19960                        }
19961                        ins.raw_bind_parameter(version_index + 1, version)
19962                            .map_err(|error| {
19963                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19964                            })?;
19965                        ins.raw_execute().map_err(|error| {
19966                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19967                        })?;
19968                        if let Some(mirror) = &mut mirror {
19969                            mirror
19970                                .raw_bind_parameter(version_index + 1, version)
19971                                .map_err(|error| {
19972                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19973                                })?;
19974                            mirror.raw_execute().map_err(|error| {
19975                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19976                            })?;
19977                        }
19978                        if reconcile_pending {
19979                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19980                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19981                            )?);
19982                        }
19983                        processed += 1;
19984                    }
19985                }
19986                if processed > 0 {
19987                    batch.table(&table.name);
19988                    self.overlay_dirty.table(&table.name);
19989                }
19990                if reconcile_pending {
19991                    self.reconcile_imported_rows(table, &mut changed_keys)
19992                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19993                }
19994                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19995                    self.apply_outbox_ops(
19996                        self.outbox
19997                            .iter()
19998                            .flat_map(|commit| &commit.ops)
19999                            .filter(|op| op.table == table.name),
20000                    )
20001                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
20002                    self.overlay_dirty.set(false);
20003                } else {
20004                    self.rebuild_overlay_if_dirty()
20005                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
20006                }
20007                self.finish_observation("syncular_image_chunk", batch)
20008                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
20009                Ok(processed)
20010            })();
20011            let processed = match result {
20012                Ok(processed) => processed,
20013                Err(error) => {
20014                    self.rollback_observation("syncular_image_chunk");
20015                    self.overlay_dirty.restore(was_dirty.clone());
20016                    return Err(error);
20017                }
20018            };
20019            applied += processed;
20020            self.progress
20021                .update(|p| p.rows_processed = u64::from(applied));
20022            std::thread::yield_now();
20023            if i64::from(applied) == actual_count {
20024                break;
20025            }
20026        }
20027        Ok(applied)
20028    }
20029
20030    /// Restore only imported and pending row identities before FIFO replay. All pending
20031    /// identities participate: a server row can free a unique value for a different edit.
20032    /// The caller owns the chunk transaction and publishes its revision after replay.
20033    fn reconcile_imported_rows(
20034        &self,
20035        table: &TableSchema,
20036        keys: &mut Vec<SqlValue>,
20037    ) -> Result<(), String> {
20038        let visible = visible_table(&table.name);
20039        let base = base_table(&table.name);
20040        let pk = quote_ident(&table.primary_key);
20041        let predicate = row_id_predicate(table);
20042        let mut lookup = self.conn.prepare_cached(&format!(
20043            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
20044        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20045        let mut deleted = HashSet::new();
20046        for op in self
20047            .outbox
20048            .iter()
20049            .flat_map(|commit| &commit.ops)
20050            .filter(|op| op.table == table.name)
20051        {
20052            if op.upsert {
20053                if let Some(values) = &op.values {
20054                    let key = json_to_column_value(
20055                        &table.columns[table.pk_index],
20056                        values.get(&table.primary_key),
20057                    )?;
20058                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
20059                }
20060            } else if deleted.insert(&op.row_id) {
20061                keys.extend(
20062                    lookup
20063                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
20064                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20065                        .collect::<Result<Vec<_>, _>>()
20066                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
20067                );
20068            }
20069        }
20070        // Delete the complete affected set before restoring any base value, so
20071        // old optimistic unique values cannot collide with the new server rows.
20072        for chunk in keys.chunks(400) {
20073            let holes = vec!["?"; chunk.len()].join(",");
20074            self.conn
20075                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
20076                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
20077                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20078        }
20079        let updates = table
20080            .columns
20081            .iter()
20082            .map(|column| column.name.as_str())
20083            .chain(std::iter::once("_syncular_version"))
20084            .filter(|name| *name != table.primary_key)
20085            .map(|name| {
20086                let name = quote_ident(name);
20087                format!("{name}=excluded.{name}")
20088            })
20089            .collect::<Vec<_>>()
20090            .join(",");
20091        for chunk in keys.chunks(400) {
20092            let holes = vec!["?"; chunk.len()].join(",");
20093            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
20094                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
20095                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20096        }
20097        Ok(())
20098    }
20099
20100    // -- application-authorized local purge ---------------------------------------
20101
20102    fn compile_local_data_purge(
20103        &self,
20104        input: &LocalDataPurgeInput,
20105    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
20106        let invalid = |message: String| format!("sync.invalid_request: {message}");
20107        if input.purge_id.is_empty()
20108            || input.purge_id.len() > 128
20109            || !is_local_operation_code_like(&input.purge_id)
20110        {
20111            return Err(invalid(
20112                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
20113            ));
20114        }
20115        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
20116            return Err(invalid(format!(
20117                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
20118            )));
20119        }
20120
20121        let mut deduplicated: BTreeMap<
20122            String,
20123            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
20124        > = BTreeMap::new();
20125        for target in &input.targets {
20126            let table = self.schema.table(&target.table).ok_or_else(|| {
20127                invalid(format!(
20128                    "local purge names unknown table {:?}",
20129                    target.table
20130                ))
20131            })?;
20132            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
20133                return Err(invalid(format!(
20134                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
20135                    target.table
20136                )));
20137            }
20138            let mut selectors = Vec::with_capacity(target.selectors.len());
20139            let mut canonical_selectors = BTreeMap::new();
20140            for (column_name, raw_values) in &target.selectors {
20141                let Some((column_index, column)) = table
20142                    .columns
20143                    .iter()
20144                    .enumerate()
20145                    .find(|(_, column)| column.name == *column_name)
20146                else {
20147                    return Err(invalid(format!(
20148                        "local purge target {:?} names unknown column {:?}",
20149                        target.table, column_name
20150                    )));
20151                };
20152                let encrypted = table
20153                    .encrypted_columns
20154                    .iter()
20155                    .any(|candidate| candidate.index == column_index);
20156                if column.ty != ColumnType::String || encrypted {
20157                    return Err(invalid(format!(
20158                        "local purge selector {:?}.{:?} must be a plaintext string column",
20159                        target.table, column_name
20160                    )));
20161                }
20162                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
20163                    return Err(invalid(format!(
20164                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
20165                        target.table, column_name
20166                    )));
20167                }
20168                let mut values = raw_values.clone();
20169                values.sort();
20170                values.dedup();
20171                if values.iter().any(|value| {
20172                    value.is_empty()
20173                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
20174                        || !is_local_operation_code_like(value)
20175                }) {
20176                    return Err(invalid(format!(
20177                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
20178                    )));
20179                }
20180                selectors.push((column_name.clone(), values.clone()));
20181                canonical_selectors.insert(column_name.clone(), values);
20182            }
20183            let canonical = LocalDataPurgeTarget {
20184                table: target.table.clone(),
20185                selectors: canonical_selectors,
20186            };
20187            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
20188            deduplicated.insert(
20189                key,
20190                (
20191                    CompiledLocalDataPurgeTarget {
20192                        table: target.table.clone(),
20193                        selectors,
20194                    },
20195                    canonical,
20196                ),
20197            );
20198        }
20199        let targets = deduplicated
20200            .values()
20201            .map(|(compiled, _)| compiled.clone())
20202            .collect::<Vec<_>>();
20203        let canonical = deduplicated
20204            .values()
20205            .map(|(_, target)| target.clone())
20206            .collect::<Vec<_>>();
20207        let canonical_plan =
20208            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
20209        Ok((targets, canonical_plan))
20210    }
20211
20212    fn local_purge_base_row_ids(
20213        &self,
20214        targets: &[CompiledLocalDataPurgeTarget],
20215    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
20216        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
20217        for target in targets {
20218            let table = self
20219                .schema
20220                .table(&target.table)
20221                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
20222            let mut clauses = Vec::with_capacity(target.selectors.len());
20223            let mut params = Vec::new();
20224            for (column, values) in &target.selectors {
20225                clauses.push(format!(
20226                    "{} IN ({})",
20227                    quote_ident(column),
20228                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
20229                ));
20230                params.extend(values.iter().cloned().map(SqlValue::Text));
20231            }
20232            let sql = format!(
20233                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
20234                quote_ident(&table.primary_key),
20235                base_table(&target.table),
20236                clauses.join(" AND ")
20237            );
20238            let mut statement = self
20239                .conn
20240                .prepare(&sql)
20241                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20242            let rows = statement
20243                .query_map(rusqlite::params_from_iter(params), |row| {
20244                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
20245                    render_row_id_json(Some(&value))
20246                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
20247                })
20248                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20249            let ids = by_table.entry(target.table.clone()).or_default();
20250            for row in rows {
20251                ids.insert(
20252                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
20253                );
20254            }
20255        }
20256        Ok(by_table)
20257    }
20258
20259    fn local_purge_values_match(
20260        target: &CompiledLocalDataPurgeTarget,
20261        values: &Map<String, Value>,
20262    ) -> bool {
20263        target.selectors.iter().all(|(column, allowed)| {
20264            values
20265                .get(column)
20266                .and_then(Value::as_str)
20267                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
20268        })
20269    }
20270
20271    /// Apply one host-authorized local security purge. The host MUST gate the
20272    /// corresponding subscriptions first; this primitive owns local SQLite
20273    /// cleanup and never grants/revokes server authority by itself.
20274    pub fn purge_local_data(
20275        &mut self,
20276        input: &LocalDataPurgeInput,
20277    ) -> Result<LocalDataPurgeResult, String> {
20278        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
20279        let meta_key = format!("localPurge:{}", input.purge_id);
20280        let applied_plan = self
20281            .conn
20282            .query_row(
20283                "SELECT value FROM _syncular_meta WHERE key = ?1",
20284                rusqlite::params![meta_key],
20285                |row| row.get::<_, String>(0),
20286            )
20287            .optional()
20288            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20289        if let Some(applied_plan) = applied_plan {
20290            if applied_plan != canonical_plan {
20291                return Err(format!(
20292                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
20293                    input.purge_id
20294                ));
20295            }
20296            return Ok(LocalDataPurgeResult {
20297                already_applied: true,
20298                purged_rows: 0,
20299                dropped_commits: 0,
20300            });
20301        }
20302
20303        // RFC 0005 D7: the container file cannot be a purge target (its name is
20304        // not in the running schema), so purge drops it as a fixed step, before
20305        // any mirror row is touched and unconditionally.
20306        let _ = self.drop_previous_version();
20307
20308        let prior_outbox = self.outbox.clone();
20309        let prior_failed = self.failed_commits.clone();
20310        let prior_rejections = self.rejections.clone();
20311        let prior_conflicts = self.conflicts.clone();
20312        let prior_overlay_dirty = self.overlay_dirty.snapshot();
20313        self.begin_observation("syncular_local_purge")?;
20314        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
20315            let row_ids = self.local_purge_base_row_ids(&targets)?;
20316            let retained_dropped = self.drop_failed_commits(|table, values| {
20317                targets.iter().any(|target| {
20318                    target.table == table && Self::local_purge_values_match(target, values)
20319                })
20320            })?;
20321            let doomed = self
20322                .outbox
20323                .iter()
20324                .filter(|commit| {
20325                    commit.ops.iter().any(|operation| {
20326                        let matching_targets = targets
20327                            .iter()
20328                            .filter(|target| target.table == operation.table)
20329                            .collect::<Vec<_>>();
20330                        if matching_targets.is_empty() {
20331                            return false;
20332                        }
20333                        if row_ids
20334                            .get(&operation.table)
20335                            .is_some_and(|ids| ids.contains(&operation.row_id))
20336                        {
20337                            return true;
20338                        }
20339                        operation.values.as_ref().is_some_and(|values| {
20340                            matching_targets
20341                                .iter()
20342                                .any(|target| Self::local_purge_values_match(target, values))
20343                        })
20344                    })
20345                })
20346                .cloned()
20347                .collect::<Vec<_>>();
20348            let doomed_ids = doomed
20349                .iter()
20350                .map(|commit| commit.client_commit_id.clone())
20351                .collect::<BTreeSet<_>>();
20352            let mut batch = ChangeAccumulator::default();
20353            for commit in &retained_dropped {
20354                batch.conflicts = true;
20355                batch.rejections = true;
20356                batch.outcomes = true;
20357                for operation in &commit.ops {
20358                    batch.table(&operation.table);
20359                }
20360            }
20361            let mut rejections = Vec::new();
20362            for commit in &doomed {
20363                for operation in &commit.ops {
20364                    batch.table(&operation.table);
20365                }
20366                let results = commit
20367                    .ops
20368                    .iter()
20369                    .enumerate()
20370                    .map(|(op_index, operation)| {
20371                        let rejection = RejectionRecord {
20372                            client_commit_id: commit.client_commit_id.clone(),
20373                            op_index: op_index as i32,
20374                            code: "client.local_data_purged".to_owned(),
20375                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
20376                            retryable: false,
20377                            details: None,
20378                            operation: Some(CommitOperation::from(operation)),
20379                        };
20380                        rejections.push(rejection.clone());
20381                        CommitOperationOutcome::Error { rejection }
20382                    })
20383                    .collect::<Vec<_>>();
20384                self.persist_commit_outcome(
20385                    &commit.client_commit_id,
20386                    CommitOutcomeStatus::Rejected,
20387                    &results,
20388                    Some(&commit.ops),
20389                )?;
20390                self.delete_outbox_persisted(&commit.client_commit_id)?;
20391            }
20392            if !doomed.is_empty() {
20393                self.outbox
20394                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
20395                self.rejections.extend(rejections);
20396                self.prune_commit_outcomes()?;
20397                batch.status = true;
20398                batch.rejections = true;
20399                batch.outcomes = true;
20400            }
20401
20402            let mut purged_rows = 0usize;
20403            for (table, ids) in &row_ids {
20404                if ids.is_empty() {
20405                    continue;
20406                }
20407                batch.table(table);
20408                for row_id in ids {
20409                    self.delete_base_row(table, row_id)?;
20410                    purged_rows += 1;
20411                }
20412            }
20413            self.rebuild_overlay_if_dirty()?;
20414            self.delete_unreferenced_cached_blobs()?;
20415            self.conn
20416                .execute(
20417                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20418                    rusqlite::params![meta_key, canonical_plan],
20419                )
20420                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20421            Ok((
20422                batch,
20423                LocalDataPurgeResult {
20424                    already_applied: false,
20425                    purged_rows,
20426                    dropped_commits: doomed.len(),
20427                },
20428            ))
20429        })();
20430        let (batch, result) = match applied {
20431            Ok(value) => value,
20432            Err(error) => {
20433                self.rollback_observation("syncular_local_purge");
20434                self.outbox = prior_outbox;
20435                self.failed_commits = prior_failed;
20436                self.rejections = prior_rejections;
20437                self.conflicts = prior_conflicts;
20438                self.overlay_dirty.restore(prior_overlay_dirty.clone());
20439                return Err(error);
20440            }
20441        };
20442        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
20443            self.rollback_observation("syncular_local_purge");
20444            self.outbox = prior_outbox;
20445            self.failed_commits = prior_failed;
20446            self.rejections = prior_rejections;
20447            self.conflicts = prior_conflicts;
20448            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20449            return Err(error);
20450        }
20451        self.cancel_sync_round();
20452        Ok(result)
20453    }
20454
20455    /// Application-authorized recovery of the replicated projection. Unlike a
20456    /// local security purge, this retains the entire outbox, device identity,
20457    /// lease, outcomes, subscription registrations, and protected bookkeeping.
20458    /// The projection reset, subscription rewind, optimistic replay, and
20459    /// idempotency marker share one savepoint for interruption safety.
20460    pub fn rebootstrap_local_data(
20461        &mut self,
20462        input: &LocalDataRebootstrapInput,
20463    ) -> Result<LocalDataRebootstrapResult, String> {
20464        if input.rebootstrap_id.is_empty()
20465            || input.rebootstrap_id.len() > 128
20466            || !is_local_operation_code_like(&input.rebootstrap_id)
20467        {
20468            return Err(
20469                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
20470                    .to_owned(),
20471            );
20472        }
20473        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20474        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20475            let (retained_commits, reset_subscriptions) =
20476                decode_local_rebootstrap_receipt(&persisted)?;
20477            return Ok(LocalDataRebootstrapResult {
20478                already_applied: true,
20479                retained_commits,
20480                reset_subscriptions,
20481            });
20482        }
20483        if self.stopped || self.schema_floor.is_some() {
20484            return Err(
20485                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20486                    .to_owned(),
20487            );
20488        }
20489
20490        let retained_commits = self.outbox.len();
20491        let reset_subscriptions = self.subs.len();
20492        let prior_subs = self.subs.clone();
20493        let prior_upgrading = self.upgrading;
20494        let prior_stopped = self.stopped;
20495        let prior_schema_floor = self.schema_floor.clone();
20496        let prior_overlay_dirty = self.overlay_dirty.snapshot();
20497        let prior_sync_needed = self.sync_needed;
20498        let prior_sync_intents = self.sync_intent_queue.clone();
20499        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20500
20501        let prior_active_round = self.active_round;
20502        self.begin_observation("syncular_local_rebootstrap")?;
20503        let mut batch = ChangeAccumulator::default();
20504        let applied = (|| -> Result<(), String> {
20505            self.run_schema_reset_observed(&mut batch, false)?;
20506            self.conn
20507                .execute(
20508                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20509                    rusqlite::params![meta_key, receipt],
20510                )
20511                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20512            self.sync_needed = true;
20513            self.sync_intent_queue.push_back(SyncIntent::Interactive);
20514            batch.status = true;
20515            Ok(())
20516        })();
20517        if let Err(error) = applied {
20518            self.rollback_observation("syncular_local_rebootstrap");
20519            self.active_round = prior_active_round;
20520            self.subs = prior_subs;
20521            self.upgrading = prior_upgrading;
20522            self.stopped = prior_stopped;
20523            self.schema_floor = prior_schema_floor;
20524            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20525            self.sync_needed = prior_sync_needed;
20526            self.sync_intent_queue = prior_sync_intents;
20527            return Err(error);
20528        }
20529        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20530            self.rollback_observation("syncular_local_rebootstrap");
20531            self.active_round = prior_active_round;
20532            self.subs = prior_subs;
20533            self.upgrading = prior_upgrading;
20534            self.stopped = prior_stopped;
20535            self.schema_floor = prior_schema_floor;
20536            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20537            self.sync_needed = prior_sync_needed;
20538            self.sync_intent_queue = prior_sync_intents;
20539            return Err(error);
20540        }
20541
20542        Ok(LocalDataRebootstrapResult {
20543            already_applied: false,
20544            retained_commits,
20545            reset_subscriptions,
20546        })
20547    }
20548
20549    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
20550
20551    /// Delete base rows matching the effective scopes; `Err(())` = no local
20552    /// scope-column mapping for a key (the fail-closed case).
20553    fn purge_scope_rows(
20554        &mut self,
20555        table_name: &str,
20556        effective: &[(String, Vec<String>)],
20557    ) -> Result<(), ()> {
20558        if effective.is_empty() {
20559            return Ok(());
20560        }
20561        let table = self.schema.table(table_name).ok_or(())?.clone();
20562        let mut clauses = Vec::new();
20563        let mut params: Vec<SqlValue> = Vec::new();
20564        for (variable, values) in effective {
20565            let column = table.scope_column(variable).ok_or(())?;
20566            let placeholders: Vec<String> = values
20567                .iter()
20568                .map(|v| {
20569                    params.push(SqlValue::Text(v.clone()));
20570                    "?".to_owned()
20571                })
20572                .collect();
20573            clauses.push(format!(
20574                "{} IN ({})",
20575                quote_ident(column),
20576                placeholders.join(", ")
20577            ));
20578        }
20579        let sql = format!(
20580            "DELETE FROM {} WHERE {}",
20581            base_table(table_name),
20582            clauses.join(" AND ")
20583        );
20584        self.overlay_dirty.table(table_name);
20585        self.conn
20586            .execute(&sql, rusqlite::params_from_iter(&params))
20587            .map_err(|_| ())?;
20588        self.conn
20589            .execute(
20590                &format!(
20591                    "DELETE FROM {} WHERE {}",
20592                    visible_table(table_name),
20593                    clauses.join(" AND ")
20594                ),
20595                rusqlite::params_from_iter(&params),
20596            )
20597            .map_err(|_| ())?;
20598        Ok(())
20599    }
20600
20601    /// §3.3: drop pending commits whose upserts provably land in the
20602    /// revoked effective scopes — whole-commit, never per-operation.
20603    fn drop_doomed_outbox(
20604        &mut self,
20605        table_name: &str,
20606        effective: &[(String, Vec<String>)],
20607    ) -> Result<bool, String> {
20608        if effective.is_empty() {
20609            return Ok(false);
20610        }
20611        let Some(table) = self.schema.table(table_name).cloned() else {
20612            return Ok(false);
20613        };
20614        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20615        for (variable, values) in effective {
20616            match table.scope_column(variable) {
20617                Some(column) => mappings.push((column, values)),
20618                None => return Ok(false), // not provable without a mapping
20619            }
20620        }
20621        let doomed: Vec<OutboxCommit> = self
20622            .outbox
20623            .iter()
20624            .filter(|commit| {
20625                commit.ops.iter().any(|op| {
20626                    op.upsert
20627                        && op.table == table_name
20628                        && op.values.as_ref().is_some_and(|values| {
20629                            mappings
20630                                .iter()
20631                                .all(|(column, allowed)| match values.get(*column) {
20632                                    Some(Value::String(s)) => allowed.contains(s),
20633                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20634                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20635                                    _ => false,
20636                                })
20637                        })
20638                })
20639            })
20640            .cloned()
20641            .collect();
20642        if doomed.is_empty() {
20643            return Ok(false);
20644        }
20645        let mut rejections = Vec::new();
20646        for commit in &doomed {
20647            let results = commit
20648                .ops
20649                .iter()
20650                .enumerate()
20651                .map(|(op_index, operation)| {
20652                    let rejection = RejectionRecord {
20653                        client_commit_id: commit.client_commit_id.clone(),
20654                        op_index: op_index as i32,
20655                        code: "sync.scope_revoked".to_owned(),
20656                        message: "the commit was dropped because its effective scope was revoked"
20657                            .to_owned(),
20658                        retryable: false,
20659                        details: None,
20660                        operation: Some(CommitOperation::from(operation)),
20661                    };
20662                    rejections.push(rejection.clone());
20663                    CommitOperationOutcome::Error { rejection }
20664                })
20665                .collect::<Vec<_>>();
20666            self.persist_commit_outcome(
20667                &commit.client_commit_id,
20668                CommitOutcomeStatus::Rejected,
20669                &results,
20670                Some(&commit.ops),
20671            )?;
20672            self.delete_outbox_persisted(&commit.client_commit_id)?;
20673        }
20674        self.prune_commit_outcomes()?;
20675        let doomed_ids = doomed
20676            .iter()
20677            .map(|commit| commit.client_commit_id.as_str())
20678            .collect::<BTreeSet<_>>();
20679        self.outbox
20680            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20681        self.rejections.extend(rejections);
20682        Ok(true)
20683    }
20684
20685    // -- blobs (§5.9) ----------------------------------------------------------------
20686
20687    /// §5.9.7: hash bytes into the content address, cache them, queue the
20688    /// upload (flushed before the next push, B4). Returns the canonical
20689    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
20690    /// column value.
20691    pub fn upload_blob(
20692        &mut self,
20693        bytes: &[u8],
20694        media_type: Option<String>,
20695        name: Option<String>,
20696    ) -> Result<Value, String> {
20697        let blob_id = blob_id_for(bytes);
20698        let now = self.clock_now_ms();
20699        let tx = self
20700            .conn
20701            .transaction()
20702            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20703        tx.execute(
20704            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20705             ON CONFLICT(blob_id) DO NOTHING",
20706            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20707        )
20708        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20709        tx.execute(
20710            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20711            rusqlite::params![blob_id, media_type, now],
20712        )
20713        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20714        tx.commit()
20715            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20716        self.enforce_blob_cache_cap();
20717        let mut obj = Map::new();
20718        obj.insert("blobId".to_owned(), Value::from(blob_id));
20719        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20720        if let Some(mt) = media_type {
20721            obj.insert("mediaType".to_owned(), Value::from(mt));
20722        }
20723        if let Some(n) = name {
20724            obj.insert("name".to_owned(), Value::from(n));
20725        }
20726        Ok(Value::Object(obj))
20727    }
20728
20729    /// §5.9.7: resolve an owned blob body without driver encoding. A
20730    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
20731    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
20732    pub fn fetch_blob_bytes(
20733        &mut self,
20734        transport: &mut dyn Transport,
20735        blob_id_or_ref: &str,
20736    ) -> Result<FetchedBlob, (String, String)> {
20737        let simple = |m: String| ("client.failed".to_owned(), m);
20738        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20739            blob_id_or_ref.to_owned()
20740        } else {
20741            let value: Value = serde_json::from_str(blob_id_or_ref)
20742                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20743            value
20744                .get("blobId")
20745                .and_then(Value::as_str)
20746                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20747                .to_owned()
20748        };
20749        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20750            return Ok(cached);
20751        }
20752        if !self.transport_enabled {
20753            return Err(("sync.offline".into(), "transport gate is closed".into()));
20754        }
20755        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
20756        // blob.not_found) verbatim so the harness can assert on it. The
20757        // authorized endpoint serves bytes inline OR (always-issue, presign
20758        // configured) a signed url the client fetches directly — no host auth,
20759        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
20760        #[cfg(feature = "bench-internals")]
20761        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20762        let bytes = match transport
20763            .blob_download(&blob_id)
20764            .map_err(|e| (e.code, e.message))?
20765        {
20766            BlobDownload::Bytes(bytes) => bytes,
20767            BlobDownload::Url {
20768                url,
20769                url_expires_at_ms,
20770            } => {
20771                // §5.9.5: MUST NOT start a fetch at/past expiry.
20772                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20773                    return Err((
20774                        "sync.segment_expired".to_owned(),
20775                        format!(
20776                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20777                        ),
20778                    ));
20779                }
20780                transport
20781                    .fetch_blob_url(&url)
20782                    .map_err(|e| (e.code, e.message))?
20783            }
20784        };
20785        #[cfg(feature = "bench-internals")]
20786        drop(download_phase);
20787        #[cfg(feature = "bench-internals")]
20788        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20789        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
20790        if blob_id_for(&bytes) != blob_id {
20791            return Err(simple(format!(
20792                "blob content address mismatch for {blob_id}"
20793            )));
20794        }
20795        #[cfg(feature = "bench-internals")]
20796        drop(validate_phase);
20797        #[cfg(feature = "bench-internals")]
20798        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20799        let now = self.clock_now_ms();
20800        self.conn
20801            .execute(
20802                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20803                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20804            )
20805            .map_err(|e| simple(e.to_string()))?;
20806        self.conn
20807            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20808            .map_err(|e| simple(e.to_string()))?;
20809        self.enforce_blob_cache_cap();
20810        #[cfg(feature = "bench-internals")]
20811        drop(insert_phase);
20812        self.get_cached_blob_bytes(&blob_id)
20813            .map_err(simple)?
20814            .ok_or_else(|| simple("blob cache write failed".to_owned()))
20815    }
20816
20817    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20818        #[cfg(feature = "bench-internals")]
20819        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20820        let mut stmt = self
20821            .conn
20822            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20823            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20824        let mut rows = stmt
20825            .query(rusqlite::params![blob_id])
20826            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20827        if let Some(row) = rows
20828            .next()
20829            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20830        {
20831            let bytes: Vec<u8> = row
20832                .get(0)
20833                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20834            let byte_length: i64 = row
20835                .get(1)
20836                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20837            let media_type: Option<String> = row
20838                .get(2)
20839                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20840            return Ok(Some(FetchedBlob {
20841                blob_id: blob_id.to_owned(),
20842                byte_length,
20843                bytes,
20844                media_type,
20845            }));
20846        }
20847        Ok(None)
20848    }
20849
20850    /// §5.9.7 B4: upload every queued blob before push.
20851    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20852        let pending: Vec<(String, Option<String>)> = {
20853            let mut stmt = self
20854                .conn
20855                .prepare(
20856                    "SELECT blob_id, media_type FROM (
20857                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20858                      UNION ALL
20859                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20860                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20861                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20862                    ) ORDER BY created_at_ms, blob_id",
20863                )
20864                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20865            let rows = stmt
20866                .query_map([], |row| {
20867                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20868                })
20869                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20870            rows.collect::<Result<_, _>>()
20871                .map_err(|error| match error {
20872                    rusqlite::Error::InvalidColumnType(..)
20873                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20874                        "sync.local_corrupt",
20875                        "Pending blob upload metadata is invalid",
20876                    ),
20877                    _ => TransportError::new("client.failed", error.to_string()),
20878                })?
20879        };
20880        let mut uploads = Vec::new();
20881        for (blob_id, media_type) in pending {
20882            let bytes = (|| {
20883                let stored: Option<(SqlValue, SqlValue)> = self
20884                    .conn
20885                    .query_row(
20886                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20887                        rusqlite::params![blob_id],
20888                        |row| Ok((row.get(0)?, row.get(1)?)),
20889                    )
20890                    .optional()
20891                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20892                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20893                    return Err(TransportError::new(
20894                        "sync.local_corrupt",
20895                        "Pending blob upload body is missing or corrupt",
20896                    ));
20897                };
20898                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20899                    return Err(TransportError::new(
20900                        "sync.local_corrupt",
20901                        "Pending blob upload body is missing or corrupt",
20902                    ));
20903                }
20904                Ok(bytes)
20905            })();
20906            let failed = bytes.is_err();
20907            uploads.push(crate::round::PendingUpload {
20908                id: blob_id,
20909                bytes,
20910                media_type,
20911            });
20912            if failed {
20913                break;
20914            }
20915        }
20916        Ok(uploads)
20917    }
20918
20919    fn visible_blob_ids_sql(&self) -> String {
20920        let mut selects = Vec::new();
20921        for table in &self.schema.tables {
20922            for column in table
20923                .columns
20924                .iter()
20925                .filter(|column| column.ty == ColumnType::BlobRef)
20926            {
20927                let identifier = quote_ident(&column.name);
20928                selects.push(format!(
20929                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20930                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20931                     AND json_type({identifier}, '$.blobId') = 'text'",
20932                    quote_ident(&table.name),
20933                ));
20934            }
20935        }
20936        if selects.is_empty() {
20937            "SELECT NULL AS blob_id WHERE 0".to_owned()
20938        } else {
20939            selects.join(" UNION ")
20940        }
20941    }
20942
20943    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
20944    /// staged uploads pin bodies directly; no derived refcount is stored.
20945    fn enforce_blob_cache_cap(&self) {
20946        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20947            return;
20948        };
20949        let mut total: i64 = self
20950            .conn
20951            .query_row(
20952                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20953                [],
20954                |row| row.get(0),
20955            )
20956            .unwrap_or(0);
20957        if total <= max_bytes {
20958            return;
20959        }
20960        let query = format!(
20961            "SELECT blob_id, byte_length FROM _syncular_blobs
20962             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20963               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20964               AND blob_id NOT IN ({})
20965             ORDER BY created_at_ms ASC, blob_id ASC",
20966            self.visible_blob_ids_sql(),
20967        );
20968        let candidates: Vec<(String, i64)> = {
20969            let Ok(mut stmt) = self.conn.prepare(&query) else {
20970                return;
20971            };
20972            let Ok(rows) = stmt.query_map([], |row| {
20973                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20974            }) else {
20975                return;
20976            };
20977            rows.filter_map(Result::ok).collect()
20978        };
20979        for (blob_id, byte_length) in candidates {
20980            if total <= max_bytes {
20981                break;
20982            }
20983            let _ = self.conn.execute(
20984                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20985                rusqlite::params![blob_id],
20986            );
20987            total -= byte_length;
20988        }
20989    }
20990
20991    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20992        #[cfg(feature = "bench-internals")]
20993        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20994        if !self.schema_has_blobs() {
20995            return Ok(());
20996        }
20997        self.rebuild_overlay_if_dirty()?;
20998        self.conn
20999            .execute(
21000                &format!(
21001                    "DELETE FROM _syncular_blobs
21002                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
21003                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
21004                   AND blob_id NOT IN ({})",
21005                    self.visible_blob_ids_sql(),
21006                ),
21007                [],
21008            )
21009            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21010        Ok(())
21011    }
21012
21013    // -- local row storage ----------------------------------------------------------
21014
21015    /// The cached per-table primary-key upsert SQL for `full_table` (see
21016    /// the `insert_sql` field: built once, reused per row).
21017    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
21018        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
21019            return sql.clone();
21020        }
21021        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
21022        columns.push(quote_ident("_syncular_version"));
21023        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
21024        let primary_key = quote_ident(&table.primary_key);
21025        let updates = columns
21026            .iter()
21027            .filter(|column| **column != primary_key)
21028            .map(|column| format!("{column}=excluded.{column}"))
21029            .collect::<Vec<_>>()
21030            .join(", ");
21031        let sql = format!(
21032            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
21033            columns.join(", "),
21034            placeholders.join(", ")
21035        );
21036        self.insert_sql
21037            .borrow_mut()
21038            .insert(full_table.to_owned(), sql.clone());
21039        sql
21040    }
21041
21042    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
21043        self.overlay_dirty.table(table_name);
21044        self.write_row(&base_table(table_name), table_name, row, version)
21045    }
21046
21047    fn write_row(
21048        &self,
21049        full_table: &str,
21050        table_name: &str,
21051        row: &Row,
21052        version: i64,
21053    ) -> Result<(), String> {
21054        #[cfg(feature = "bench-internals")]
21055        let _phase = self.benchmark_phases.start(Phase::RowWrite);
21056        let table = self
21057            .schema
21058            .table(table_name)
21059            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
21060        let sql = self.insert_row_sql(full_table, table);
21061        let mut stmt = self
21062            .conn
21063            .prepare_cached(&sql)
21064            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21065        let params = row
21066            .iter()
21067            .map(RowParam::Cell)
21068            .chain(std::iter::once(RowParam::Version(version)));
21069        stmt.execute(rusqlite::params_from_iter(params))
21070            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21071        Ok(())
21072    }
21073
21074    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
21075        let table = self
21076            .schema
21077            .table(table_name)
21078            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
21079        self.overlay_dirty.table(table_name);
21080        let sql = format!(
21081            "DELETE FROM {} WHERE {}",
21082            base_table(table_name),
21083            row_id_predicate(table)
21084        );
21085        let mut stmt = self
21086            .conn
21087            .prepare_cached(&sql)
21088            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21089        stmt.execute(rusqlite::params![row_id])
21090            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21091        Ok(())
21092    }
21093
21094    fn has_acknowledged_rows(&self) -> bool {
21095        self.conn
21096            .query_row(
21097                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
21098                [],
21099                |row| row.get(0),
21100            )
21101            .expect("read acknowledged intent")
21102    }
21103
21104    fn acknowledged_operations(
21105        &self,
21106        rows: Option<&HashSet<(&str, &str)>>,
21107        tables: Option<&BTreeSet<&str>>,
21108    ) -> Result<Vec<OutboxOp>, String> {
21109        let keys = rows.map(|rows| {
21110            serde_json::to_string(
21111                &rows
21112                    .iter()
21113                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
21114                    .collect::<Vec<_>>(),
21115            )
21116            .expect("row keys")
21117        });
21118        let mut statement = self
21119            .conn
21120            .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
21121            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21122        let rows = statement
21123            .query_map(
21124                rusqlite::params![
21125                    keys,
21126                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
21127                ],
21128                |row| {
21129                    #[cfg(test)]
21130                    self.acknowledged_replay_count
21131                        .set(self.acknowledged_replay_count.get() + 1);
21132                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
21133                },
21134            )
21135            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21136        rows.map(|row| {
21137            let (op, intent) =
21138                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21139            let op: CommitOperation =
21140                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
21141            let table = self
21142                .schema
21143                .table(&op.table)
21144                .ok_or_else(|| "sync.unknown_table".to_owned())?;
21145            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
21146                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
21147            } else {
21148                op.values
21149            };
21150            Ok(OutboxOp {
21151                table: op.table,
21152                row_id: op.row_id,
21153                upsert: op.op == "upsert",
21154                base_version: op.base_version,
21155                values,
21156                insert: op.insert,
21157            })
21158        })
21159        .collect()
21160    }
21161
21162    fn drop_acknowledged_rows(
21163        &self,
21164        matches: impl Fn(&str, &Map<String, Value>) -> bool,
21165    ) -> Result<Vec<OutboxCommit>, String> {
21166        let mut statement = self
21167            .conn
21168            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
21169            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21170        let rows = statement
21171            .query_map([], |row| {
21172                Ok((
21173                    row.get::<_, String>(0)?,
21174                    row.get::<_, String>(1)?,
21175                    row.get::<_, String>(2)?,
21176                    row.get::<_, String>(3)?,
21177                ))
21178            })
21179            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21180        let mut doomed = HashSet::new();
21181        for row in rows {
21182            let (id, table, intent, row_id) =
21183                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21184            let values =
21185                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
21186            let base_matches = self
21187                .schema
21188                .table(&table)
21189                .map(|schema| self.stored_row(schema, &row_id, true))
21190                .transpose()?
21191                .flatten()
21192                .is_some_and(|(base, _)| {
21193                    let schema = self.schema.table(&table).expect("acknowledged table");
21194                    let values = schema
21195                        .columns
21196                        .iter()
21197                        .zip(base.iter())
21198                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
21199                        .collect();
21200                    matches(&table, &values)
21201                });
21202            if matches(&table, &values) || base_matches {
21203                doomed.insert(id);
21204            }
21205        }
21206        let mut dropped = Vec::new();
21207        for id in doomed {
21208            let mut statement = self
21209                .conn
21210                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
21211                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21212            let operations = statement
21213                .query_map([&id], |row| row.get::<_, String>(0))
21214                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21215            let mut ops = Vec::new();
21216            for operation in operations {
21217                let json = operation
21218                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21219                let op: CommitOperation =
21220                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
21221                self.overlay_dirty.table(&op.table);
21222                ops.push(OutboxOp {
21223                    table: op.table,
21224                    row_id: op.row_id,
21225                    upsert: op.op == "upsert",
21226                    base_version: op.base_version,
21227                    values: op.values,
21228                    insert: op.insert,
21229                });
21230            }
21231            dropped.push(OutboxCommit {
21232                client_commit_id: id.clone(),
21233                ops,
21234            });
21235            self.conn
21236                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
21237                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21238        }
21239        Ok(dropped)
21240    }
21241
21242    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
21243    /// the outbox changed since the last rebuild (the no-op sync round).
21244    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
21245        if self.overlay_dirty.get() {
21246            self.rebuild_overlay()?;
21247        }
21248        Ok(())
21249    }
21250
21251    /// §7.1: local reads see outbox state applied optimistically — rebuild
21252    /// every visible table as (base server state) + (pending outbox replay
21253    /// on top). Optimistic rows carry version `-1`.
21254    fn rebuild_overlay(&mut self) -> Result<(), String> {
21255        #[cfg(feature = "bench-internals")]
21256        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
21257        #[cfg(test)]
21258        self.overlay_rebuild_count
21259            .set(self.overlay_rebuild_count.get() + 1);
21260        let dirty = self.overlay_dirty.snapshot();
21261        let tables: Vec<_> = self
21262            .schema
21263            .tables
21264            .iter()
21265            .filter(|table| {
21266                dirty
21267                    .as_ref()
21268                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
21269            })
21270            .cloned()
21271            .collect();
21272        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
21273        self.begin_observation("syncular_overlay")?;
21274        let rebuilt = (|| {
21275            for table in &tables {
21276                for index in &table.fts_indexes {
21277                    self.drop_fts_triggers(index)?;
21278                }
21279                let visible = visible_table(&table.name);
21280                let base = base_table(&table.name);
21281                self.conn
21282                    .execute_batch(&format!(
21283                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
21284                    ))
21285                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21286            }
21287            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
21288            self.apply_outbox_ops(
21289                acknowledged
21290                    .iter()
21291                    .filter(|op| names.contains(op.table.as_str())),
21292            )?;
21293            for (commit, initial) in &self.failed_commits {
21294                for operation in &commit.ops {
21295                    if !names.contains(operation.table.as_str()) || !operation.upsert {
21296                        continue;
21297                    }
21298                    if let Some(table) = self.schema.table(&operation.table) {
21299                        if self.visible_row(table, &operation.row_id)?.is_none()
21300                            && operation.presented_values(table).is_some_and(|values| {
21301                                table
21302                                    .columns
21303                                    .iter()
21304                                    .all(|column| values.contains_key(&column.name))
21305                            })
21306                        {
21307                            let key = serde_json::to_string(&(
21308                                operation.table.clone(),
21309                                operation.row_id.clone(),
21310                            ))
21311                            .expect("row key serialization");
21312                            if let Some(Value::Object(values)) = initial.get(&key) {
21313                                self.apply_outbox_ops(&[OutboxOp {
21314                                    values: Some(values.clone()),
21315                                    ..operation.clone()
21316                                }])?;
21317                            }
21318                        }
21319                    }
21320                }
21321                self.apply_outbox_ops(
21322                    commit
21323                        .ops
21324                        .iter()
21325                        .filter(|op| names.contains(op.table.as_str())),
21326                )?;
21327            }
21328            for commit in &self.outbox {
21329                self.apply_outbox_ops(
21330                    commit
21331                        .ops
21332                        .iter()
21333                        .filter(|op| names.contains(op.table.as_str())),
21334                )?;
21335            }
21336            for table in &tables {
21337                for index in &table.fts_indexes {
21338                    self.rebuild_fts_projection(table, index)?;
21339                    self.create_fts_triggers(table, index)?;
21340                }
21341            }
21342            self.conn
21343                .execute_batch("RELEASE syncular_overlay")
21344                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21345            Ok(())
21346        })();
21347        if rebuilt.is_err() {
21348            self.conn
21349                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
21350                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21351        } else {
21352            self.overlay_dirty.set(false);
21353        }
21354        rebuilt
21355    }
21356
21357    /// §7.1 replay: every operation is applied over the current visible
21358    /// state. An operation that no longer applies (a server row now holds its
21359    /// unique value) stays pending and invisible until a later replay admits
21360    /// it or the server answers its push.
21361    fn apply_outbox_ops<'a>(
21362        &self,
21363        ops: impl IntoIterator<Item = &'a OutboxOp>,
21364    ) -> Result<(), String> {
21365        #[cfg(feature = "bench-internals")]
21366        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
21367        for op in ops {
21368            #[cfg(feature = "bench-internals")]
21369            if let Some(phase) = &mut phase {
21370                phase.unit();
21371            }
21372            if op.upsert {
21373                if let Some((table, values)) = self
21374                    .schema
21375                    .table(&op.table)
21376                    .filter(|table| table.indexes.iter().any(|index| index.unique))
21377                    .and_then(|table| Some((table, op.presented_values(table)?)))
21378                {
21379                    let mut intended: Map<String, Value> = self
21380                        .visible_row(table, &op.row_id)?
21381                        .map(|(row, _)| {
21382                            table
21383                                .columns
21384                                .iter()
21385                                .zip(&row)
21386                                .map(|(column, value)| {
21387                                    (column.name.clone(), column_value_to_json(value))
21388                                })
21389                                .collect()
21390                        })
21391                        .unwrap_or_default();
21392                    intended.extend(values.into_owned());
21393                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
21394                        continue;
21395                    }
21396                }
21397            }
21398            self.apply_outbox_op(op)?;
21399        }
21400        Ok(())
21401    }
21402
21403    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
21404        let Some(table) = self.schema.table(&op.table) else {
21405            return Ok(());
21406        };
21407        let visible = visible_table(&table.name);
21408        if !op.upsert {
21409            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
21410            self.conn
21411                .prepare_cached(&sql)
21412                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
21413                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21414            return Ok(());
21415        }
21416        // §7.1: an insert-intent operation that lacks a non-nullable column
21417        // cannot encode; it stays invisible until the send-time prepass drops
21418        // it as sync.outbox_incompatible (§7.4.4).
21419        let Some(values) = op.presented_values(table) else {
21420            return Ok(());
21421        };
21422        // §7.1: the overlay applies the operation's PRESENT columns
21423        // over the current local row. A partial operation over an
21424        // absent local row leaves it absent — the server answers it
21425        // with sync.row_deleted or sync.row_missing (§6.2). An
21426        // insert-intent operation presents every column.
21427        let full = table
21428            .columns
21429            .iter()
21430            .all(|column| values.contains_key(&column.name));
21431        let local = self.visible_row(table, &op.row_id)?;
21432        if local.is_none() && !full {
21433            return Ok(());
21434        }
21435        let mut row: Row = Vec::with_capacity(table.columns.len());
21436        for (index, column) in table.columns.iter().enumerate() {
21437            let incoming = match values.get(&column.name) {
21438                Some(raw) => json_to_column_value(
21439                    if raw.get("$bytes").is_some()
21440                        && table
21441                            .encrypted_columns
21442                            .iter()
21443                            .any(|encrypted| encrypted.index == index)
21444                    {
21445                        &table.wire_columns[index]
21446                    } else {
21447                        column
21448                    },
21449                    Some(raw),
21450                ),
21451                None => Ok(local
21452                    .as_ref()
21453                    .and_then(|(values, _)| values.get(index).cloned())
21454                    .flatten()),
21455            };
21456            row.push(incoming.map_err(|_| {
21457                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
21458            })?);
21459        }
21460        let version = local.as_ref().map_or(-1, |(_, version)| *version);
21461        self.write_row(&visible, &table.name, &row, version)
21462            .map_err(|error| {
21463                if error.starts_with("UNIQUE constraint failed:") {
21464                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
21465                } else {
21466                    error
21467                }
21468            })
21469    }
21470
21471    /// Whether the row is present in the overlay. Existence reads only the
21472    /// key: locked encrypted values stay opaque, and decoding the complete row
21473    /// would reject a valid plain write.
21474    fn visible_row_exists(&self, table: &TableSchema, row_id: &str) -> Result<bool, String> {
21475        self.conn
21476            .query_row(
21477                &format!(
21478                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
21479                    visible_table(&table.name),
21480                    row_id_predicate(table)
21481                ),
21482                rusqlite::params![row_id],
21483                |row| row.get(0),
21484            )
21485            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
21486    }
21487
21488    /// The visible (optimistic) row's values plus version, or `None` when the
21489    /// row is absent from the overlay.
21490    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
21491        self.stored_row(table, row_id, false)
21492    }
21493
21494    fn stored_row(
21495        &self,
21496        table: &TableSchema,
21497        row_id: &str,
21498        base: bool,
21499    ) -> Result<Option<(Row, i64)>, String> {
21500        let sql = format!(
21501            "SELECT * FROM {} WHERE {}",
21502            if base {
21503                base_table(&table.name)
21504            } else {
21505                visible_table(&table.name)
21506            },
21507            row_id_predicate(table)
21508        );
21509        let mut stmt = self
21510            .conn
21511            .prepare_cached(&sql)
21512            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21513        let mut rows = stmt
21514            .query(rusqlite::params![row_id])
21515            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21516        let Some(row) = rows
21517            .next()
21518            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21519        else {
21520            return Ok(None);
21521        };
21522        let mut values = Vec::with_capacity(table.columns.len());
21523        for (index, column) in table.columns.iter().enumerate() {
21524            let raw = row
21525                .get_ref(index)
21526                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21527            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21528                if table
21529                    .encrypted_columns
21530                    .iter()
21531                    .any(|encrypted| encrypted.index == index)
21532                {
21533                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21534                    continue;
21535                }
21536            }
21537            let json = sql_ref_to_json(column, raw);
21538            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21539                "sync.local_corrupt: persisted local row value is invalid".to_owned()
21540            })?);
21541        }
21542        let version: i64 = row
21543            .get(table.columns.len())
21544            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21545        Ok(Some((values, version)))
21546    }
21547
21548    // -- realtime (§8) ---------------------------------------------------------------
21549
21550    /// §8.8: the configured realtime policy.
21551    #[must_use]
21552    pub fn realtime_policy(&self) -> RealtimePolicy {
21553        self.realtime_policy
21554    }
21555
21556    /// §8.8: the explicit realtime availability state.
21557    #[must_use]
21558    pub fn realtime_state(&self) -> RealtimeState {
21559        self.realtime_state
21560    }
21561
21562    /// §8.8: set the realtime policy. Refused while the socket is connected:
21563    /// a policy change never silently re-routes an ownership state.
21564    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21565        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21566            return Err(
21567                "sync.invalid_request: disconnect realtime before changing the realtime policy"
21568                    .to_owned(),
21569            );
21570        }
21571        self.realtime_policy = policy;
21572        match policy {
21573            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21574            RealtimePolicy::Required | RealtimePolicy::Optional
21575                if self.realtime_state == RealtimeState::Disabled =>
21576            {
21577                self.set_realtime_state(RealtimeState::Disconnected, None);
21578            }
21579            RealtimePolicy::Required | RealtimePolicy::Optional => {}
21580        }
21581        Ok(())
21582    }
21583
21584    /// Record an availability transition, keeping the reason code bounded and
21585    /// code-like (§7.6) and clearing it where it cannot apply.
21586    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21587        self.realtime_state = state;
21588        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21589        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21590            self.realtime_reason_code = None;
21591            self.realtime_retry_delay_ms = None;
21592        }
21593    }
21594
21595    #[must_use]
21596    fn realtime_connected(&self) -> bool {
21597        self.realtime_state == RealtimeState::Connected
21598    }
21599
21600    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21601        if !self.transport_enabled {
21602            return Err("sync.offline: transport gate is closed".into());
21603        }
21604        if self.realtime_policy == RealtimePolicy::Off {
21605            return Err(
21606                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21607            );
21608        }
21609        if self.realtime_connected() {
21610            return Ok(());
21611        }
21612        self.realtime_state = RealtimeState::Connecting;
21613        match transport.realtime_connect_for_client(&self.client_id) {
21614            Ok(()) => {
21615                self.set_realtime_state(RealtimeState::Connected, None);
21616                Ok(())
21617            }
21618            Err(error) => {
21619                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21620                Err(format!("{}: {}", error.code, error.message))
21621            }
21622        }
21623    }
21624
21625    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21626        self.cancel_sync_round();
21627        // A deliberately disconnected or policy-disabled client has nothing
21628        // to release; a `lost` socket still does (the host owns the handle).
21629        if self.realtime_state == RealtimeState::Disconnected
21630            || self.realtime_state == RealtimeState::Disabled
21631        {
21632            return;
21633        }
21634        let _ = transport.realtime_close();
21635        self.set_realtime_state(
21636            if self.realtime_policy == RealtimePolicy::Off {
21637                RealtimeState::Disabled
21638            } else {
21639                RealtimeState::Disconnected
21640            },
21641            None,
21642        );
21643        self.presence.clear(); // §8.6.1: presence is per-connection
21644    }
21645
21646    /// §8.8: the host observed the socket end without disconnecting it (a
21647    /// peer close, an I/O error, a failed send; the native transport reports
21648    /// these as `Inbound::Disconnected`). Releases the transport, moves a
21649    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
21650    /// one background retry intent so the host reconnects and catches up.
21651    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21652        if self.realtime_state != RealtimeState::Connected {
21653            return;
21654        }
21655        self.cancel_sync_round();
21656        let _ = transport.realtime_close();
21657        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21658        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21659        self.presence.clear();
21660    }
21661
21662    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
21663    /// document for `scope_key`. Requires a live socket; the document is
21664    /// ephemeral (lost on disconnect). Authorization is the connection's
21665    /// registration (§8.6.3) — an unheld key is rejected loudly by the
21666    /// server with `presence.forbidden`.
21667    pub fn set_presence(
21668        &mut self,
21669        transport: &mut dyn Transport,
21670        scope_key: &str,
21671        doc: Option<&Value>,
21672    ) -> Result<(), String> {
21673        if !self.transport_enabled {
21674            return Err("sync.offline: transport gate is closed".into());
21675        }
21676        if !self.realtime_connected() {
21677            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21678        }
21679        let text = encode_presence_publish(scope_key, doc);
21680        transport
21681            .realtime_send(&text)
21682            .map_err(|e| format!("{}: {}", e.code, e.message))
21683    }
21684
21685    /// §8.6: the peers currently present on a scope key (ephemeral).
21686    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21687        self.presence
21688            .get(scope_key)
21689            .map(|peers| peers.values().cloned().collect())
21690            .unwrap_or_default()
21691    }
21692
21693    /// §8.6 apply an inbound presence fanout to the local map.
21694    fn apply_presence(
21695        &mut self,
21696        scope_key: String,
21697        kind: Option<PresenceKind>,
21698        actor_id: Option<String>,
21699        client_id: Option<String>,
21700        doc: Option<Value>,
21701        error: Option<String>,
21702    ) {
21703        // The publisher-directed error variant is out-of-band; nothing to
21704        // record in the peer map.
21705        if error.is_some() {
21706            return;
21707        }
21708        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21709            return;
21710        };
21711        let peer_key = format!("{actor_id} {client_id}");
21712        match kind {
21713            PresenceKind::Leave => {
21714                if let Some(peers) = self.presence.get_mut(&scope_key) {
21715                    peers.remove(&peer_key);
21716                    if peers.is_empty() {
21717                        self.presence.remove(&scope_key);
21718                    }
21719                }
21720            }
21721            _ => {
21722                let doc = match doc {
21723                    Some(Value::Object(_)) => doc.unwrap(),
21724                    _ => return,
21725                };
21726                self.presence.entry(scope_key).or_default().insert(
21727                    peer_key,
21728                    PresencePeer {
21729                        actor_id,
21730                        client_id,
21731                        doc,
21732                    },
21733                );
21734            }
21735        }
21736    }
21737
21738    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
21739    pub fn on_realtime_text(&mut self, text: &str) {
21740        if self.stopped || !self.transport_enabled || self.security_preflight {
21741            return;
21742        }
21743        match parse_control(text) {
21744            Ok(ControlMessage::Hello { requires_sync, .. }) => {
21745                if requires_sync {
21746                    // §8.1: pull before trusting the socket for continuity.
21747                    self.set_sync_needed(true, true);
21748                }
21749            }
21750            Ok(ControlMessage::Presence {
21751                scope_key,
21752                kind,
21753                actor_id,
21754                client_id,
21755                doc,
21756                error,
21757                ..
21758            }) => {
21759                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21760            }
21761            Ok(ControlMessage::Wake { .. }) => {
21762                // §8.3: any wake-up means "run a pull soon", never data.
21763                self.set_sync_needed(true, true);
21764            }
21765            _ => {}
21766        }
21767    }
21768
21769    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
21770    /// a pull response per section; an unapplied delta is a wake-up.
21771    /// Mailbox hosts send durable acknowledgements on their I/O executor.
21772    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21773        let mut transport = crate::round::DownloadResults::default();
21774        self.on_realtime_binary(&mut transport, bytes);
21775        transport.controls
21776    }
21777
21778    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21779        if self.stopped || !self.transport_enabled || self.security_preflight {
21780            return;
21781        }
21782        #[cfg(feature = "bench-internals")]
21783        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21784        let message = match decode_message(bytes) {
21785            Ok(m) if m.msg_kind == MsgKind::Response => m,
21786            _ => {
21787                self.set_sync_needed(true, true);
21788                return;
21789            }
21790        };
21791        #[cfg(feature = "bench-internals")]
21792        drop(decode_phase);
21793        let coalesced_deletes =
21794            coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21795                if status != SubStatus::Active {
21796                    return false;
21797                }
21798                let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21799                    return false;
21800                };
21801                sub.state == SubState::Active
21802                    && sub.bootstrap_state.is_none()
21803                    && sub.synced_once
21804                    && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21805            });
21806        let mut frames = message.frames.into_iter();
21807        let mut applied_cursor: Option<i64> = None;
21808        let mut any_covered = false;
21809        let mut dropped = self.begin_coalesced_apply().is_err();
21810        while let Some(frame) = frames.next() {
21811            let Frame::SubStart {
21812                id,
21813                status,
21814                effective_scopes,
21815                ..
21816            } = frame
21817            else {
21818                continue;
21819            };
21820            let mut body = Vec::new();
21821            let mut next_cursor: Option<i64> = None;
21822            for inner in frames.by_ref() {
21823                match inner {
21824                    Frame::SubEnd {
21825                        next_cursor: nc, ..
21826                    } => {
21827                        next_cursor = Some(nc);
21828                        break;
21829                    }
21830                    Frame::Unknown { .. } => {}
21831                    other => body.push(other),
21832                }
21833            }
21834            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21835                dropped = true;
21836                continue;
21837            };
21838            let sub = &self.subs[sub_index];
21839            // §8.2: only locally active, not mid-bootstrap subscriptions
21840            // apply; skipped sections are repaired by the next pull.
21841            if status != SubStatus::Active
21842                || sub.state != SubState::Active
21843                || sub.bootstrap_state.is_some()
21844                || !sub.synced_once
21845            {
21846                dropped = true;
21847                continue;
21848            }
21849            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21850                // Idempotent redelivery of an already-covered window.
21851                any_covered = true;
21852                continue;
21853            }
21854            let previous_effective = self.subs[sub_index].effective.clone();
21855            let previous_cursor = self.subs[sub_index].cursor;
21856            self.subs[sub_index].effective = Some(effective_scopes);
21857            let mut failed = false;
21858            for inner in body {
21859                match inner {
21860                    Frame::Commit {
21861                        tables,
21862                        changes,
21863                        commit_seq,
21864                        ..
21865                    } => {
21866                        if self
21867                            .apply_commit_frame_coalesced(
21868                                &tables,
21869                                changes,
21870                                commit_seq,
21871                                &coalesced_deletes,
21872                            )
21873                            .is_err()
21874                        {
21875                            failed = true;
21876                            break;
21877                        }
21878                    }
21879                    _ => {
21880                        failed = true;
21881                        break;
21882                    }
21883                }
21884            }
21885            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21886                self.apply_sub_end(sub_index, cursor, None)
21887                    .ok()
21888                    .map(|()| cursor)
21889            });
21890            let Some(next_cursor) = committed else {
21891                self.subs[sub_index].effective = previous_effective;
21892                self.subs[sub_index].cursor = previous_cursor;
21893                dropped = true;
21894                continue;
21895            };
21896            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21897        }
21898        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
21899        // the cursors, so the delta counts as a wake-up.
21900        if self.finish_coalesced_apply().is_err() {
21901            applied_cursor = None;
21902            dropped = true;
21903        }
21904        if let Some(cursor) = applied_cursor {
21905            // §8.2 ack point: the highest applied SUB_END.nextCursor.
21906            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21907            let _ = transport.realtime_send(&ack);
21908        } else if !any_covered || dropped {
21909            // §8.2: a delta not applied at all is treated as a wake-up.
21910            self.set_sync_needed(true, true);
21911        }
21912    }
21913
21914    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
21915    /// cursor across active, non-bootstrapping subscriptions that have
21916    /// synced at least once. No such subscription, no ack.
21917    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21918        if !self.realtime_connected() {
21919            return;
21920        }
21921        let floor = self
21922            .subs
21923            .iter()
21924            .filter(|s| {
21925                s.state == SubState::Active
21926                    && s.bootstrap_state.is_none()
21927                    && s.synced_once
21928                    && s.cursor >= 0
21929            })
21930            .map(|s| s.cursor)
21931            .min();
21932        if let Some(cursor) = floor {
21933            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21934            let _ = transport.realtime_send(&ack);
21935        }
21936    }
21937}
21938
21939#[cfg(test)]
21940mod previous_version_wiring_tests {
21941    use super::*;
21942    use serde_json::{json, Value};
21943
21944    fn previous_version_schema(version: i32) -> Value {
21945        json!({
21946            "version": version,
21947            "tables": [{
21948                "name": "tasks",
21949                "primaryKey": "id",
21950                "columns": [
21951                    { "name": "id", "type": "string", "nullable": false },
21952                    { "name": "note", "type": "string", "nullable": true }
21953                ],
21954                "scopes": []
21955            }]
21956        })
21957    }
21958
21959    fn previous_version_limits() -> crate::api::ClientLimits {
21960        crate::api::ClientLimits {
21961            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21962                enabled: true,
21963                ..Default::default()
21964            }),
21965            ..Default::default()
21966        }
21967    }
21968
21969    /// The configuration the live client carries, for a direct capture call.
21970    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21971        previous_version_limits()
21972            .previous_version_context
21973            .expect("configured")
21974    }
21975
21976    /// Capture the container the way a bump would, so a read test starts from a
21977    /// container this client's own state will then decide about.
21978    fn recapture_previous_version(client: &SyncClient) {
21979        let replica_path = client
21980            .previous_version_replica_path()
21981            .expect("file-backed replica");
21982        let outcome = crate::previous_version::capture_previous_version_from_replica(
21983            &client.conn,
21984            &replica_path,
21985            2,
21986            2,
21987            &previous_version_config(),
21988            1,
21989        )
21990        .expect("capture");
21991        assert!(matches!(
21992            outcome,
21993            crate::previous_version::CaptureOutcome::Captured(_)
21994        ));
21995    }
21996
21997    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21998        Subscription {
21999            id: "wiring-sub".to_owned(),
22000            table: "tasks".to_owned(),
22001            requested: Vec::new(),
22002            params: None,
22003            cursor,
22004            bootstrap_state: bootstrap_state.map(str::to_owned),
22005            state,
22006            reason_code: None,
22007            effective: None,
22008            synced_once: false,
22009        }
22010    }
22011
22012    #[test]
22013    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
22014        let path =
22015            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
22016        let path_str = path.to_str().expect("temp path");
22017        let schema = previous_version_schema(3);
22018        let mut older = previous_version_schema(2);
22019        older["tables"][0]["columns"] =
22020            json!([{ "name": "id", "type": "string", "nullable": false }]);
22021        let mut client = SyncClient::open_path(
22022            "downgrade".into(),
22023            &previous_version_schema(1),
22024            previous_version_limits(),
22025            path_str,
22026        )
22027        .expect("fresh");
22028        client
22029            .mutate(vec![Mutation::Upsert {
22030                table: "tasks".into(),
22031                values: Map::from_iter([
22032                    ("id".into(), json!("accepted")),
22033                    ("note".into(), json!("old")),
22034                ]),
22035                base_version: None,
22036            }])
22037            .expect("v1 write");
22038        client
22039            .recreate_with_schema(&schema)
22040            .expect("upgrade captures context");
22041        let commit = client
22042            .mutate(vec![Mutation::Upsert {
22043                table: "tasks".into(),
22044                values: Map::from_iter([
22045                    ("id".into(), json!("queued")),
22046                    ("note".into(), json!("v3-only")),
22047                ]),
22048                base_version: None,
22049            }])
22050            .expect("queued v3 write");
22051        let changes = client.conn.total_changes();
22052        let outbox = client.pending_commit_ids();
22053        assert!(outbox.contains(&commit));
22054        let descriptor = client.get_meta("localSchemaDescriptor");
22055        let container = format!(
22056            "{path_str}{}",
22057            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22058        );
22059        let context = std::fs::read(&container).expect("captured context");
22060        assert!(client
22061            .recreate_with_schema(&older)
22062            .expect_err("refuse recreate")
22063            .starts_with("client.schema_downgrade:"));
22064        assert_eq!(client.schema.version, 3);
22065        assert_eq!(client.conn.total_changes(), changes);
22066        assert_eq!(client.pending_commit_ids(), outbox);
22067        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
22068        assert_eq!(
22069            client
22070                .conn
22071                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
22072                    row.get::<_, String>(0)
22073                })
22074                .expect("queued row"),
22075            "v3-only"
22076        );
22077        assert_eq!(std::fs::read(&container).expect("context remains"), context);
22078        drop(client);
22079        let before = std::fs::read(&path).expect("replica bytes");
22080        // open_path uses DELETE journaling. The identity open must refuse
22081        // before its normal WAL configuration changes this database.
22082        for entry in 0..3 {
22083            let error = match entry {
22084                0 => SyncClient::open_path(
22085                    "downgrade".into(),
22086                    &older,
22087                    previous_version_limits(),
22088                    path_str,
22089                ),
22090                1 => SyncClient::open_path_with_identity(
22091                    None,
22092                    &older,
22093                    previous_version_limits(),
22094                    path_str,
22095                ),
22096                _ => SyncClient::with_connection(
22097                    "downgrade".into(),
22098                    &older,
22099                    previous_version_limits(),
22100                    Connection::open(&path).expect("connection"),
22101                ),
22102            }
22103            .err()
22104            .expect("refuse open");
22105            assert!(error.starts_with("client.schema_downgrade:"));
22106            let conn = Connection::open(&path).expect("inspect");
22107            assert_eq!(
22108                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
22109                    .expect("journal mode"),
22110                "delete"
22111            );
22112            drop(conn);
22113            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
22114            assert_eq!(
22115                std::fs::read(&container).expect("context unchanged"),
22116                context
22117            );
22118        }
22119        let reopened = SyncClient::open_path(
22120            "downgrade".into(),
22121            &schema,
22122            previous_version_limits(),
22123            path_str,
22124        )
22125        .expect("compatible reopen");
22126        assert_eq!(reopened.pending_commit_ids(), outbox);
22127        assert_eq!(
22128            reopened
22129                .conn
22130                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
22131                    row.get::<_, String>(0)
22132                })
22133                .expect("queued row remains"),
22134            "v3-only"
22135        );
22136        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
22137        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22138        drop(reopened);
22139        let legacy = SyncClient::open_path(
22140            "downgrade".into(),
22141            &schema,
22142            previous_version_limits(),
22143            path_str,
22144        )
22145        .expect("legacy no-marker reopen");
22146        assert_eq!(legacy.pending_commit_ids(), outbox);
22147        assert!(!legacy.upgrading());
22148        assert_eq!(
22149            legacy
22150                .conn
22151                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
22152                    row.get::<_, String>(0)
22153                })
22154                .expect("legacy row remains"),
22155            "v3-only"
22156        );
22157        drop(legacy);
22158        std::fs::remove_file(path).expect("remove replica");
22159        let _ = std::fs::remove_file(container);
22160    }
22161
22162    #[test]
22163    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
22164        for marker in [
22165            "''",
22166            "'bad'",
22167            "'0'",
22168            "'-1'",
22169            "'2.5'",
22170            "'03'",
22171            "'3\n'",
22172            "' 3'",
22173            "'+3'",
22174            "'3e0'",
22175            "'2147483648'",
22176            "NULL",
22177            "X'33'",
22178        ] {
22179            let conn = Connection::open_in_memory().expect("connection");
22180            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
22181            let error = SyncClient::with_connection(
22182                "marker".into(),
22183                &previous_version_schema(3),
22184                ClientLimits::default(),
22185                conn,
22186            )
22187            .err()
22188            .expect("refuse marker");
22189            assert!(
22190                error.starts_with("sync.local_corrupt:"),
22191                "{marker}: {error}"
22192            );
22193        }
22194        for fault in [
22195            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
22196            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
22197        ] {
22198            let mut client = SyncClient::new(
22199                "marker".into(),
22200                &previous_version_schema(3),
22201                ClientLimits::default(),
22202            )
22203            .expect("fresh");
22204            let commit = client
22205                .mutate(vec![Mutation::Upsert {
22206                    table: "tasks".into(),
22207                    values: Map::from_iter([("id".into(), json!("queued"))]),
22208                    base_version: None,
22209                }])
22210                .expect("queued write");
22211            client.conn.execute_batch(fault).expect("marker fault");
22212            let changes = client.conn.total_changes();
22213            let error = client
22214                .recreate_with_schema(&previous_version_schema(4))
22215                .expect_err("refuse corrupt marker");
22216            assert!(error.starts_with("sync.local_corrupt:"));
22217            assert_eq!(client.conn.total_changes(), changes);
22218            assert_eq!(client.schema.version, 3);
22219            assert_eq!(client.pending_commit_ids(), vec![commit]);
22220        }
22221        for setup in [
22222            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
22223            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
22224        ] {
22225            let path =
22226                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
22227            let conn = Connection::open(&path).expect("connection");
22228            conn.execute_batch(setup)
22229                .expect("unreadable metadata fixture");
22230            drop(conn);
22231            let before = std::fs::read(&path).expect("before");
22232            let error = SyncClient::open_path_with_identity(
22233                None,
22234                &previous_version_schema(3),
22235                ClientLimits::default(),
22236                path.to_str().expect("path"),
22237            )
22238            .err()
22239            .expect("refuse unreadable marker");
22240            assert!(error.starts_with("sync.local_corrupt:"));
22241            assert_eq!(std::fs::read(&path).expect("after"), before);
22242            std::fs::remove_file(path).expect("remove fixture");
22243        }
22244    }
22245
22246    std::thread_local! {
22247        /// The pending concurrent upgrade a busy handler commits, so a lock
22248        /// collision deterministically becomes the marker race under test.
22249        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
22250            const { RefCell::new(None) };
22251    }
22252
22253    fn commit_concurrent_upgrade(attempts: i32) -> bool {
22254        CONCURRENT_UPGRADE.with(|slot| {
22255            if let Some(banner) = slot.borrow_mut().take() {
22256                banner
22257                    .execute_batch(
22258                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
22259                    )
22260                    .expect("commit the concurrent upgrade");
22261            }
22262        });
22263        attempts < 10
22264    }
22265
22266    fn journal_mode(conn: &Connection) -> String {
22267        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
22268            .expect("journal mode")
22269    }
22270
22271    fn temp_replica(label: &str) -> std::path::PathBuf {
22272        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
22273    }
22274
22275    #[test]
22276    fn file_identity_constructor_requires_the_returned_wal_mode() {
22277        for path in [":memory:", ""] {
22278            let error = SyncClient::open_path_with_identity(
22279                None,
22280                &previous_version_schema(1),
22281                ClientLimits::default(),
22282                path,
22283            )
22284            .err()
22285            .expect("temporary SQLite databases cannot enter WAL mode");
22286            assert_eq!(
22287                error,
22288                "sync.invalid_request: file-backed client requires WAL journal mode"
22289            );
22290        }
22291        let path = temp_replica("confirmed-wal");
22292        let client = SyncClient::open_path_with_identity(
22293            None,
22294            &previous_version_schema(1),
22295            ClientLimits::default(),
22296            path.to_str().unwrap(),
22297        )
22298        .unwrap();
22299        assert_eq!(journal_mode(&client.conn), "wal");
22300        drop(client);
22301        std::fs::remove_file(path).unwrap();
22302    }
22303
22304    #[test]
22305    fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
22306        for path in [":memory:", ""] {
22307            let client = SyncClient::open_path_with_identity_journal(
22308                None,
22309                &previous_version_schema(1),
22310                ClientLimits::default(),
22311                path,
22312                false,
22313            )
22314            .expect("a host without WAL can still open the identity constructor");
22315            assert_ne!(journal_mode(&client.conn), "wal");
22316            assert_eq!(
22317                client
22318                    .conn
22319                    .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
22320                    .expect("synchronous"),
22321                2,
22322                "every identity open pins FULL durability"
22323            );
22324        }
22325    }
22326
22327    #[test]
22328    fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
22329        let path = temp_replica("reset");
22330        let path_str = path.to_str().expect("path");
22331        let client = SyncClient::open_path(
22332            "reset-replica".into(),
22333            &previous_version_schema(1),
22334            ClientLimits::default(),
22335            path_str,
22336        )
22337        .expect("replica");
22338        let original_id = client.client_id().to_owned();
22339        drop(client);
22340        for suffix in ["-journal", "-wal", "-shm"] {
22341            std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
22342        }
22343
22344        SyncClient::reset_replica(path_str).expect("reset through the VFS");
22345
22346        for suffix in ["", "-journal", "-wal", "-shm"] {
22347            assert!(
22348                !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
22349                "reset removes the replica file {suffix:?}"
22350            );
22351        }
22352        let reopened = SyncClient::open_path_with_identity(
22353            None,
22354            &previous_version_schema(1),
22355            ClientLimits::default(),
22356            path_str,
22357        )
22358        .expect("fresh replica");
22359        assert_ne!(reopened.client_id(), original_id);
22360        assert!(reopened
22361            .query("SELECT id FROM tasks", &[])
22362            .expect("empty")
22363            .is_empty());
22364        drop(reopened);
22365        assert_eq!(
22366            SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
22367            "sync.invalid_request: a memory replica has nothing to reset"
22368        );
22369    }
22370
22371    #[test]
22372    fn non_positive_generated_schema_versions_are_refused_before_storage() {
22373        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
22374            let mut schema = previous_version_schema(1);
22375            schema["version"] = version.clone();
22376            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22377                .err()
22378                .expect("refuse an out-of-range generated version");
22379            assert_eq!(
22380                error,
22381                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
22382            );
22383            // The file-backed seam refuses before the replica gains a table.
22384            let path = temp_replica("bad-version");
22385            let path_str = path.to_str().expect("path");
22386            for identity in [false, true] {
22387                let error = if identity {
22388                    SyncClient::open_path_with_identity(
22389                        None,
22390                        &schema,
22391                        ClientLimits::default(),
22392                        path_str,
22393                    )
22394                } else {
22395                    SyncClient::open_path(
22396                        "bad-version".into(),
22397                        &schema,
22398                        ClientLimits::default(),
22399                        path_str,
22400                    )
22401                }
22402                .err()
22403                .expect("refuse before storage");
22404                assert!(
22405                    error.starts_with("sync.invalid_request:"),
22406                    "{version}: {error}"
22407                );
22408                assert!(
22409                    !path.exists(),
22410                    "{version}: a refused constructor creates no replica file"
22411                );
22412            }
22413        }
22414        // A missing version is the same request error, not a parse failure.
22415        let mut schema = previous_version_schema(1);
22416        schema
22417            .as_object_mut()
22418            .expect("schema object")
22419            .remove("version");
22420        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22421            .err()
22422            .expect("refuse a missing version");
22423        assert!(error.starts_with("sync.invalid_request:"), "{error}");
22424    }
22425
22426    #[test]
22427    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
22428        let path = temp_replica("duplicate-marker");
22429        let path_str = path.to_str().expect("path");
22430        {
22431            // A hand-built/legacy metadata table without its primary key, with
22432            // the older marker first: `query_row` would pick it and reset.
22433            let conn = Connection::open(path_str).expect("fixture");
22434            conn.execute_batch(
22435                "CREATE TABLE _syncular_meta(key TEXT, value);
22436                 INSERT INTO _syncular_meta VALUES
22437                   ('localSchemaVersion', '1'),
22438                   ('localSchemaVersion', '2'),
22439                   ('clientId', 'duplicate');",
22440            )
22441            .expect("duplicate marker fixture");
22442        }
22443        let error = SyncClient::open_path_with_identity(
22444            None,
22445            &previous_version_schema(3),
22446            ClientLimits::default(),
22447            path_str,
22448        )
22449        .err()
22450        .expect("refuse a duplicated marker");
22451        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
22452        let probe = Connection::open(path_str).expect("probe");
22453        assert_eq!(
22454            probe
22455                .query_row(
22456                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
22457                    [],
22458                    |row| row.get::<_, i64>(0),
22459                )
22460                .expect("marker rows"),
22461            2,
22462            "the duplicated rows are left for the operator, not collapsed"
22463        );
22464        assert_eq!(
22465            probe
22466                .query_row(
22467                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
22468                    [],
22469                    |row| row.get::<_, i64>(0),
22470                )
22471                .expect("synced tables"),
22472            0,
22473            "a duplicated marker never drives the reset that recreates v3 tables"
22474        );
22475        assert_eq!(
22476            journal_mode(&probe),
22477            "delete",
22478            "the refusal leaves the journal mode alone"
22479        );
22480        drop(probe);
22481        std::fs::remove_file(path).expect("remove replica");
22482    }
22483
22484    #[test]
22485    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
22486        let path = temp_replica("refused-wal");
22487        let path_str = path.to_str().expect("path");
22488        // `open_path` leaves the default journal mode, so a refused open is
22489        // the only thing that could switch this replica to WAL.
22490        drop(
22491            SyncClient::open_path(
22492                "owner".into(),
22493                &previous_version_schema(2),
22494                ClientLimits::default(),
22495                path_str,
22496            )
22497            .expect("v2 replica"),
22498        );
22499        let before = std::fs::read(&path).expect("before");
22500        let error = SyncClient::open_path_with_identity(
22501            Some("intruder".into()),
22502            &previous_version_schema(2),
22503            ClientLimits::default(),
22504            path_str,
22505        )
22506        .err()
22507        .expect("refuse the identity mismatch");
22508        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22509        let probe = Connection::open(path_str).expect("probe");
22510        assert_eq!(
22511            journal_mode(&probe),
22512            "delete",
22513            "a refused open must not persist WAL"
22514        );
22515        assert_eq!(std::fs::read(&path).expect("after"), before);
22516        assert_eq!(
22517            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22518            Some("2")
22519        );
22520        drop(probe);
22521        std::fs::remove_file(path).expect("remove replica");
22522    }
22523
22524    #[test]
22525    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22526        let path = temp_replica("marker-race");
22527        let path_str = path.to_str().expect("path");
22528        drop(
22529            SyncClient::open_path_with_identity(
22530                Some("stale".into()),
22531                &previous_version_schema(1),
22532                ClientLimits::default(),
22533                path_str,
22534            )
22535            .expect("v1 replica"),
22536        );
22537        // Another process holds the write lock with an uncommitted upgrade; a
22538        // stale open reads the v1 marker first and only then meets the lock.
22539        // The first busy therefore lands after this replica became v2.
22540        let banner = Connection::open(path_str).expect("banner");
22541        banner
22542            .execute_batch(
22543                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22544            )
22545            .expect("pending upgrade");
22546        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22547        let conn = Connection::open(path_str).expect("stale connection");
22548        conn.busy_handler(Some(commit_concurrent_upgrade))
22549            .expect("busy handler");
22550        let error = SyncClient::with_connection(
22551            "stale".into(),
22552            &previous_version_schema(1),
22553            ClientLimits::default(),
22554            conn,
22555        )
22556        .err()
22557        .expect("a concurrent upgrade must refuse the stale open");
22558        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22559        let probe = Connection::open(path_str).expect("probe");
22560        assert_eq!(
22561            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22562            Some("2"),
22563            "the concurrent upgrade survives"
22564        );
22565        drop(probe);
22566        std::fs::remove_file(path).expect("remove replica");
22567    }
22568
22569    #[test]
22570    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22571        let path = temp_replica("recreate-race");
22572        let path_str = path.to_str().expect("path");
22573        let mut client = SyncClient::open_path_with_identity(
22574            Some("stale".into()),
22575            &previous_version_schema(1),
22576            ClientLimits::default(),
22577            path_str,
22578        )
22579        .expect("v1 replica");
22580        client
22581            .mutate(vec![Mutation::Upsert {
22582                table: "tasks".into(),
22583                values: Map::from_iter([("id".into(), json!("queued"))]),
22584                base_version: None,
22585            }])
22586            .expect("queued write");
22587        let commit = client.pending_commit_ids();
22588        let banner = Connection::open(path_str).expect("banner");
22589        banner
22590            .execute_batch(
22591                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22592            )
22593            .expect("pending upgrade");
22594        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22595        client
22596            .conn
22597            .busy_handler(Some(commit_concurrent_upgrade))
22598            .expect("busy handler");
22599        let error = client
22600            .recreate_with_schema(&previous_version_schema(1))
22601            .expect_err("a concurrent upgrade must refuse the stale recreate");
22602        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22603        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22604        assert_eq!(
22605            client.pending_commit_ids(),
22606            commit,
22607            "the outbox is preserved"
22608        );
22609        let probe = Connection::open(path_str).expect("probe");
22610        assert_eq!(
22611            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22612            Some("2")
22613        );
22614        drop(probe);
22615        drop(client);
22616        std::fs::remove_file(path).expect("remove replica");
22617    }
22618
22619    #[test]
22620    fn failed_schema_record_writes_roll_back_recreation() {
22621        for key in [
22622            LOCAL_SCHEMA_VERSION_KEY,
22623            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22624        ] {
22625            let mut client = SyncClient::new(
22626                "schema-record".into(),
22627                &previous_version_schema(1),
22628                ClientLimits::default(),
22629            )
22630            .expect("v1");
22631            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22632            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22633            client.conn.execute_batch(&format!("CREATE TRIGGER refuse_schema_record BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{key}' BEGIN SELECT RAISE(ABORT, 'refuse schema record'); END")).expect("fault");
22634            client
22635                .recreate_with_schema(&previous_version_schema(2))
22636                .expect_err("both record writes must propagate failure");
22637            assert_eq!(client.schema.version, 1);
22638            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22639            assert_eq!(
22640                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22641                descriptor
22642            );
22643            client
22644                .conn
22645                .execute_batch("DROP TRIGGER refuse_schema_record")
22646                .expect("remove fault");
22647            client
22648                .recreate_with_schema(&previous_version_schema(2))
22649                .expect("retry");
22650            assert_eq!(client.schema.version, 2);
22651        }
22652    }
22653
22654    #[test]
22655    fn a_failed_outer_commit_restores_every_reset_side_effect() {
22656        let path = temp_replica("outer-commit");
22657        let path_str = path.to_str().expect("path");
22658        // A rollback-journal replica (no WAL), so a second read connection can
22659        // hold SHARED across the recreated transaction's COMMIT.
22660        let conn = Connection::open(path_str).expect("replica connection");
22661        let mut client = SyncClient::with_connection(
22662            "outer-commit".into(),
22663            &previous_version_schema(1),
22664            previous_version_limits(),
22665            conn,
22666        )
22667        .expect("v1 replica");
22668        assert_eq!(journal_mode(&client.conn), "delete");
22669        let commit = client
22670            .mutate(vec![Mutation::Upsert {
22671                table: "tasks".into(),
22672                values: Map::from_iter([
22673                    ("id".into(), json!("queued")),
22674                    ("note".into(), json!("dropped-by-v2")),
22675                ]),
22676                base_version: None,
22677            }])
22678            .expect("queued write");
22679        client.drain_change_batches();
22680        // v2 drops `note`, so the queued upsert cannot re-encode.
22681        let mut dropped = previous_version_schema(2);
22682        dropped["tables"][0]["columns"] =
22683            json!([{ "name": "id", "type": "string", "nullable": false }]);
22684        // Prior database and memory state: the refused COMMIT must restore all
22685        // of it.
22686        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22687        let revision = client.local_revision();
22688        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22689        let columns: i64 = client
22690            .conn
22691            .query_row(
22692                "SELECT count(*) FROM pragma_table_info('tasks')",
22693                [],
22694                |row| row.get(0),
22695            )
22696            .expect("column count");
22697        let rebuilds = client.overlay_rebuild_count.get();
22698        let prior_subs = client.subs.len();
22699        let prior_changes = client.change_queue.len();
22700        let prior_intents = client.sync_intent_queue.len();
22701        let prior_last_change = client.last_change.clone();
22702        let prior_overlay_dirty = client.overlay_dirty.get();
22703        let prior_sync_needed = client.sync_needed;
22704        let prior_upgrading = client.upgrading;
22705        let prior_stopped = client.stopped;
22706        let prior_schema_floor = client.schema_floor.clone();
22707        let prior_active_round = client.active_round;
22708        // The reader holds SHARED for the whole attempt: the writer takes
22709        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
22710        let reader = Connection::open(path_str).expect("reader");
22711        reader
22712            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22713            .expect("reader snapshot");
22714        client
22715            .conn
22716            .busy_timeout(std::time::Duration::ZERO)
22717            .expect("busy timeout");
22718        let error = client
22719            .recreate_with_schema(&dropped)
22720            .expect_err("a blocked COMMIT must refuse the recreation");
22721        assert!(error.starts_with("client.storage_busy:"), "{error}");
22722        assert!(
22723            client.overlay_rebuild_count.get() > rebuilds,
22724            "the reset ran to its overlay rebuild inside the transaction"
22725        );
22726        assert!(client.conn.is_autocommit(), "the transaction is released");
22727        // Database: the rolled-back reset left nothing behind.
22728        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22729        assert_eq!(client.local_revision(), revision);
22730        assert_eq!(
22731            client.query("SELECT * FROM tasks", &[]).expect("rows"),
22732            rows
22733        );
22734        assert_eq!(
22735            client
22736                .conn
22737                .query_row(
22738                    "SELECT count(*) FROM pragma_table_info('tasks')",
22739                    [],
22740                    |row| { row.get::<_, i64>(0) }
22741                )
22742                .expect("column count"),
22743            columns,
22744            "the previous table layout survives the rollback"
22745        );
22746        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22747        // Memory: every side effect of the successful reset is restored with
22748        // the rolled-back database.
22749        assert_eq!(client.schema.version, 1, "compiled schema");
22750        assert_eq!(client.subs.len(), prior_subs);
22751        assert_eq!(client.change_queue.len(), prior_changes);
22752        assert_eq!(client.sync_intent_queue.len(), prior_intents);
22753        assert_eq!(client.last_change, prior_last_change);
22754        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22755        assert_eq!(client.sync_needed, prior_sync_needed);
22756        assert_eq!(client.upgrading, prior_upgrading);
22757        assert_eq!(client.stopped, prior_stopped);
22758        assert_eq!(client.schema_floor, prior_schema_floor);
22759        assert_eq!(client.active_round, prior_active_round);
22760        assert!(
22761            client.rejections.is_empty(),
22762            "the dropped-column rejection is rolled back too"
22763        );
22764        // The capture committed in its separate file before the failed replica
22765        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
22766        let container = crate::previous_version::previous_version_container_path(path_str);
22767        assert!(std::path::Path::new(&container).exists());
22768        // The reader no longer needs its snapshot, so discard can clean the
22769        // replica's audit records along with the stale sibling file.
22770        drop(reader);
22771        let snapshot = client
22772            .previous_version_snapshot(&PreviousVersionReadSpec {
22773                table: "tasks".into(),
22774                ..Default::default()
22775            })
22776            .expect("refuse stale capture");
22777        assert!(!snapshot.available);
22778        assert_eq!(snapshot.current_version, 1);
22779        assert!(!std::path::Path::new(&container).exists());
22780        // The client remains usable: releasing the reader lets the identical
22781        // recreation run to completion, and the reset preserves the
22782        // incompatible intent until the send-time prepass classifies it
22783        // (§7.4.3/§7.4.4: the reset never touches the outbox).
22784        client
22785            .conn
22786            .busy_timeout(std::time::Duration::from_secs(1))
22787            .expect("busy timeout");
22788        client
22789            .recreate_with_schema(&dropped)
22790            .expect("the recreation succeeds once the reader releases");
22791        assert_eq!(client.schema.version, 2);
22792        assert_eq!(
22793            client.pending_commit_ids(),
22794            vec![commit.clone()],
22795            "the reset kept the incompatible commit pending"
22796        );
22797        assert!(
22798            client.rejections.is_empty(),
22799            "no rejection exists before the send-time prepass"
22800        );
22801        // The send boundary classifies and drops it.
22802        client
22803            .prepare_sync_round(false)
22804            .expect("the send prepass runs during round preparation");
22805        assert!(client.pending_commit_ids().is_empty());
22806        assert!(
22807            client
22808                .rejections
22809                .iter()
22810                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22811            "the dropped-column commit surfaced as a rejection at send time"
22812        );
22813        client.previous_version_discard();
22814        drop(client);
22815        std::fs::remove_file(path).expect("remove replica");
22816    }
22817
22818    #[test]
22819    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22820        let v1 = previous_version_schema(1);
22821        let mut v2 = previous_version_schema(2);
22822        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
22823        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22824        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22825        let dropped = client
22826            .mutate(vec![Mutation::Upsert {
22827                table: "tasks".into(),
22828                values: Map::from_iter([
22829                    ("id".into(), json!("dropped")),
22830                    ("note".into(), json!("gone")),
22831                ]),
22832                base_version: None,
22833            }])
22834            .unwrap();
22835        // A full-row v1 upsert always carries the v1 columns, so the surviving
22836        // commit is a value-free delete (it encodes under any schema that still
22837        // has the table).
22838        let survivor = client
22839            .mutate(vec![Mutation::Delete {
22840                table: "tasks".into(),
22841                row_id: "gone".into(),
22842                base_version: None,
22843            }])
22844            .unwrap();
22845        client
22846            .recreate_with_schema(&v2)
22847            .expect("the reset preserves the outbox");
22848        assert_eq!(
22849            client.pending_commit_ids(),
22850            vec![dropped.clone(), survivor.clone()],
22851            "the reset preserves both commits"
22852        );
22853        assert!(client.rejections.is_empty(), "the reset classifies nothing");
22854        client.drain_change_batches();
22855        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22856        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22857
22858        // A fault anywhere in the atomic drop leaves the replica, the outbox,
22859        // the durable outcomes, the visible projection, the revision, and the
22860        // publish surface untouched. Clearing `syncNeeded` first keeps the
22861        // round's own status flip out of the assertion, so any published change
22862        // can only be the drop's.
22863        for fault in [
22864            "outcome-insert",
22865            "outbox-delete",
22866            "overlay-rebuild",
22867            "publish-revision",
22868        ] {
22869            client.set_sync_needed(false, false);
22870            client.drain_change_batches();
22871            let revision = client.local_revision();
22872            let trigger = match fault {
22873                "outcome-insert" => {
22874                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22875                }
22876                "outbox-delete" => {
22877                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22878                }
22879                // The optimized replay removes only the dropped projection, so
22880                // the replay fault belongs on the DELETE; a publish fault lands
22881                // on the revision write that `finish_observation` performs after
22882                // every durable effect already succeeded.
22883                "publish-revision" => {
22884                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22885                }
22886                _ => {
22887                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22888                }
22889            };
22890            client.conn.execute_batch(trigger).unwrap();
22891            let outcome = client
22892                .prepare_sync_round(false)
22893                .err()
22894                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22895            assert!(
22896                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22897                "{fault}: {outcome:?}"
22898            );
22899            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22900            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22901            assert_eq!(
22902                client.pending_commit_ids(),
22903                vec![dropped.clone(), survivor.clone()],
22904                "{fault}: the outbox is restored"
22905            );
22906            assert!(
22907                client.rejections.is_empty(),
22908                "{fault}: no in-memory rejection survives"
22909            );
22910            assert!(
22911                client.commit_outcome(&dropped).unwrap().is_none(),
22912                "{fault}: the durable outcome rolled back"
22913            );
22914            assert_eq!(client.local_revision(), revision, "{fault}: revision");
22915            assert_eq!(
22916                client.query("SELECT * FROM tasks", &[]).unwrap(),
22917                rows,
22918                "{fault}: the visible projection is unchanged"
22919            );
22920            assert!(
22921                client.drain_change_batches().is_empty(),
22922                "{fault}: nothing is published"
22923            );
22924        }
22925
22926        // A nested failure leaves the caller's transaction and earlier writes
22927        // intact; only the drop's savepoint is rolled back.
22928        client
22929            .conn
22930            .execute_batch(
22931                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22932             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22933             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22934            )
22935            .unwrap();
22936        assert!(client.drop_incompatible_outbox().is_err());
22937        assert!(!client.conn.is_autocommit());
22938        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22939        assert_eq!(
22940            client.pending_commit_ids(),
22941            vec![dropped.clone(), survivor.clone()]
22942        );
22943        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22944        client
22945            .conn
22946            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22947            .unwrap();
22948
22949        // The successful drop publishes exactly once and leaves the surviving
22950        // intent queued for the same round. A log epoch is required before a
22951        // round builds push frames at all, so the classification provably runs
22952        // before any operation of the dropped commit is sent.
22953        client.set_meta(LOG_EPOCH_KEY, "epoch");
22954        client.set_sync_needed(false, false);
22955        client.drain_change_batches();
22956        let revision = client.local_revision();
22957        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22958        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22959        assert_eq!(
22960            prepared.meta.pushed_ids,
22961            vec![survivor.clone()],
22962            "the incompatible commit is classified before any operation is sent"
22963        );
22964        let rejection = client
22965            .rejections
22966            .iter()
22967            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22968            .expect("a sync.outbox_incompatible rejection");
22969        assert_eq!(rejection.client_commit_id, dropped);
22970        assert!(
22971            !rejection.retryable,
22972            "a schema-incompatible commit is final"
22973        );
22974        assert_eq!(
22975            client
22976                .commit_outcome(&dropped)
22977                .unwrap()
22978                .expect("a durable rejected outcome")
22979                .status,
22980            CommitOutcomeStatus::Rejected
22981        );
22982        assert_eq!(client.local_revision(), revision + 1, "one revision");
22983        let batches = client.drain_change_batches();
22984        assert_eq!(batches.len(), 1, "one change batch");
22985        assert!(
22986            batches[0].rejections_changed
22987                && batches[0].outcomes_changed
22988                && batches[0].status.is_some(),
22989            "{:?}",
22990            batches[0]
22991        );
22992        assert!(
22993            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22994            "{:?}",
22995            batches[0]
22996        );
22997        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22998        assert!(
22999            visible.is_empty(),
23000            "the dropped projection is undone: {visible:?}"
23001        );
23002
23003        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
23004        // a rollback-journal reader holding SHARED makes it fail with the typed
23005        // busy classification, and releasing the reader lets the retry succeed.
23006        let path = temp_replica("outbox-drop-commit");
23007        let path_str = path.to_str().expect("path");
23008        let conn = Connection::open(path_str).expect("replica connection");
23009        let mut client =
23010            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
23011                .expect("v1 replica");
23012        let dropped = client
23013            .mutate(vec![Mutation::Upsert {
23014                table: "tasks".into(),
23015                values: Map::from_iter([
23016                    ("id".into(), json!("dropped")),
23017                    ("note".into(), json!("gone")),
23018                ]),
23019                base_version: None,
23020            }])
23021            .unwrap();
23022        client
23023            .recreate_with_schema(&v2)
23024            .expect("the reset preserves the outbox");
23025        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
23026        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
23027        assert_eq!(rows.len(), 1, "the optimistic row is visible");
23028        client.set_sync_needed(false, false);
23029        client.drain_change_batches();
23030        let revision = client.local_revision();
23031        let reader = Connection::open(path_str).expect("reader");
23032        reader
23033            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
23034            .expect("reader snapshot");
23035        client
23036            .conn
23037            .busy_timeout(std::time::Duration::ZERO)
23038            .expect("busy timeout");
23039        let outcome = client
23040            .prepare_sync_round(false)
23041            .err()
23042            .expect("a blocked COMMIT must fail the round");
23043        assert!(
23044            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
23045            "{outcome:?}"
23046        );
23047        assert!(client.conn.is_autocommit(), "the savepoint is released");
23048        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
23049        assert!(client.rejections.is_empty());
23050        assert!(client.commit_outcome(&dropped).unwrap().is_none());
23051        assert_eq!(client.local_revision(), revision);
23052        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
23053        assert!(client.drain_change_batches().is_empty());
23054        // The same round succeeds once the reader releases.
23055        drop(reader);
23056        client
23057            .conn
23058            .busy_timeout(std::time::Duration::from_secs(1))
23059            .expect("busy timeout");
23060        client
23061            .prepare_sync_round(false)
23062            .expect("the drop succeeds after the reader releases");
23063        assert!(client.pending_commit_ids().is_empty());
23064        assert!(client
23065            .rejections
23066            .iter()
23067            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
23068        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
23069        drop(client);
23070        std::fs::remove_file(path).expect("remove replica");
23071    }
23072
23073    #[test]
23074    fn previous_version_read_reasons_come_from_live_client_state() {
23075        use crate::previous_version::{
23076            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
23077        };
23078        let path = std::env::temp_dir()
23079            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
23080            .to_string_lossy()
23081            .into_owned();
23082        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
23083        {
23084            let mut setup = SyncClient::open_path(
23085                "wiring".to_owned(),
23086                &previous_version_schema(1),
23087                previous_version_limits(),
23088                &path,
23089            )
23090            .expect("v1 install");
23091            setup
23092                .mutate(vec![Mutation::Upsert {
23093                    table: "tasks".to_owned(),
23094                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
23095                    base_version: None,
23096                }])
23097                .expect("seed a row");
23098        }
23099        let mut client = SyncClient::open_path(
23100            "wiring".to_owned(),
23101            &previous_version_schema(2),
23102            previous_version_limits(),
23103            &path,
23104        )
23105        .expect("v2 bump captures");
23106        assert!(std::path::Path::new(&container).exists());
23107        let spec = PreviousVersionReadSpec {
23108            table: "tasks".to_owned(),
23109            ..Default::default()
23110        };
23111
23112        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
23113        recapture_previous_version(&client);
23114        client.lease_state = Some(LeaseState {
23115            lease_id: None,
23116            expires_at_ms: None,
23117            error_code: Some("lease.expired".to_owned()),
23118        });
23119        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23120        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
23121        assert!(!snapshot.available);
23122        assert!(!std::path::Path::new(&container).exists());
23123
23124        // An expired lease uses the client clock (injected, no sleeps).
23125        recapture_previous_version(&client);
23126        client.set_now_ms(1_000);
23127        client.lease_state = Some(LeaseState {
23128            lease_id: Some("lease".to_owned()),
23129            expires_at_ms: Some(1_000),
23130            error_code: None,
23131        });
23132        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23133        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
23134        assert!(!std::path::Path::new(&container).exists());
23135        client.lease_state = None;
23136
23137        // A revoked subscription ⇒ scope-revoked.
23138        recapture_previous_version(&client);
23139        client.subs.push(subscription(SubState::Revoked, 0, None));
23140        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23141        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
23142        assert!(!std::path::Path::new(&container).exists());
23143        client.subs.clear();
23144
23145        // Coverage completion: every ACTIVE subscription has a cursor and no
23146        // resume token — a still-resuming subscription is not completion.
23147        recapture_previous_version(&client);
23148        client
23149            .subs
23150            .push(subscription(SubState::Active, 0, Some("resume")));
23151        assert!(!client.previous_version_coverage_complete());
23152        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23153        assert!(snapshot.available);
23154        assert_eq!(snapshot.previous_version, Some(2));
23155        assert_eq!(snapshot.rows.len(), 1);
23156
23157        recapture_previous_version(&client);
23158        client.subs[0].bootstrap_state = None;
23159        assert!(client.previous_version_coverage_complete());
23160        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
23161        assert_eq!(
23162            snapshot.reason,
23163            Some(PreviousVersionReason::CoverageComplete)
23164        );
23165        assert!(!std::path::Path::new(&container).exists());
23166        client.subs.clear();
23167
23168        // With no active subscription at all, coverage is NOT complete.
23169        assert!(!client.previous_version_coverage_complete());
23170
23171        drop(client);
23172        let _ = std::fs::remove_file(&path);
23173    }
23174
23175    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
23176    /// come from live state; this proves the OTHER caller —
23177    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
23178    /// actually removes the container for lease-inactivity, coverage
23179    /// completion and scope revocation, and leaves it alone while the
23180    /// replacement bootstrap is still resuming.
23181    #[test]
23182    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
23183        let path = std::env::temp_dir()
23184            .join(format!(
23185                "syncular-prev-lifetime-{}.db",
23186                uuid::Uuid::new_v4()
23187            ))
23188            .to_string_lossy()
23189            .into_owned();
23190        let container = format!(
23191            "{path}{}",
23192            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
23193        );
23194        let exists = || std::path::Path::new(&container).exists();
23195        {
23196            let mut setup = SyncClient::open_path(
23197                "lifetime".to_owned(),
23198                &previous_version_schema(1),
23199                previous_version_limits(),
23200                &path,
23201            )
23202            .expect("v1 install");
23203            setup
23204                .mutate(vec![Mutation::Upsert {
23205                    table: "tasks".to_owned(),
23206                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
23207                    base_version: None,
23208                }])
23209                .expect("seed a row");
23210        }
23211        let mut client = SyncClient::open_path(
23212            "lifetime".to_owned(),
23213            &previous_version_schema(2),
23214            previous_version_limits(),
23215            &path,
23216        )
23217        .expect("v2 bump captures");
23218        // Pin the clock so the 24h TTL never fires in this test (the TTL has
23219        // its own coverage); the capture record's createdAtMs is 1.
23220        client.set_now_ms(1_000);
23221        assert!(exists());
23222
23223        // §7.3.5 lease error ⇒ the lifetime check drops the container.
23224        client.lease_state = Some(LeaseState {
23225            lease_id: None,
23226            expires_at_ms: None,
23227            error_code: Some("lease.expired".to_owned()),
23228        });
23229        client.previous_version_lifetime_check();
23230        assert!(!exists());
23231        client.lease_state = None;
23232
23233        // §7.4.5 coverage completion (every active sub has a cursor and no
23234        // resume token) ⇒ drop.
23235        recapture_previous_version(&client);
23236        client.subs.push(subscription(SubState::Active, 0, None));
23237        client.previous_version_lifetime_check();
23238        assert!(!exists());
23239        client.subs.clear();
23240
23241        // §3.3 scope revocation ⇒ drop.
23242        recapture_previous_version(&client);
23243        client.subs.push(subscription(SubState::Revoked, 0, None));
23244        client.previous_version_lifetime_check();
23245        assert!(!exists());
23246        client.subs.clear();
23247
23248        // Negative control: a still-resuming active subscription is NOT
23249        // coverage completion, so the container survives the same call.
23250        recapture_previous_version(&client);
23251        client
23252            .subs
23253            .push(subscription(SubState::Active, 0, Some("resume")));
23254        client.previous_version_lifetime_check();
23255        assert!(exists());
23256        client.subs.clear();
23257
23258        drop(client);
23259        let _ = std::fs::remove_file(&path);
23260        let _ = std::fs::remove_file(&container);
23261        let _ = std::fs::remove_file(format!("{container}-wal"));
23262        let _ = std::fs::remove_file(format!("{container}-shm"));
23263    }
23264}
23265
23266#[cfg(test)]
23267mod scope_move_delivery_tests {
23268    use super::*;
23269    use crate::native_transport::HostTransport;
23270    use crate::values::encode_row_json;
23271    use serde_json::json;
23272
23273    fn client() -> SyncClient {
23274        SyncClient::new(
23275            "scope-move".to_owned(),
23276            &json!({
23277                "version": 1,
23278                "tables": [{
23279                    "name": "tasks",
23280                    "primaryKey": "id",
23281                    "columns": [
23282                        { "name": "id", "type": "string", "nullable": false },
23283                        { "name": "project_id", "type": "string", "nullable": false }
23284                    ],
23285                    "scopes": [{ "pattern": "project:{project_id}" }]
23286                }]
23287            }),
23288            ClientLimits::default(),
23289        )
23290        .expect("test client")
23291    }
23292
23293    fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
23294        ssp2::model::Change {
23295            table_index: 0,
23296            row_id: "t1".to_owned(),
23297            op: Op::Upsert,
23298            row_version: Some(1),
23299            scopes: vec![("project_id".to_owned(), project.to_owned())],
23300            row: Some(
23301                encode_row_json(
23302                    client.schema.table("tasks").unwrap(),
23303                    "t1",
23304                    &Map::from_iter([
23305                        ("id".to_owned(), Value::from("t1")),
23306                        ("project_id".to_owned(), Value::from(project)),
23307                    ]),
23308                    &client.encryption,
23309                )
23310                .unwrap(),
23311            ),
23312        }
23313    }
23314
23315    fn delete(project: &str) -> ssp2::model::Change {
23316        ssp2::model::Change {
23317            table_index: 0,
23318            row_id: "t1".to_owned(),
23319            op: Op::Delete,
23320            row_version: None,
23321            scopes: vec![("project_id".to_owned(), project.to_owned())],
23322            row: None,
23323        }
23324    }
23325
23326    #[test]
23327    fn a_scope_move_keeps_the_row_in_either_section_order() {
23328        for order in [["old", "new"], ["new", "old"]] {
23329            let mut client = client();
23330            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23331            client
23332                .subscribe(
23333                    "old".into(),
23334                    "tasks".into(),
23335                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23336                    None,
23337                )
23338                .unwrap();
23339            client
23340                .subscribe(
23341                    "new".into(),
23342                    "tasks".into(),
23343                    vec![("project_id".to_owned(), vec!["p2".to_owned()])],
23344                    None,
23345                )
23346                .unwrap();
23347            let seed = upsert(&client, "p1");
23348            client
23349                .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
23350                .unwrap();
23351            assert_eq!(
23352                client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
23353                json!("p1")
23354            );
23355
23356            let mut frames = vec![Frame::RespHeader {
23357                required_schema_version: None,
23358                latest_schema_version: None,
23359                log_epoch: Some("epoch-1".to_owned()),
23360                reset_required: Some(false),
23361            }];
23362            for id in order {
23363                let project = if id == "old" { "p1" } else { "p2" };
23364                frames.push(Frame::SubStart {
23365                    id: id.to_owned(),
23366                    status: SubStatus::Active,
23367                    reason_code: String::new(),
23368                    effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
23369                    bootstrap: false,
23370                });
23371                frames.push(Frame::Commit {
23372                    commit_seq: 2,
23373                    created_at_ms: 0,
23374                    actor_id: "actor".to_owned(),
23375                    tables: vec!["tasks".to_owned()],
23376                    changes: vec![if id == "old" {
23377                        delete("p1")
23378                    } else {
23379                        upsert(&client, "p2")
23380                    }],
23381                });
23382                frames.push(Frame::SubEnd {
23383                    next_cursor: 2,
23384                    bootstrap_state: None,
23385                });
23386            }
23387            let (_, meta) = client.build_request(false).unwrap();
23388            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23389            let outcome = client.process_response(
23390                &mut transport,
23391                Message {
23392                    wire_version: WIRE_VERSION,
23393                    msg_kind: MsgKind::Response,
23394                    frames,
23395                },
23396                &meta,
23397            );
23398            assert!(
23399                matches!(outcome, SyncOutcome::Ok(_)),
23400                "{order:?}: {outcome:?}"
23401            );
23402            let rows = client
23403                .query("SELECT id, project_id FROM tasks", &[])
23404                .unwrap();
23405            assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
23406            assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
23407        }
23408    }
23409
23410    struct RefusalTransport;
23411
23412    impl Transport for RefusalTransport {
23413        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
23414            let mut error = TransportError::new(
23415                "sync.invalid_client_id",
23416                "clientId is bound to a different actor in this partition",
23417            );
23418            error.details = Some(json!({
23419                "causeKind": "status",
23420                "httpStatus": 400,
23421                "category": "invalid-request",
23422                "retryable": false,
23423                "recommendedAction": "resetClientId"
23424            }));
23425            Err(error)
23426        }
23427
23428        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
23429            self.sync(request)
23430        }
23431
23432        fn download_segment(
23433            &mut self,
23434            _request: &SegmentRequest,
23435            _on_progress: &mut dyn FnMut(u64),
23436        ) -> Result<Vec<u8>, TransportError> {
23437            Err(TransportError::new("sync.transport_failed", "offline"))
23438        }
23439
23440        fn realtime_connect(&mut self) -> Result<(), TransportError> {
23441            Ok(())
23442        }
23443
23444        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
23445            Ok(())
23446        }
23447
23448        fn realtime_close(&mut self) -> Result<(), TransportError> {
23449            Ok(())
23450        }
23451    }
23452
23453    #[test]
23454    fn an_http_refusal_carries_its_code_and_recommended_action() {
23455        let mut client = client();
23456        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23457        let outcome = client.sync(&mut RefusalTransport);
23458        let SyncOutcome::Failed {
23459            error_code,
23460            recommended_action,
23461            details,
23462            ..
23463        } = outcome
23464        else {
23465            panic!("expected a failed round");
23466        };
23467        assert_eq!(error_code, "sync.invalid_client_id");
23468        assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
23469        assert_eq!(
23470            details
23471                .as_ref()
23472                .and_then(|details| details.get("httpStatus")),
23473            Some(&json!(400))
23474        );
23475    }
23476
23477    #[test]
23478    fn reset_client_id_persists_and_changes_the_request_identity() {
23479        let mut client = client();
23480        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23481        let previous = client.client_id().to_owned();
23482        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23483        let next = client.reset_client_id(&mut transport);
23484        assert_ne!(next, previous);
23485        assert_eq!(client.client_id(), next);
23486        assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
23487
23488        client
23489            .subscribe(
23490                "tasks".into(),
23491                "tasks".into(),
23492                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23493                None,
23494            )
23495            .unwrap();
23496        let (message, _) = client.build_request(false).unwrap();
23497        assert!(matches!(
23498            message.frames.first(),
23499            Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23500        ));
23501    }
23502}
23503
23504#[cfg(test)]
23505mod insert_intent_tests {
23506    use super::*;
23507    use serde_json::{json, Value};
23508
23509    /// Version 1 `tasks(id, title)`; later versions append `extra` columns.
23510    fn tasks_schema(version: i32, extra: Value) -> Value {
23511        let mut columns = vec![
23512            json!({ "name": "id", "type": "string", "nullable": false }),
23513            json!({ "name": "title", "type": "string", "nullable": true }),
23514        ];
23515        columns.extend(extra.as_array().cloned().unwrap_or_default());
23516        json!({
23517            "version": version,
23518            "tables": [{ "name": "tasks", "primaryKey": "id", "columns": columns, "scopes": [] }]
23519        })
23520    }
23521
23522    fn with_note() -> Value {
23523        tasks_schema(
23524            2,
23525            json!([{ "name": "note", "type": "string", "nullable": true }]),
23526        )
23527    }
23528
23529    fn upsert(id: &str, title: &str) -> Mutation {
23530        Mutation::Upsert {
23531            table: "tasks".into(),
23532            values: Map::from_iter([("id".into(), json!(id)), ("title".into(), json!(title))]),
23533            base_version: None,
23534        }
23535    }
23536
23537    fn persisted_markers(client: &SyncClient) -> Vec<Vec<Option<Value>>> {
23538        let mut statement = client
23539            .conn
23540            .prepare("SELECT ops_json FROM _syncular_outbox ORDER BY seq")
23541            .unwrap();
23542        statement
23543            .query_map([], |row| row.get::<_, String>(0))
23544            .unwrap()
23545            .map(|raw| {
23546                serde_json::from_str::<Vec<Value>>(&raw.unwrap())
23547                    .unwrap()
23548                    .iter()
23549                    .map(|entry| entry.get("insert").cloned())
23550                    .collect()
23551            })
23552            .collect()
23553    }
23554
23555    /// One insert of `q` queued under version 1, then the bump to `next`.
23556    /// `strip_marker` rewrites the persisted operation into the pre-marker
23557    /// form an earlier release wrote.
23558    fn queued_insert_after_bump(next: &Value, strip_marker: bool) -> SyncClient {
23559        let mut client = SyncClient::new(
23560            "insert-intent".into(),
23561            &tasks_schema(1, json!([])),
23562            ClientLimits::default(),
23563        )
23564        .unwrap();
23565        client.mutate(vec![upsert("q", "new")]).unwrap();
23566        if strip_marker {
23567            client
23568                .conn
23569                .execute_batch(
23570                    "UPDATE _syncular_outbox SET ops_json = json_remove(ops_json, '$[0].insert')",
23571                )
23572                .unwrap();
23573            client.outbox[0].ops[0].insert = false;
23574        }
23575        client.recreate_with_schema(next).unwrap();
23576        client
23577    }
23578
23579    /// Write `q` into the base table under the current schema and replay the
23580    /// outbox over it.
23581    fn seed_base_row(client: &mut SyncClient, row: Row) {
23582        client
23583            .write_row(&base_table("tasks"), "tasks", &row, 1)
23584            .unwrap();
23585        client.overlay_dirty.set(true);
23586        client.rebuild_overlay().unwrap();
23587    }
23588
23589    fn sparse_payload(client: &SyncClient, values: Value) -> Vec<u8> {
23590        let table = client.schema.table("tasks").unwrap();
23591        encode_sparse_row_json(
23592            table,
23593            "q",
23594            values.as_object().unwrap(),
23595            &client.encryption,
23596            None,
23597        )
23598        .unwrap()
23599    }
23600
23601    #[test]
23602    fn mutate_records_insert_intent_only_for_rows_absent_at_call_time() {
23603        let mut client = SyncClient::new(
23604            "insert-intent".into(),
23605            &tasks_schema(1, json!([])),
23606            ClientLimits::default(),
23607        )
23608        .unwrap();
23609        // Both upserts of one batch see the replica as it was at call time.
23610        client
23611            .mutate(vec![upsert("a", "first"), upsert("a", "second")])
23612            .unwrap();
23613        client.mutate(vec![upsert("a", "visible")]).unwrap();
23614        client
23615            .patch(
23616                "tasks",
23617                "a",
23618                Map::from_iter([("title".into(), json!("patched"))]),
23619                None,
23620            )
23621            .unwrap();
23622        client
23623            .mutate(vec![Mutation::Delete {
23624                table: "tasks".into(),
23625                row_id: "a".into(),
23626                base_version: None,
23627            }])
23628            .unwrap();
23629
23630        let marker = Some(Value::Bool(true));
23631        assert_eq!(
23632            persisted_markers(&client),
23633            vec![
23634                vec![marker.clone(), marker],
23635                vec![None],
23636                vec![None],
23637                vec![None]
23638            ]
23639        );
23640        let flags = |commits: &[OutboxCommit]| {
23641            commits
23642                .iter()
23643                .map(|commit| commit.ops.iter().map(|op| op.insert).collect::<Vec<_>>())
23644                .collect::<Vec<_>>()
23645        };
23646        let expected = vec![vec![true, true], vec![false], vec![false], vec![false]];
23647        assert_eq!(flags(&client.outbox), expected);
23648        assert_eq!(
23649            flags(&client.load_outbox().unwrap()),
23650            expected,
23651            "reload keeps it"
23652        );
23653
23654        // The journal and failed-commit form carries the marker only when set.
23655        let insert = serde_json::to_value(CommitOperation::from(&client.outbox[0].ops[0])).unwrap();
23656        assert_eq!(insert["insert"], json!(true));
23657        assert!(
23658            serde_json::from_value::<CommitOperation>(insert)
23659                .unwrap()
23660                .insert
23661        );
23662        let update = serde_json::to_value(CommitOperation::from(&client.outbox[1].ops[0])).unwrap();
23663        assert!(update.get("insert").is_none());
23664    }
23665
23666    #[test]
23667    fn insert_intent_replays_and_encodes_an_appended_nullable_column_as_null() {
23668        let mut client = queued_insert_after_bump(&with_note(), false);
23669        assert_eq!(
23670            client
23671                .query("SELECT id, title, note FROM tasks", &[])
23672                .unwrap(),
23673            vec![Map::from_iter([
23674                ("id".into(), json!("q")),
23675                ("title".into(), json!("new")),
23676                ("note".into(), Value::Null),
23677            ])],
23678            "the replayed insert is visible with the appended column NULL"
23679        );
23680        assert_eq!(
23681            client.pending_payloads(),
23682            vec![sparse_payload(
23683                &client,
23684                json!({ "id": "q", "title": "new", "note": null })
23685            )],
23686            "the insert presents the appended column as NULL"
23687        );
23688
23689        // Over an existing local row the NULL overwrites the local value.
23690        seed_base_row(
23691            &mut client,
23692            vec![
23693                Some(ColumnValue::String("q".into())),
23694                Some(ColumnValue::String("base".into())),
23695                Some(ColumnValue::String("server".into())),
23696            ],
23697        );
23698        assert_eq!(
23699            client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23700            vec![Map::from_iter([
23701                ("title".into(), json!("new")),
23702                ("note".into(), Value::Null),
23703            ])]
23704        );
23705    }
23706
23707    #[test]
23708    fn an_operation_without_the_marker_keeps_its_presence_set() {
23709        let mut client = queued_insert_after_bump(&with_note(), true);
23710        assert!(
23711            client
23712                .query("SELECT id FROM tasks", &[])
23713                .unwrap()
23714                .is_empty(),
23715            "a partial operation over an absent row stays invisible"
23716        );
23717        assert_eq!(
23718            client.pending_payloads(),
23719            vec![sparse_payload(
23720                &client,
23721                json!({ "id": "q", "title": "new" })
23722            )],
23723            "the appended column stays absent"
23724        );
23725
23726        seed_base_row(
23727            &mut client,
23728            vec![
23729                Some(ColumnValue::String("q".into())),
23730                Some(ColumnValue::String("base".into())),
23731                Some(ColumnValue::String("server".into())),
23732            ],
23733        );
23734        assert_eq!(
23735            client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23736            vec![Map::from_iter([
23737                ("title".into(), json!("new")),
23738                ("note".into(), json!("server")),
23739            ])],
23740            "the stored appended value survives"
23741        );
23742    }
23743
23744    #[test]
23745    fn insert_intent_missing_a_non_nullable_column_is_outbox_incompatible() {
23746        let with_rank = tasks_schema(
23747            2,
23748            json!([{ "name": "rank", "type": "integer", "nullable": false }]),
23749        );
23750        let mut client = SyncClient::new(
23751            "insert-intent".into(),
23752            &tasks_schema(1, json!([])),
23753            ClientLimits::default(),
23754        )
23755        .unwrap();
23756        let insert = client.mutate(vec![upsert("q", "new")]).unwrap();
23757        // An update of the now-visible row carries no insert intent and keeps
23758        // its sparse presence set.
23759        let update = client.mutate(vec![upsert("q", "edited")]).unwrap();
23760        client.recreate_with_schema(&with_rank).unwrap();
23761        assert_eq!(
23762            client.pending_commit_ids(),
23763            vec![insert.clone(), update.clone()],
23764            "the reset classifies nothing"
23765        );
23766        assert!(client.rejections.is_empty());
23767        assert!(
23768            client
23769                .query("SELECT id FROM tasks", &[])
23770                .unwrap()
23771                .is_empty(),
23772            "the unencodable insert stays invisible"
23773        );
23774
23775        assert!(client.drop_incompatible_outbox().unwrap());
23776        assert_eq!(client.pending_commit_ids(), vec![update]);
23777        assert_eq!(client.rejections.len(), 1);
23778        let rejection = &client.rejections[0];
23779        assert_eq!(rejection.client_commit_id, insert);
23780        assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE);
23781        assert!(rejection.operation.as_ref().is_some_and(|op| op.insert));
23782        assert!(
23783            client.commit_outcome(&insert).unwrap().is_some(),
23784            "the journal accepts the marked operation"
23785        );
23786    }
23787
23788    #[test]
23789    fn the_stored_operation_validator_accepts_the_marker_only_on_upserts() {
23790        let upsert =
23791            json!({ "table": "tasks", "rowId": "q", "op": "upsert", "values": { "id": "q" } });
23792        let delete = json!({ "table": "tasks", "rowId": "q", "op": "delete" });
23793        let with = |base: &Value, insert: Value| {
23794            let mut value = base.clone();
23795            value["insert"] = insert;
23796            value
23797        };
23798        assert!(SyncClient::stored_operation_is_representable(&upsert));
23799        assert!(SyncClient::stored_operation_is_representable(&with(
23800            &upsert,
23801            json!(true)
23802        )));
23803        assert!(!SyncClient::stored_operation_is_representable(&with(
23804            &upsert,
23805            json!(false)
23806        )));
23807        assert!(!SyncClient::stored_operation_is_representable(&with(
23808            &upsert,
23809            json!("yes")
23810        )));
23811        assert!(SyncClient::stored_operation_is_representable(&delete));
23812        assert!(!SyncClient::stored_operation_is_representable(&with(
23813            &delete,
23814            json!(true)
23815        )));
23816    }
23817}