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;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32    CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33    CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34    DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35    DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36    LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37    PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38    QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39    RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40    SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41    SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42    WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
59    json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60    sort_scope_map,
61};
62
63/// §4.2 default: rows and SQLite images, both applied in committed chunks.
64/// Bit 3 (signed URLs, §5.4) is
65/// added per transport capability at request-build time.
66const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73/// §10.1 `recommendedAction` carried by a server HTTP error body.
74fn recommended_action_of(details: &Option<Value>) -> Option<String> {
75    details
76        .as_ref()?
77        .get("recommendedAction")?
78        .as_str()
79        .map(str::to_owned)
80}
81
82/// §2.2 scope migration: one commit carries the delete under the old scope and
83/// the upsert under the new one. A reader that holds both scopes receives the
84/// two changes in separate subscription sections, so section order would
85/// otherwise decide whether the row survives. Collect the `(commitSeq, table,
86/// rowId)` keys whose delete must be skipped because the same commit delivers
87/// the row's upsert in another applied section. A section that carries both
88/// changes itself keeps its own operation order.
89fn coalesced_delete_keys(
90    frames: &[Frame],
91    mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
92) -> HashSet<(i64, String, String)> {
93    let mut sections = Vec::new();
94    let mut open: Option<(usize, String, SubStatus)> = None;
95    for (index, frame) in frames.iter().enumerate() {
96        match frame {
97            Frame::SubStart { id, status, .. } => {
98                open = Some((index, id.clone(), *status));
99            }
100            Frame::SubEnd { next_cursor, .. } => {
101                if let Some((start, id, status)) = open.take() {
102                    sections.push((start, index, id, status, Some(*next_cursor)));
103                }
104            }
105            _ => {}
106        }
107    }
108    type Key = (i64, String, String);
109    let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
110    let mut both_in_frame: HashSet<Key> = HashSet::new();
111    let mut frame_ordinal = 0usize;
112    for (start, end, id, status, next_cursor) in sections {
113        if !applies(&id, status, next_cursor) {
114            continue;
115        }
116        for frame in &frames[start + 1..end] {
117            let Frame::Commit {
118                commit_seq,
119                tables,
120                changes,
121                ..
122            } = frame
123            else {
124                continue;
125            };
126            frame_ordinal += 1;
127            let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
128            for change in changes {
129                let Some(table) = tables.get(change.table_index as usize) else {
130                    continue;
131                };
132                let key = (*commit_seq, table.clone(), change.row_id.clone());
133                let entry = ops.entry(key).or_default();
134                let local_entry = local
135                    .entry((table.clone(), change.row_id.clone()))
136                    .or_default();
137                match change.op {
138                    Op::Upsert => {
139                        entry.0.insert(frame_ordinal);
140                        local_entry.0 = true;
141                    }
142                    Op::Delete => {
143                        entry.1.insert(frame_ordinal);
144                        local_entry.1 = true;
145                    }
146                }
147            }
148            for ((table, row_id), (has_upsert, has_delete)) in local {
149                if has_upsert && has_delete {
150                    both_in_frame.insert((*commit_seq, table, row_id));
151                }
152            }
153        }
154    }
155    ops.into_iter()
156        .filter_map(|(key, (upserts, deletes))| {
157            (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
158                .then_some(key)
159        })
160        .collect()
161}
162
163/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
164pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
165const LOCAL_REVISION_KEY: &str = "localRevision";
166const CLIENT_ID_KEY: &str = "clientId";
167const LEASE_STATE_KEY: &str = "leaseState";
168const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
169pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
170/// Persisted fail-closed quarantine marker: present while a security preflight
171/// is pending and `activateSecurity` has yet to run. Storing it in the replica
172/// keeps the gate with the data it protects, so a rebuilt host handle reopening
173/// the same file returns in preflight even though its in-memory state is fresh.
174const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
175const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
176const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
177/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
178/// the new schema after a bump. Never a wire code (§10.3).
179const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
180/// §5.11 client-local code: an encode at the push seam resolved no usable key
181/// id or named an unknown key. Never a wire code (§10.3).
182const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
183const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
184/// §7.4.4: static text for a persisted commit the current codec refuses.
185const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
186    "the persisted commit carries values the current codec refuses";
187/// §7.4.4: bounded reason on every intrinsic value drop.
188const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192struct PersistedLocalDataRebootstrapReceipt {
193    version: u8,
194    retained_commits: u64,
195    reset_subscriptions: u64,
196}
197
198fn invalid_local_rebootstrap_receipt() -> String {
199    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
200}
201
202fn encode_local_rebootstrap_receipt(
203    retained_commits: usize,
204    reset_subscriptions: usize,
205) -> Result<String, String> {
206    let retained_commits =
207        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
208    let reset_subscriptions =
209        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
210    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
211        return Err(invalid_local_rebootstrap_receipt());
212    }
213    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
214        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
215        retained_commits,
216        reset_subscriptions,
217    })
218    .map_err(|_| invalid_local_rebootstrap_receipt())
219}
220
221fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
222    // Pre-0.15.36 markers proved application but did not retain the receipt.
223    if value == "v1" {
224        return Ok((0, 0));
225    }
226    let receipt: PersistedLocalDataRebootstrapReceipt =
227        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
228    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
229        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
230        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
231    {
232        return Err(invalid_local_rebootstrap_receipt());
233    }
234    Ok((
235        usize::try_from(receipt.retained_commits)
236            .map_err(|_| invalid_local_rebootstrap_receipt())?,
237        usize::try_from(receipt.reset_subscriptions)
238            .map_err(|_| invalid_local_rebootstrap_receipt())?,
239    ))
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum SubState {
244    Active,
245    Revoked,
246    Failed,
247}
248
249#[cfg(test)]
250mod observation_tests {
251    use super::*;
252    use crate::native_transport::HostTransport;
253    use crate::values::encode_row_json;
254    use crate::BlobUploadGrant;
255    use serde_json::json;
256
257    #[test]
258    fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
259        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
260            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
261        ]}]});
262        for name in [
263            "secret",
264            "missing",
265            "*",
266            "id FROM authority; DELETE FROM authority",
267            "_syncular_version",
268        ] {
269            let limits = ClientLimits {
270                authority_reads: vec![crate::api::AuthorityReadDeclaration {
271                    table: "authority".into(),
272                    columns: vec!["id".into(), "actor_id".into(), name.into()],
273                    scopes: [("actor_id".into(), vec!["a".into()])].into(),
274                }],
275                ..Default::default()
276            };
277            let error = SyncClient::new("authority".into(), &schema, limits)
278                .err()
279                .expect("forbidden policy");
280            assert!(
281                error.starts_with("client.authority_read_forbidden:"),
282                "{error}"
283            );
284        }
285    }
286
287    #[test]
288    fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
289        let path = std::env::temp_dir().join(format!(
290            "syncular-authority-snapshot-{}.db",
291            uuid::Uuid::new_v4()
292        ));
293        let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
294            {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
295        ]}]});
296        let limits = ClientLimits {
297            authority_reads: vec![crate::api::AuthorityReadDeclaration {
298                table: "authority".into(),
299                columns: vec!["id".into(), "actor_id".into(), "revision".into()],
300                scopes: [("actor_id".into(), vec!["a".into()])].into(),
301            }],
302            ..Default::default()
303        };
304        let mut client =
305            SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
306                .unwrap();
307        client.begin_security_preflight();
308        let update = |conn: &Connection, revision: i64| {
309            conn.execute_batch("BEGIN IMMEDIATE").unwrap();
310            conn.execute(
311                "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
312                [revision],
313            )
314            .unwrap();
315            conn.execute(
316                "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
317                [revision.to_string()],
318            )
319            .unwrap();
320            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();
321            conn.execute_batch("COMMIT").unwrap();
322        };
323        update(&client.conn, 1);
324        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
325        let writer_barrier = barrier.clone();
326        let writer_path = path.clone();
327        let writer = std::thread::spawn(move || {
328            let conn = Connection::open(writer_path).unwrap();
329            conn.busy_timeout(std::time::Duration::from_secs(5))
330                .unwrap();
331            writer_barrier.wait();
332            for revision in 2..=100 {
333                update(&conn, revision);
334            }
335        });
336        barrier.wait();
337        for _ in 0..100 {
338            let snapshot = client.authority_snapshot().unwrap();
339            let revision = snapshot["revision"]
340                .as_str()
341                .unwrap()
342                .parse::<i64>()
343                .unwrap();
344            assert_eq!(
345                snapshot["tables"][0]["rows"][0]["values"]["revision"],
346                revision
347            );
348            assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
349            assert_eq!(snapshot["complete"], true);
350        }
351        writer.join().unwrap();
352        assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
353        drop(client);
354        let _ = std::fs::remove_file(path);
355    }
356
357    fn client() -> SyncClient {
358        SyncClient::new(
359            "retry-test".to_owned(),
360            &json!({
361                "version": 1,
362                "tables": [{
363                    "name": "tasks",
364                    "primaryKey": "id",
365                    "columns": [
366                        { "name": "id", "type": "string", "nullable": false },
367                        { "name": "project_id", "type": "string", "nullable": false }
368                    ],
369                    "scopes": [{ "pattern": "project:{project_id}" }]
370                }]
371            }),
372            ClientLimits::default(),
373        )
374        .expect("test client")
375    }
376
377    fn round_error(outcome: SyncOutcome) -> TransportError {
378        match outcome {
379            SyncOutcome::Failed {
380                error_code,
381                message,
382                details,
383                ..
384            } => TransportError {
385                code: error_code,
386                message,
387                details,
388            },
389            other => TransportError::new(
390                "client.failed",
391                format!("unexpected round outcome: {other:?}"),
392            ),
393        }
394    }
395
396    /// Upload every pinned blob through the split round API and stop before the
397    /// metadata request.
398    fn flush_blob_uploads(
399        client: &mut SyncClient,
400        transport: &mut dyn Transport,
401    ) -> Result<(), TransportError> {
402        let mut prepared = match client.prepare_sync_round(false) {
403            Ok(prepared) => prepared,
404            Err(outcome) => return Err(round_error(*outcome)),
405        };
406        loop {
407            if prepared.uploads.is_empty() {
408                client.cancel_sync_round();
409                return Ok(());
410            }
411            let completed = crate::round::test_support::drive_round(prepared, transport);
412            prepared = match client.apply_sync_round(completed) {
413                crate::AppliedSyncRound::Continue(prepared) => prepared,
414                crate::AppliedSyncRound::Complete { outcome, .. } => {
415                    return match outcome {
416                        SyncOutcome::Ok(_) => Ok(()),
417                        other => Err(round_error(other)),
418                    };
419                }
420            };
421        }
422    }
423
424    #[test]
425    fn subscribe_many_registers_all_or_nothing() {
426        let request = |id: &str, project: &str| SubscriptionRequest {
427            id: id.to_owned(),
428            table: "tasks".to_owned(),
429            scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
430            params: None,
431        };
432        let mut client = client();
433        client
434            .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
435            .unwrap();
436        let persisted = |client: &SyncClient| -> i64 {
437            client
438                .conn
439                .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
440                    row.get(0)
441                })
442                .unwrap()
443        };
444        assert_eq!(persisted(&client), 2);
445        // `b` names another project: the whole call fails, `c` stays out.
446        let error = client
447            .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
448            .unwrap_err();
449        assert!(error.starts_with("client.subscription_intent_mismatch:"));
450        assert!(client.subscription_state("c").is_none());
451        assert_eq!(persisted(&client), 2);
452        assert!(client.conn.is_autocommit());
453    }
454
455    #[cfg(feature = "e2ee")]
456    #[test]
457    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
458        let schema = json!({
459            "version": 1,
460            "tables": [{
461                "name": "secrets",
462                "primaryKey": "id",
463                "columns": [
464                    { "name": "id", "type": "string", "nullable": false },
465                    { "name": "encryption_key_id", "type": "string", "nullable": true },
466                    { "name": "note", "type": "bytes", "nullable": true,
467                      "encrypted": true, "declaredType": "string" }
468                ],
469                "scopes": []
470            }]
471        });
472        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
473            .expect("test client");
474        let mut config = crate::values::EncryptionConfig::default();
475        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
476        config
477            .key_id_columns
478            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
479        client.set_encryption(config);
480        // The row exists, but its key selector is absent. The encrypted edit
481        // authors locally and fails at the push seam.
482        client
483            .write_base_row(
484                "secrets",
485                &vec![Some(ColumnValue::String("ghost".into())), None, None],
486                1,
487            )
488            .unwrap();
489        client.rebuild_overlay().expect("rebuild overlay");
490        let commit_id = client
491            .patch(
492                "secrets",
493                "ghost",
494                Map::from_iter([("note".to_owned(), json!("x"))]),
495                None,
496            )
497            .expect("patch records without an author-time encrypt failure");
498        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
499        // ...and the push seam raises a durable local rejection, not a throw.
500        assert!(client.drop_unencodable_outbox(true, None).unwrap());
501        assert!(client.pending_commit_ids().is_empty());
502        let rejections = client.rejections();
503        assert_eq!(rejections.len(), 1);
504        assert_eq!(rejections[0].code, "client.encrypt_failed");
505        assert_eq!(rejections[0].client_commit_id, commit_id);
506        assert_eq!(
507            client
508                .commit_outcome(&commit_id)
509                .unwrap()
510                .map(|outcome| outcome.status),
511            Some(CommitOutcomeStatus::Rejected)
512        );
513    }
514
515    #[test]
516    fn patch_scope_read_preserves_sqlite_failure() {
517        let mut client = client();
518        client
519            .conn
520            .execute_batch(
521                "DROP TABLE tasks;
522            CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
523            )
524            .unwrap();
525        let error = client
526            .patch(
527                "tasks",
528                "t1",
529                Map::from_iter([("project_id".to_owned(), json!("p1"))]),
530                None,
531            )
532            .expect_err("the scope read must fail");
533        assert_ne!(error.code, "sync.invalid_request");
534        assert_eq!(error.code, "client.failed");
535        assert!(error.details.as_ref().unwrap()["legacyCause"]
536            .as_str()
537            .unwrap()
538            .contains("integer overflow"));
539        assert!(client.pending_commit_ids().is_empty());
540        assert!(client.conn.is_autocommit());
541    }
542
543    #[test]
544    fn patch_with_a_scope_column_matches_the_stored_local_row() {
545        let mut client = client();
546        client.create_synced_tables().unwrap();
547        client
548            .conn
549            .execute(
550                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
551                [],
552            )
553            .unwrap();
554
555        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
556        // the scope column leaves the presence set.
557        client
558            .patch(
559                "tasks",
560                "t1",
561                Map::from_iter([
562                    ("project_id".to_owned(), json!("p1")),
563                    ("id".to_owned(), json!("t1")),
564                ]),
565                None,
566            )
567            .expect("an equal scope column is a no-op");
568        let values = client.outbox[0].ops[0]
569            .values
570            .clone()
571            .expect("the patch records values");
572        assert_eq!(values.get("id"), Some(&json!("t1")));
573        assert!(!values.contains_key("project_id"));
574
575        // A differing value stays rejected.
576        let differing = client.patch(
577            "tasks",
578            "t1",
579            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
580            None,
581        );
582        let differing = differing.expect_err("a differing scope column is rejected");
583        assert_eq!(differing.code, "sync.invalid_request");
584        assert_eq!(differing.message, "the authoring request is invalid");
585        assert!(
586            differing
587                .details
588                .as_ref()
589                .and_then(|details| details["legacyCause"].as_str())
590                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
591            "{differing:?}"
592        );
593
594        // An absent local row leaves nothing to prove equality against, so
595        // the patch fails closed.
596        let absent = client.patch(
597            "tasks",
598            "ghost",
599            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
600            None,
601        );
602        let absent = absent.expect_err("an absent local row is rejected");
603        assert_eq!(absent.code, "sync.row_missing");
604        assert!(absent.message.contains("requires a local row"));
605        assert_eq!(client.pending_commit_ids().len(), 1);
606    }
607
608    #[test]
609    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
610        let schema = json!({
611            "version": 1,
612            "tables": [
613                {
614                    "name": "tenants",
615                    "primaryKey": "tenant_id",
616                    "columns": [
617                        { "name": "tenant_id", "type": "string", "nullable": false },
618                        { "name": "body", "type": "string", "nullable": false }
619                    ],
620                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
621                },
622                {
623                    "name": "buckets",
624                    "primaryKey": "id",
625                    "columns": [
626                        { "name": "id", "type": "string", "nullable": false },
627                        { "name": "bucket", "type": "float", "nullable": false },
628                        { "name": "body", "type": "string", "nullable": false }
629                    ],
630                    "scopes": [{ "pattern": "bucket:{bucket}" }]
631                }
632            ]
633        });
634        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
635            .expect("test client");
636        client.create_synced_tables().unwrap();
637
638        // A primary key that is also a scope column still needs a local base.
639        client
640            .conn
641            .execute(
642                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
643                [],
644            )
645            .unwrap();
646        client
647            .patch(
648                "tenants",
649                "t1",
650                Map::from_iter([
651                    ("tenant_id".to_owned(), json!("t1")),
652                    ("body".to_owned(), json!("created")),
653                ]),
654                None,
655            )
656            .expect("the primary-key scope column is proven equal by construction");
657
658        // A stored REAL scope value coerces from a supplied integer.
659        client
660            .conn
661            .execute(
662                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
663                [],
664            )
665            .unwrap();
666        client
667            .patch(
668                "buckets",
669                "b1",
670                Map::from_iter([
671                    ("bucket".to_owned(), json!(2)),
672                    ("body".to_owned(), json!("coerced")),
673                ]),
674                None,
675            )
676            .expect("the stored REAL value coerces from a supplied integer");
677        let values = client.outbox[1].ops[0]
678            .values
679            .clone()
680            .expect("the patch records values");
681        assert!(!values.contains_key("bucket"));
682
683        // A differing value stays rejected.
684        let differing = client.patch(
685            "buckets",
686            "b1",
687            Map::from_iter([("bucket".to_owned(), json!(3))]),
688            None,
689        );
690        let differing = differing.expect_err("a differing scope value is rejected");
691        assert_eq!(differing.code, "sync.invalid_request");
692        assert_eq!(differing.message, "the authoring request is invalid");
693        assert!(
694            differing
695                .details
696                .as_ref()
697                .and_then(|details| details["legacyCause"].as_str())
698                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
699            "{differing:?}"
700        );
701    }
702
703    #[cfg(feature = "e2ee")]
704    #[test]
705    fn stored_key_fallback_resolves_an_integer_primary_key() {
706        let schema = json!({
707            "version": 1,
708            "tables": [{
709                "name": "patients",
710                "primaryKey": "id",
711                "columns": [
712                    { "name": "id", "type": "integer", "nullable": false },
713                    { "name": "encryption_key_id", "type": "string", "nullable": true },
714                    { "name": "note", "type": "bytes", "nullable": true,
715                      "encrypted": true, "declaredType": "string" }
716                ],
717                "scopes": []
718            }]
719        });
720        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
721            .expect("test client");
722        client.create_synced_tables().unwrap();
723        client
724            .conn
725            .execute(
726                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
727                 VALUES (5, 'k1', NULL, 1)",
728                [],
729            )
730            .unwrap();
731        let mut config = crate::values::EncryptionConfig::default();
732        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
733        config
734            .key_id_columns
735            .insert("patients".to_owned(), "encryption_key_id".to_owned());
736        client.set_encryption(config);
737        let table = client.schema.table("patients").unwrap();
738        let fallback = client
739            .stored_key_fallback(table, "5")
740            .expect("the stored integer key resolves the fallback");
741        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
742    }
743
744    #[test]
745    fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
746        let schema = json!({
747            "version": 1,
748            "tables": [{
749                "name": "counters",
750                "primaryKey": "id",
751                "columns": [
752                    { "name": "id", "type": "integer", "nullable": false },
753                    { "name": "title", "type": "string", "nullable": false }
754                ],
755                "scopes": []
756            }]
757        });
758        let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
759            .expect("test client");
760        client
761            .write_base_row(
762                "counters",
763                &vec![
764                    Some(ColumnValue::Integer(42)),
765                    Some(ColumnValue::String("before".into())),
766                ],
767                1,
768            )
769            .unwrap();
770        client.rebuild_overlay().expect("rebuild overlay");
771        client
772            .patch(
773                "counters",
774                "42",
775                Map::from_iter([
776                    ("id".to_owned(), json!(42)),
777                    ("title".to_owned(), json!("after")),
778                ]),
779                None,
780            )
781            .expect("an integer key that renders as the rowId is not a change");
782        let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
783        assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
784        for row_id in ["042", "+42", "42.0"] {
785            let error = client
786                .patch(
787                    "counters",
788                    row_id,
789                    Map::from_iter([("title".to_owned(), json!("x"))]),
790                    None,
791                )
792                .expect_err("a non-canonical integer rowId names no row");
793            assert_eq!(error.code, "sync.row_missing", "{row_id}");
794        }
795    }
796
797    #[test]
798    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
799        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
800            {"name":"id","type":"string","nullable":false},
801            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
802        for state in ["absent", "cached", "pinned"] {
803            let path = std::env::temp_dir().join(format!(
804                "syncular-blob-stage-{}.sqlite",
805                uuid::Uuid::new_v4()
806            ));
807            let mut client = SyncClient::open_path(
808                "stage-test".into(),
809                &schema,
810                Default::default(),
811                path.to_str().unwrap(),
812            )
813            .unwrap();
814            let bytes = b"atomic stage";
815            if state != "absent" {
816                client
817                    .upload_blob(bytes, Some("text/plain".into()), None)
818                    .unwrap();
819                if state == "cached" {
820                    client
821                        .conn
822                        .execute("DELETE FROM _syncular_blob_uploads", [])
823                        .unwrap();
824                }
825            }
826            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
827            client
828                .conn
829                .execute_batch(
830                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
831                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
832                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
833                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
834                )
835                .unwrap();
836            let error = client
837                .upload_blob(bytes, Some("application/octet-stream".into()), None)
838                .unwrap_err();
839            assert!(error.contains("injected stage failure"), "{state}: {error}");
840            assert!(client.conn.is_autocommit());
841            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
842            drop(client);
843            let mut reopened = SyncClient::open_path(
844                "stage-test".into(),
845                &schema,
846                Default::default(),
847                path.to_str().unwrap(),
848            )
849            .unwrap();
850            let durable = reopened
851                .query("SELECT * FROM _syncular_blobs", &[])
852                .unwrap();
853            reopened
854                .conn
855                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
856                .unwrap();
857            let retried = reopened.upload_blob(bytes, None, None).unwrap();
858            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
859            assert_eq!(
860                reopened
861                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
862                    .unwrap(),
863                vec![Map::from_iter([(
864                    "blob_id".into(),
865                    Value::from(blob_id_for(bytes)),
866                )])]
867            );
868            drop(reopened);
869            std::fs::remove_file(&path).unwrap();
870            assert_eq!(durable, bodies, "{state}: reopened body state");
871            assert_eq!(immediate, bodies, "{state}: immediate body state");
872        }
873    }
874
875    #[test]
876    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
877        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
878            {"name":"id","type":"string","nullable":false},
879            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
880        for fault in [
881            "missing",
882            "body-type",
883            "length",
884            "hash",
885            "pin-metadata",
886            "read",
887            "pin-delete",
888        ] {
889            let path = std::env::temp_dir().join(format!(
890                "syncular-blob-upload-{}.sqlite",
891                uuid::Uuid::new_v4()
892            ));
893            let mut client = SyncClient::open_path(
894                "upload-test".into(),
895                &schema,
896                Default::default(),
897                path.to_str().unwrap(),
898            )
899            .unwrap();
900            let bytes = b"durable upload";
901            let ref_value = client
902                .upload_blob(bytes, Some("text/plain".into()), None)
903                .unwrap();
904            let commit = client
905                .mutate(vec![Mutation::Upsert {
906                    table: "attachments".into(),
907                    values: Map::from_iter([
908                        ("id".into(), Value::from("pending")),
909                        (
910                            "file".into(),
911                            Value::from(format!(
912                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
913                                blob_id_for(bytes),
914                                bytes.len()
915                            )),
916                        ),
917                    ]),
918                    base_version: None,
919                }])
920                .unwrap();
921            client
922                .conn
923                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
924                .unwrap();
925            client.conn.execute_batch(match fault {
926                "missing" => "DELETE FROM _syncular_blobs",
927                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
928                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
929                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
930                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
931                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
932                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
933            }).unwrap();
934            let state = if fault == "read" {
935                Vec::new()
936            } else {
937                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
938            };
939            client.set_meta(LOG_EPOCH_KEY, "epoch");
940            let mut transport = CountingRealtimeTransport::default();
941            let error = flush_blob_uploads(&mut client, &mut transport).unwrap_err();
942            assert_eq!(
943                error.code,
944                if matches!(fault, "read" | "pin-delete") {
945                    "client.failed"
946                } else {
947                    "sync.local_corrupt"
948                },
949                "{fault}"
950            );
951            assert_eq!(
952                transport.blob_uploads.len(),
953                usize::from(fault == "pin-delete"),
954                "{fault}: corrupt bytes must never be uploaded"
955            );
956            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
957            if fault != "read" {
958                assert_eq!(
959                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
960                    state
961                );
962            } else {
963                client
964                    .conn
965                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
966                    .unwrap();
967            }
968            drop(client);
969            let mut reopened = SyncClient::open_path(
970                "upload-test".into(),
971                &schema,
972                Default::default(),
973                path.to_str().unwrap(),
974            )
975            .unwrap();
976            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
977            if fault != "read" {
978                assert_eq!(
979                    reopened
980                        .query("SELECT * FROM _syncular_blobs", &[])
981                        .unwrap(),
982                    state
983                );
984            }
985            if fault == "pin-delete" {
986                reopened
987                    .conn
988                    .execute_batch("DROP TRIGGER fail_pin_delete")
989                    .unwrap();
990            }
991            if fault == "pin-metadata" {
992                reopened
993                    .conn
994                    .execute(
995                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
996                        [],
997                    )
998                    .unwrap();
999            }
1000            reopened
1001                .conn
1002                .execute_batch(
1003                    "DELETE FROM _syncular_blobs;
1004                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
1005                )
1006                .unwrap();
1007            transport.blob_uploads.clear();
1008            flush_blob_uploads(&mut reopened, &mut transport).unwrap();
1009            assert_eq!(
1010                transport.blob_uploads,
1011                [(blob_id_for(bytes), bytes.to_vec())]
1012            );
1013            assert_eq!(
1014                reopened
1015                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
1016                    .unwrap(),
1017                Vec::<Map<String, Value>>::new()
1018            );
1019            assert_eq!(reopened.pending_commit_ids(), [commit]);
1020            assert_eq!(
1021                reopened
1022                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
1023                    .unwrap()
1024                    .unwrap()
1025                    .bytes,
1026                bytes
1027            );
1028            drop(reopened);
1029            std::fs::remove_file(path).unwrap();
1030        }
1031    }
1032
1033    /// §1.4 rule 4: `sections` subscriptions, each with `commits` COMMIT
1034    /// frames of `rows` rows. Returns the client with its subscriptions
1035    /// registered and the response.
1036    fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
1037        let mut client = client();
1038        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1039        let mut response = Message {
1040            wire_version: WIRE_VERSION,
1041            msg_kind: MsgKind::Response,
1042            frames: vec![Frame::RespHeader {
1043                required_schema_version: None,
1044                latest_schema_version: None,
1045                log_epoch: Some("epoch-1".into()),
1046                reset_required: Some(false),
1047            }],
1048        };
1049        let mut seq = 0;
1050        for section in 0..sections {
1051            let project = format!("p{section}");
1052            let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1053            client
1054                .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1055                .unwrap();
1056            let sub = client.subs.last_mut().unwrap();
1057            sub.cursor = 0;
1058            sub.synced_once = true;
1059            sub.effective = Some(scopes.clone());
1060            let sub = sub.clone();
1061            client.persist_sub(&sub).unwrap();
1062            response.frames.push(Frame::SubStart {
1063                id: format!("s{section}"),
1064                status: SubStatus::Active,
1065                reason_code: "".into(),
1066                effective_scopes: scopes,
1067                bootstrap: false,
1068            });
1069            for _ in 0..commits {
1070                seq += 1;
1071                let changes = (0..rows)
1072                    .map(|row| {
1073                        let id = format!("{project}-{seq}-{row}");
1074                        let values = Map::from_iter([
1075                            ("id".into(), Value::from(id.clone())),
1076                            ("project_id".into(), Value::from(project.clone())),
1077                        ]);
1078                        ssp2::model::Change {
1079                            table_index: 0,
1080                            row_id: id.clone(),
1081                            op: Op::Upsert,
1082                            row_version: Some(seq),
1083                            scopes: vec![("project_id".into(), project.clone())],
1084                            row: Some(
1085                                encode_row_json(
1086                                    client.schema.table("tasks").unwrap(),
1087                                    &id,
1088                                    &values,
1089                                    &client.encryption,
1090                                )
1091                                .unwrap(),
1092                            ),
1093                        }
1094                    })
1095                    .collect();
1096                response.frames.push(Frame::Commit {
1097                    commit_seq: seq,
1098                    actor_id: "writer".into(),
1099                    created_at_ms: 1,
1100                    tables: vec!["tasks".into()],
1101                    changes,
1102                });
1103            }
1104            response.frames.push(Frame::SubEnd {
1105                next_cursor: seq,
1106                bootstrap_state: None,
1107            });
1108        }
1109        client.drain_change_batches();
1110        (client, response)
1111    }
1112
1113    fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1114        let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1115        let counter = commits.clone();
1116        client
1117            .conn
1118            .commit_hook(Some(move || {
1119                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1120                false
1121            }))
1122            .expect("commit hook");
1123        commits
1124    }
1125
1126    #[test]
1127    fn a_response_of_many_small_sections_commits_once() {
1128        for realtime in [false, true] {
1129            let (mut client, response) = coalesced_fixture(70, 1, 1);
1130            client.realtime_state = RealtimeState::Connected;
1131            client.realtime_reason_code = None;
1132            let mut transport = CountingRealtimeTransport::default();
1133            let (_, meta) = client.build_request(false).unwrap();
1134            let commits = count_commits(&client);
1135            if realtime {
1136                client.on_realtime_binary(&mut transport, &encode_message(&response));
1137            } else {
1138                assert!(matches!(
1139                    client.process_response(&mut transport, response, &meta),
1140                    SyncOutcome::Ok(_)
1141                ));
1142            }
1143            assert_eq!(
1144                commits.load(std::sync::atomic::Ordering::SeqCst),
1145                1,
1146                "{realtime}"
1147            );
1148            assert!(client.conn.is_autocommit());
1149            assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1150            // Every frame keeps its own revision, published in order.
1151            let revisions = client
1152                .drain_change_batches()
1153                .into_iter()
1154                .filter(|batch| !batch.tables.is_empty())
1155                .map(|batch| batch.revision)
1156                .collect::<Vec<_>>();
1157            assert_eq!(
1158                revisions,
1159                (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1160            );
1161            assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1162            // The ack follows the COMMIT.
1163            assert_eq!(
1164                transport
1165                    .messages
1166                    .iter()
1167                    .filter(|m| m.contains("\"ack\""))
1168                    .count(),
1169                1,
1170                "{realtime}"
1171            );
1172        }
1173    }
1174
1175    #[test]
1176    fn a_response_past_the_row_bound_commits_per_bound() {
1177        // 3 frames of 600 rows: no two fit one 1,024-row transaction.
1178        let (mut client, response) = coalesced_fixture(1, 3, 600);
1179        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1180        let (_, meta) = client.build_request(false).unwrap();
1181        let commits = count_commits(&client);
1182        assert!(matches!(
1183            client.process_response(&mut transport, response, &meta),
1184            SyncOutcome::Ok(_)
1185        ));
1186        assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1187        assert_eq!(
1188            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1189            1800
1190        );
1191        assert_eq!(client.subs[0].cursor, 3);
1192    }
1193
1194    #[test]
1195    fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1196        // Two sections of two frames of 600 rows: the bound commits the first
1197        // frame alone, so the deferred violation it carries fails that
1198        // mid-response COMMIT, and every later frame of the response fails.
1199        let (mut client, response) = coalesced_fixture(2, 2, 600);
1200        client
1201            .conn
1202            .execute_batch(
1203                "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",
1204            )
1205            .unwrap();
1206        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1207        let (_, meta) = client.build_request(false).unwrap();
1208        let subs = client.subs.clone();
1209        let revision = client.local_revision();
1210        let outcome = client.process_response(&mut transport, response.clone(), &meta);
1211        assert!(
1212            matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1213            "{outcome:?}"
1214        );
1215        assert!(client.conn.is_autocommit());
1216        assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1217        assert_eq!(client.local_revision(), revision);
1218        assert!(client
1219            .drain_change_batches()
1220            .iter()
1221            .all(|batch| batch.tables.is_empty()));
1222        assert!(client
1223            .query("SELECT id FROM tasks", &[])
1224            .unwrap()
1225            .is_empty());
1226        assert_eq!(
1227            client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1228            subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1229        );
1230        // The next response applies in full.
1231        client
1232            .conn
1233            .execute_batch("DROP TRIGGER fail_commit")
1234            .unwrap();
1235        let (_, meta) = client.build_request(false).unwrap();
1236        assert!(matches!(
1237            client.process_response(&mut transport, response, &meta),
1238            SyncOutcome::Ok(_)
1239        ));
1240        assert_eq!(
1241            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1242            2400
1243        );
1244    }
1245
1246    #[test]
1247    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1248        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1249            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1250            "scopes":[{"pattern":"project:{project_id}"}]}]});
1251        for realtime in [false, true] {
1252            for fault in [
1253                "none",
1254                "row",
1255                "revision",
1256                "commit",
1257                "cursor",
1258                "error",
1259                "missing-end",
1260            ] {
1261                if realtime && fault == "missing-end" {
1262                    continue;
1263                }
1264                let path = std::env::temp_dir()
1265                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1266                let mut client = SyncClient::open_path(
1267                    "frame-test".into(),
1268                    &schema,
1269                    Default::default(),
1270                    path.to_str().unwrap(),
1271                )
1272                .unwrap();
1273                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1274                client
1275                    .subscribe(
1276                        "tasks".into(),
1277                        "tasks".into(),
1278                        vec![("project_id".into(), vec!["p1".into()])],
1279                        None,
1280                    )
1281                    .unwrap();
1282                client.subs[0].cursor = 0;
1283                client.subs[0].synced_once = true;
1284                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1285                client.persist_sub(&client.subs[0]).unwrap();
1286                client.realtime_state = RealtimeState::Connected;
1287                client.realtime_reason_code = None;
1288                client.drain_change_batches();
1289                let mut transport = CountingRealtimeTransport::default();
1290                let (_, meta) = client.build_request(false).unwrap();
1291                let mut response = Message {
1292                    wire_version: WIRE_VERSION,
1293                    msg_kind: MsgKind::Response,
1294                    frames: vec![
1295                        Frame::RespHeader {
1296                            required_schema_version: None,
1297                            latest_schema_version: None,
1298                            log_epoch: Some("epoch-1".into()),
1299                            reset_required: Some(false),
1300                        },
1301                        Frame::SubStart {
1302                            id: "tasks".into(),
1303                            status: SubStatus::Active,
1304                            reason_code: "".into(),
1305                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1306                            bootstrap: false,
1307                        },
1308                    ],
1309                };
1310                for (index, id) in ["first", "second"].iter().enumerate() {
1311                    let values = Map::from_iter([
1312                        ("id".into(), Value::from(*id)),
1313                        ("project_id".into(), Value::from("p1")),
1314                    ]);
1315                    response.frames.push(Frame::Commit {
1316                        commit_seq: index as i64 + 1,
1317                        actor_id: "writer".into(),
1318                        created_at_ms: 1,
1319                        tables: vec!["tasks".into()],
1320                        changes: vec![ssp2::model::Change {
1321                            table_index: 0,
1322                            row_id: (*id).into(),
1323                            op: Op::Upsert,
1324                            row_version: Some(index as i64 + 1),
1325                            scopes: vec![("project_id".into(), "p1".into())],
1326                            row: Some(
1327                                encode_row_json(
1328                                    client.schema.table("tasks").unwrap(),
1329                                    id,
1330                                    &values,
1331                                    &client.encryption,
1332                                )
1333                                .unwrap(),
1334                            ),
1335                        }],
1336                    });
1337                }
1338                response.frames.push(Frame::SubEnd {
1339                    next_cursor: 2,
1340                    bootstrap_state: None,
1341                });
1342                let valid = response.clone();
1343                match fault {
1344                    "row" => {
1345                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1346                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1347                    },
1348                    "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(),
1349                    "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(),
1350                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1351                    "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});},
1352                    "missing-end" => {response.frames.truncate(3);},
1353                    _ => {}
1354                }
1355                if realtime {
1356                    client.on_realtime_binary(&mut transport, &encode_message(&response));
1357                } else {
1358                    assert_eq!(
1359                        matches!(
1360                            client.process_response(&mut transport, response, &meta),
1361                            SyncOutcome::Ok(_)
1362                        ),
1363                        fault == "none",
1364                        "{fault}"
1365                    );
1366                }
1367                assert!(
1368                    client.conn.is_autocommit(),
1369                    "no open transaction after {realtime}/{fault}"
1370                );
1371                // §1.4 rule 4: the deferred foreign key of the "commit" fault
1372                // fails the response's coalesced COMMIT, which rolls back both
1373                // frames; the other faults roll back only the failing frame.
1374                let count = match fault {
1375                    "none" | "cursor" => 2,
1376                    "commit" => 0,
1377                    _ => 1,
1378                };
1379                let expected_revision = count + u64::from(realtime && fault != "none");
1380                assert_eq!(
1381                    client.local_revision(),
1382                    expected_revision,
1383                    "{realtime}/{fault}"
1384                );
1385                let batches = client.drain_change_batches();
1386                assert_eq!(
1387                    batches.len(),
1388                    expected_revision as usize,
1389                    "{realtime}/{fault}"
1390                );
1391                let row_batches: Vec<_> = batches
1392                    .iter()
1393                    .filter(|batch| !batch.tables.is_empty())
1394                    .collect();
1395                assert_eq!(row_batches.len(), count as usize);
1396                assert_eq!(
1397                    row_batches
1398                        .iter()
1399                        .map(|b| b.revision.clone())
1400                        .collect::<Vec<_>>(),
1401                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1402                );
1403                for table in ["tasks", "_syncular_base_tasks"] {
1404                    assert_eq!(
1405                        client
1406                            .conn
1407                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1408                                .get::<_, u64>(0))
1409                            .unwrap(),
1410                        count,
1411                        "{realtime}/{fault}/{table}"
1412                    );
1413                }
1414                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1415                assert_eq!(
1416                    transport
1417                        .messages
1418                        .iter()
1419                        .filter(|m| m.contains("\"ack\""))
1420                        .count(),
1421                    usize::from(fault == "none")
1422                );
1423                if matches!(fault, "revision" | "commit" | "cursor") {
1424                    client
1425                        .conn
1426                        .execute_batch("DROP TRIGGER fail_frame")
1427                        .unwrap();
1428                }
1429                drop(client);
1430                let mut reopened = SyncClient::open_path(
1431                    "frame-test".into(),
1432                    &schema,
1433                    Default::default(),
1434                    path.to_str().unwrap(),
1435                )
1436                .unwrap();
1437                assert_eq!(reopened.local_revision(), expected_revision);
1438                assert_eq!(
1439                    reopened
1440                        .query("SELECT id FROM tasks ORDER BY id", &[])
1441                        .unwrap()
1442                        .len(),
1443                    count as usize
1444                );
1445                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1446                let (_, meta) = reopened.build_request(false).unwrap();
1447                assert!(matches!(
1448                    reopened.process_response(&mut transport, valid, &meta),
1449                    SyncOutcome::Ok(_)
1450                ));
1451                assert_eq!(
1452                    reopened
1453                        .query("SELECT id FROM tasks ORDER BY id", &[])
1454                        .unwrap()
1455                        .len(),
1456                    2
1457                );
1458                assert_eq!(reopened.subs[0].cursor, 2);
1459                drop(reopened);
1460                std::fs::remove_file(path).unwrap();
1461            }
1462        }
1463    }
1464
1465    #[test]
1466    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1467        for status in [SubStatus::Reset, SubStatus::Revoked] {
1468            for fault in ["subscription", "revision"] {
1469                let mut client = client();
1470                client
1471                    .subscribe(
1472                        "tasks".into(),
1473                        "tasks".into(),
1474                        vec![("project_id".into(), vec!["p1".into()])],
1475                        None,
1476                    )
1477                    .unwrap();
1478                client.subs[0].cursor = 41;
1479                client.subs[0].synced_once = true;
1480                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1481                client.persist_sub(&client.subs[0]).unwrap();
1482                client
1483                    .mutate(vec![Mutation::Upsert {
1484                        table: "tasks".into(),
1485                        values: Map::from_iter([
1486                            ("id".into(), Value::from("pending")),
1487                            ("project_id".into(), Value::from("p1")),
1488                        ]),
1489                        base_version: None,
1490                    }])
1491                    .unwrap();
1492                let ids = client.pending_commit_ids();
1493                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1494                let revision = client.local_revision();
1495                client.drain_change_batches();
1496                let (_, meta) = client.build_request(false).unwrap();
1497                client.conn.execute_batch(if fault == "subscription" {
1498                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1499                } else {
1500                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1501                }).unwrap();
1502                let mut transport = CountingRealtimeTransport::default();
1503                let mut report = SyncReport::default();
1504                // Reset changes window completeness and therefore needs a revision.
1505                if status == SubStatus::Reset {
1506                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1507                }
1508                let result = client.process_section(
1509                    &mut transport,
1510                    "tasks",
1511                    status,
1512                    "sync.scope_revoked",
1513                    vec![],
1514                    vec![],
1515                    Some((42, None)),
1516                    &meta,
1517                    &mut report,
1518                    &HashSet::new(),
1519                );
1520                assert!(result.is_err(), "{status:?}/{fault}");
1521                assert!(client.conn.is_autocommit());
1522                assert_eq!(client.subs[0].cursor, 41);
1523                assert_eq!(client.subs[0].state, SubState::Active);
1524                assert_eq!(client.pending_commit_ids(), ids);
1525                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1526                assert!(client.rejections.is_empty());
1527                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1528                assert_eq!(client.local_revision(), revision);
1529                assert!(client.drain_change_batches().is_empty());
1530                assert!(report.revoked.is_empty() && report.resets.is_empty());
1531            }
1532        }
1533    }
1534
1535    #[test]
1536    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1537        let mut client = client();
1538        client
1539            .subscribe(
1540                "tasks".into(),
1541                "tasks".into(),
1542                vec![("project_id".into(), vec!["p1".into()])],
1543                None,
1544            )
1545            .unwrap();
1546        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1547        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1548            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1549        let row = |id: &str| ssp2::segment::SegmentRow {
1550            server_version: 1,
1551            values: vec![
1552                Some(ColumnValue::String(id.into())),
1553                Some(ColumnValue::String("p1".into())),
1554            ],
1555        };
1556        let segment = RowsSegment {
1557            table: "tasks".into(),
1558            schema_version: 1,
1559            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1560            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1561        };
1562        assert!(client.apply_segment(0, &segment, true).is_err());
1563        assert!(client.conn.is_autocommit());
1564        assert_eq!(
1565            client
1566                .query("SELECT id FROM tasks ORDER BY id", &[])
1567                .unwrap(),
1568            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1569        );
1570        assert_eq!(client.local_revision(), 1);
1571        assert_eq!(client.drain_change_batches().len(), 1);
1572        assert_eq!(client.subs[0].cursor, -1);
1573        client
1574            .conn
1575            .execute_batch("DROP TRIGGER fail_block")
1576            .unwrap();
1577        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1578        assert_eq!(client.local_revision(), 3);
1579        assert_eq!(client.drain_change_batches().len(), 2);
1580    }
1581
1582    /// Serves one response and records the segment batch it causes. A single
1583    /// `download_segment` call means the core fell back to per-descriptor
1584    /// fetches.
1585    struct SegmentBatchTransport {
1586        response: Message,
1587        bodies: BTreeMap<String, Vec<u8>>,
1588        batches: Vec<Vec<String>>,
1589        single_fetches: usize,
1590    }
1591
1592    impl Transport for SegmentBatchTransport {
1593        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1594            Ok(encode_message(&self.response))
1595        }
1596
1597        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1598            self.sync(request)
1599        }
1600
1601        fn download_segment(
1602            &mut self,
1603            _request: &SegmentRequest,
1604            _on_progress: &mut dyn FnMut(u64),
1605        ) -> Result<Vec<u8>, TransportError> {
1606            self.single_fetches += 1;
1607            Err(TransportError::new(
1608                "sync.transport_failed",
1609                "per-descriptor fetch",
1610            ))
1611        }
1612
1613        fn download_segments(
1614            &mut self,
1615            fetches: &[crate::SegmentFetch],
1616            on_progress: &mut (dyn FnMut(usize, u64) + Send),
1617        ) -> Vec<Result<Vec<u8>, TransportError>> {
1618            self.batches.push(
1619                fetches
1620                    .iter()
1621                    .map(|fetch| match fetch {
1622                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1623                        crate::SegmentFetch::Url(url) => url.clone(),
1624                    })
1625                    .collect(),
1626            );
1627            fetches
1628                .iter()
1629                .enumerate()
1630                .map(|(index, fetch)| {
1631                    let crate::SegmentFetch::Direct(request) = fetch else {
1632                        panic!("the descriptors are direct-endpoint requests")
1633                    };
1634                    let bytes = self.bodies[&request.segment_id].clone();
1635                    on_progress(index, bytes.len() as u64);
1636                    Ok(bytes)
1637                })
1638                .collect()
1639        }
1640
1641        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1642            Ok(())
1643        }
1644
1645        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1646            Ok(())
1647        }
1648
1649        fn realtime_close(&mut self) -> Result<(), TransportError> {
1650            Ok(())
1651        }
1652    }
1653
1654    #[test]
1655    fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1656        let mut client = client();
1657        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1658        client
1659            .subscribe(
1660                "tasks".into(),
1661                "tasks".into(),
1662                vec![("project_id".into(), vec!["p1".into()])],
1663                None,
1664            )
1665            .unwrap();
1666        client.subs[0].cursor = 0;
1667        client.subs[0].synced_once = true;
1668        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1669        client.persist_sub(&client.subs[0]).unwrap();
1670        client.drain_change_batches();
1671        let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1672
1673        // Four pages, one row each. Applying a page under another page's
1674        // descriptor fails §5.1 content-address verification, so a green round
1675        // proves the results kept the wire order.
1676        let pages = ["one", "two", "three", "four"].map(|id| {
1677            let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1678                table: "tasks".into(),
1679                schema_version: 1,
1680                columns: columns.clone(),
1681                blocks: vec![vec![ssp2::segment::SegmentRow {
1682                    server_version: 1,
1683                    values: vec![
1684                        Some(ColumnValue::String(id.into())),
1685                        Some(ColumnValue::String("p1".into())),
1686                    ],
1687                }]],
1688            });
1689            (
1690                format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1691                bytes,
1692            )
1693        });
1694
1695        let mut frames = vec![
1696            Frame::RespHeader {
1697                required_schema_version: None,
1698                latest_schema_version: None,
1699                log_epoch: Some("epoch-1".into()),
1700                reset_required: Some(false),
1701            },
1702            Frame::SubStart {
1703                id: "tasks".into(),
1704                status: SubStatus::Active,
1705                reason_code: String::new(),
1706                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1707                bootstrap: false,
1708            },
1709        ];
1710        for (segment_id, bytes) in &pages {
1711            frames.push(Frame::SegmentRef {
1712                segment_id: segment_id.clone(),
1713                media_type: MediaType::Rows,
1714                table: "tasks".into(),
1715                byte_length: bytes.len() as i64,
1716                row_count: 1,
1717                as_of_commit_seq: 1,
1718                scope_digest: "digest".into(),
1719                row_cursor: None,
1720                next_row_cursor: None,
1721                url: None,
1722                url_expires_at_ms: None,
1723            });
1724        }
1725        frames.push(Frame::SubEnd {
1726            next_cursor: 1,
1727            bootstrap_state: None,
1728        });
1729        let mut transport = SegmentBatchTransport {
1730            response: Message {
1731                wire_version: WIRE_VERSION,
1732                msg_kind: MsgKind::Response,
1733                frames,
1734            },
1735            bodies: pages
1736                .iter()
1737                .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1738                .collect(),
1739            batches: Vec::new(),
1740            single_fetches: 0,
1741        };
1742
1743        let outcome = client.sync(&mut transport);
1744        assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1745        assert_eq!(
1746            transport.batches,
1747            vec![pages
1748                .iter()
1749                .map(|(segment_id, _)| segment_id.clone())
1750                .collect::<Vec<_>>()],
1751            "one batch per response, in wire order"
1752        );
1753        assert_eq!(
1754            transport.single_fetches, 0,
1755            "the batch seam replaces the per-descriptor calls"
1756        );
1757        assert_eq!(
1758            client
1759                .query("SELECT id FROM tasks ORDER BY id", &[])
1760                .unwrap(),
1761            ["four", "one", "three", "two"]
1762                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1763                .to_vec()
1764        );
1765        assert_eq!(client.subs[0].cursor, 1);
1766    }
1767
1768    #[test]
1769    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1770        assert_eq!(
1771            client().diagnostics_storage().blob_cache_bytes_approx,
1772            Some(0)
1773        );
1774        let client = SyncClient::new(
1775            "diagnostic-storage".to_owned(),
1776            &json!({
1777                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1778                    "columns": [{"name": "id", "type": "string", "nullable": false},
1779                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1780            }),
1781            ClientLimits {
1782                blob_cache_max_bytes: Some(5),
1783                ..Default::default()
1784            },
1785        )
1786        .unwrap();
1787        let empty = client.diagnostics_storage();
1788        assert_eq!(empty.status, "healthy");
1789        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1790        assert_eq!(empty.retained_outcome_entries, Some(0));
1791        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1792        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1793        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1794            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1795                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1796            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1797                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1798        let written = client.diagnostics_storage();
1799        assert_eq!(written.status, "pressure");
1800        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1801        assert_eq!(written.retained_outcome_entries, Some(2));
1802        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1803        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1804        assert_eq!(
1805            written.pressure_reason_code.as_deref(),
1806            Some("client.blob_cache_over_limit")
1807        );
1808        client
1809            .conn
1810            .execute_batch(
1811                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1812            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1813            )
1814            .unwrap();
1815        let staged = client.diagnostics_storage();
1816        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1817        assert_eq!(staged.retained_outcome_entries, Some(0));
1818        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1819        assert_eq!(staged.status, "healthy");
1820        client.conn.execute_batch("ROLLBACK").unwrap();
1821        assert_eq!(client.diagnostics_storage(), written);
1822        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1823        let unreadable = client.diagnostics_storage();
1824        assert_eq!(unreadable.status, "unreadable");
1825        assert!(unreadable.database_bytes_approx.is_none());
1826        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1827        assert!(unreadable.retained_outcome_entries.is_none());
1828        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1829        assert!(unreadable.blob_cache_bytes_approx.is_none());
1830        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1831        let restored = client.diagnostics_storage();
1832        assert_eq!(restored.status, written.status);
1833        assert_eq!(
1834            restored.retained_outcome_bytes_approx,
1835            written.retained_outcome_bytes_approx
1836        );
1837        client.conn.set_prepared_statement_cache_capacity(0);
1838        assert_eq!(client.diagnostics_storage(), restored);
1839    }
1840
1841    /// Answers every round with one server `ERROR` frame (§1.6).
1842    struct ServerErrorTransport {
1843        code: &'static str,
1844        retryable: bool,
1845    }
1846
1847    impl Transport for ServerErrorTransport {
1848        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1849            Ok(encode_message(&Message {
1850                wire_version: WIRE_VERSION,
1851                msg_kind: MsgKind::Response,
1852                frames: vec![
1853                    Frame::RespHeader {
1854                        required_schema_version: None,
1855                        latest_schema_version: None,
1856                        log_epoch: Some("epoch-1".to_owned()),
1857                        reset_required: Some(false),
1858                    },
1859                    Frame::Error {
1860                        code: self.code.to_owned(),
1861                        message: "server error".to_owned(),
1862                        category: "internal".to_owned(),
1863                        retryable: self.retryable,
1864                        recommended_action: "retryLater".to_owned(),
1865                        details: None,
1866                    },
1867                ],
1868            }))
1869        }
1870
1871        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1872            self.sync(request)
1873        }
1874
1875        fn download_segment(
1876            &mut self,
1877            _request: &SegmentRequest,
1878            _on_progress: &mut dyn FnMut(u64),
1879        ) -> Result<Vec<u8>, TransportError> {
1880            Err(TransportError::new("sync.transport_failed", "offline"))
1881        }
1882
1883        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1884            Ok(())
1885        }
1886
1887        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1888            Ok(())
1889        }
1890
1891        fn realtime_close(&mut self) -> Result<(), TransportError> {
1892            Ok(())
1893        }
1894    }
1895
1896    #[test]
1897    fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1898        let mut client = client();
1899        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1900        let mut transport = ServerErrorTransport {
1901            code: "sync.schema_not_ready",
1902            retryable: true,
1903        };
1904        client.connect_realtime(&mut transport).expect("connect");
1905        assert!(matches!(
1906            client.sync(&mut transport),
1907            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1908        ));
1909        assert_eq!(
1910            client.realtime_state(),
1911            RealtimeState::Connected,
1912            "the socket delivered the answer, so it is not lost"
1913        );
1914        assert!(
1915            matches!(
1916                client.drain_sync_intents().as_slice(),
1917                [SyncIntent::Background { delay_ms: 250 }]
1918            ),
1919            "the retry deadline is a sync round and no reconnect satisfies it"
1920        );
1921        assert_eq!(
1922            client.progress().snapshot().unwrap().retry_delay_ms,
1923            Some(250)
1924        );
1925    }
1926
1927    #[test]
1928    fn retryable_server_error_schedules_a_background_retry() {
1929        let mut client = client();
1930        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1931        let mut transport = ServerErrorTransport {
1932            code: "sync.internal_error",
1933            retryable: true,
1934        };
1935        assert!(matches!(
1936            client.sync(&mut transport),
1937            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1938        ));
1939        assert!(matches!(
1940            client.drain_sync_intents().as_slice(),
1941            [SyncIntent::Background { delay_ms: 250 }]
1942        ));
1943        assert_eq!(
1944            client.progress().snapshot().unwrap().retry_delay_ms,
1945            Some(250)
1946        );
1947        let mut transport = ServerErrorTransport {
1948            code: "sync.invalid_request",
1949            retryable: false,
1950        };
1951        assert!(matches!(
1952            client.sync(&mut transport),
1953            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1954        ));
1955        assert!(client.drain_sync_intents().is_empty());
1956        let progress = client.progress().snapshot().unwrap();
1957        assert_eq!(progress.state, ProgressState::Failed);
1958        assert_eq!(progress.retry_delay_ms, None);
1959    }
1960
1961    #[test]
1962    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1963        // §7.7: the exhausted outcome is a partial success, not a failure,
1964        // and serializes with an explicit flag.
1965        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1966        assert_eq!(exhausted["ok"], json!(true));
1967        assert_eq!(exhausted["budgetExhausted"], json!(true));
1968        assert!(exhausted["report"].is_object());
1969
1970        let mut client = client();
1971        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1972        // Zero explicit budget is a caller error, not an exhaustion.
1973        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1974        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1975        assert!(
1976            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1977            "{zero:?}"
1978        );
1979        // A real server failure stays a failure across the budget loop.
1980        let mut erroring = ServerErrorTransport {
1981            code: "sync.internal_error",
1982            retryable: false,
1983        };
1984        let failed = client.sync_until_idle(&mut erroring, Some(3));
1985        assert!(
1986            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1987            "{failed:?}"
1988        );
1989    }
1990
1991    /// Returns one pre-built response per round so a test can pin a successful
1992    /// work-bearing round followed by a failure or a schema floor.
1993    struct ScriptedSyncTransport {
1994        responses: std::collections::VecDeque<Message>,
1995        calls: usize,
1996    }
1997
1998    impl Transport for ScriptedSyncTransport {
1999        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2000            self.calls += 1;
2001            let response = self.responses.pop_front().expect("scripted response");
2002            Ok(encode_message(&response))
2003        }
2004
2005        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2006            self.sync(request)
2007        }
2008
2009        fn download_segment(
2010            &mut self,
2011            _request: &SegmentRequest,
2012            _on_progress: &mut dyn FnMut(u64),
2013        ) -> Result<Vec<u8>, TransportError> {
2014            Err(TransportError::new("sync.transport_failed", "offline"))
2015        }
2016
2017        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2018            Ok(())
2019        }
2020
2021        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2022            Ok(())
2023        }
2024
2025        fn realtime_close(&mut self) -> Result<(), TransportError> {
2026            Ok(())
2027        }
2028    }
2029
2030    #[test]
2031    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2032        let path = std::env::temp_dir().join(format!(
2033            "syncular-budget-script-{}.sqlite",
2034            uuid::Uuid::new_v4()
2035        ));
2036        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2037            {"name":"id","type":"string","nullable":false},
2038            {"name":"project_id","type":"string","nullable":false}],
2039            "scopes":[{"pattern":"project:{project_id}"}]}]});
2040        let mut client = SyncClient::open_path(
2041            "budget-script".into(),
2042            &schema,
2043            ClientLimits::default(),
2044            path.to_str().unwrap(),
2045        )
2046        .expect("open client");
2047        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2048        client
2049            .subscribe(
2050                "tasks".into(),
2051                "tasks".into(),
2052                vec![("project_id".into(), vec!["p1".into()])],
2053                None,
2054            )
2055            .unwrap();
2056        client.subs[0].cursor = 0;
2057        client.subs[0].synced_once = true;
2058        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2059        client.persist_sub(&client.subs[0]).unwrap();
2060        client.drain_change_batches();
2061
2062        let row = encode_row_json(
2063            client.schema.table("tasks").unwrap(),
2064            "kept",
2065            &Map::from_iter([
2066                ("id".into(), json!("kept")),
2067                ("project_id".into(), json!("p1")),
2068            ]),
2069            &client.encryption,
2070        )
2071        .unwrap();
2072        let success = Message {
2073            wire_version: WIRE_VERSION,
2074            msg_kind: MsgKind::Response,
2075            frames: vec![
2076                Frame::RespHeader {
2077                    required_schema_version: None,
2078                    latest_schema_version: None,
2079                    log_epoch: Some("epoch-1".into()),
2080                    reset_required: Some(false),
2081                },
2082                Frame::SubStart {
2083                    id: "tasks".into(),
2084                    status: SubStatus::Active,
2085                    reason_code: String::new(),
2086                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2087                    bootstrap: false,
2088                },
2089                Frame::Commit {
2090                    commit_seq: 1,
2091                    created_at_ms: 1,
2092                    actor_id: "writer".into(),
2093                    tables: vec!["tasks".into()],
2094                    changes: vec![ssp2::model::Change {
2095                        table_index: 0,
2096                        row_id: "kept".into(),
2097                        op: Op::Upsert,
2098                        row_version: Some(1),
2099                        scopes: vec![("project_id".into(), "p1".into())],
2100                        row: Some(row),
2101                    }],
2102                },
2103                Frame::SubEnd {
2104                    next_cursor: 1,
2105                    bootstrap_state: None,
2106                },
2107            ],
2108        };
2109        let failure = Message {
2110            wire_version: WIRE_VERSION,
2111            msg_kind: MsgKind::Response,
2112            frames: vec![
2113                Frame::RespHeader {
2114                    required_schema_version: None,
2115                    latest_schema_version: None,
2116                    log_epoch: Some("epoch-1".into()),
2117                    reset_required: Some(false),
2118                },
2119                Frame::Error {
2120                    code: "sync.internal_error".into(),
2121                    message: "second round failed".into(),
2122                    category: "internal".into(),
2123                    retryable: false,
2124                    recommended_action: "retryLater".into(),
2125                    details: None,
2126                },
2127            ],
2128        };
2129        let mut transport = ScriptedSyncTransport {
2130            responses: std::collections::VecDeque::from([success, failure]),
2131            calls: 0,
2132        };
2133        let outcome = client.sync_until_idle(&mut transport, Some(2));
2134        let SyncOutcome::Failed {
2135            error_code,
2136            recommended_action,
2137            ..
2138        } = outcome
2139        else {
2140            panic!("expected the in-band failure");
2141        };
2142        assert_eq!(error_code, "sync.internal_error");
2143        assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2144        assert_eq!(
2145            transport.calls, 2,
2146            "one successful round then the failing round"
2147        );
2148        assert_eq!(
2149            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2150            json!("kept"),
2151            "the earlier round's durable work survives the later failure"
2152        );
2153        drop(client);
2154        let _ = std::fs::remove_file(&path);
2155    }
2156
2157    #[test]
2158    fn sync_until_idle_stops_on_a_schema_floor() {
2159        let mut client = client();
2160        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2161        let floor = Message {
2162            wire_version: WIRE_VERSION,
2163            msg_kind: MsgKind::Response,
2164            frames: vec![Frame::RespHeader {
2165                required_schema_version: Some(2),
2166                latest_schema_version: Some(2),
2167                log_epoch: Some("epoch-1".into()),
2168                reset_required: Some(false),
2169            }],
2170        };
2171        let mut transport = ScriptedSyncTransport {
2172            responses: std::collections::VecDeque::from([floor.clone(), floor]),
2173            calls: 0,
2174        };
2175        let outcome = client.sync_until_idle(&mut transport, Some(3));
2176        match outcome {
2177            SyncOutcome::Ok(report) => assert_eq!(
2178                report
2179                    .schema_floor
2180                    .as_ref()
2181                    .and_then(|floor| floor.required_schema_version),
2182                Some(2),
2183                "the stop retains the latest schema floor"
2184            ),
2185            other => panic!("expected a schema-floor stop, got {other:?}"),
2186        }
2187        assert_eq!(
2188            transport.calls, 1,
2189            "a schema floor stops the budget loop after one round"
2190        );
2191    }
2192
2193    /// Records the decoded request of every round and answers from a script of
2194    /// round results, so a test can pin what the following request carries.
2195    struct FailureScriptTransport {
2196        rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2197        requests: Vec<Message>,
2198    }
2199
2200    impl Transport for FailureScriptTransport {
2201        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2202            self.requests
2203                .push(decode_message(request).expect("the client encodes a legal request"));
2204            self.rounds.pop_front().expect("scripted round")
2205        }
2206
2207        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2208            self.sync(request)
2209        }
2210
2211        fn download_segment(
2212            &mut self,
2213            _request: &SegmentRequest,
2214            _on_progress: &mut dyn FnMut(u64),
2215        ) -> Result<Vec<u8>, TransportError> {
2216            Err(TransportError::new("transport.timeout", "segment stalled"))
2217        }
2218
2219        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2220            Ok(())
2221        }
2222
2223        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2224            Ok(())
2225        }
2226
2227        fn realtime_close(&mut self) -> Result<(), TransportError> {
2228            Ok(())
2229        }
2230    }
2231
2232    /// A complete response: RESP_HEADER for the client's epoch plus `frames`.
2233    fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2234        let mut all = vec![Frame::RespHeader {
2235            required_schema_version: None,
2236            latest_schema_version: None,
2237            log_epoch: Some("epoch-1".into()),
2238            reset_required: Some(false),
2239        }];
2240        all.extend(frames);
2241        encode_message(&Message {
2242            wire_version: WIRE_VERSION,
2243            msg_kind: MsgKind::Response,
2244            frames: all,
2245        })
2246    }
2247
2248    fn carried_round_failures(request: &Message) -> Vec<String> {
2249        request
2250            .frames
2251            .iter()
2252            .filter_map(|frame| match frame {
2253                Frame::RoundFailure { code } => Some(code.clone()),
2254                _ => None,
2255            })
2256            .collect()
2257    }
2258
2259    fn failure_transport(
2260        rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2261    ) -> FailureScriptTransport {
2262        FailureScriptTransport {
2263            rounds: rounds.into_iter().collect(),
2264            requests: Vec::new(),
2265        }
2266    }
2267
2268    #[test]
2269    fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2270        let mut client = client();
2271        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2272        let mut transport = failure_transport([
2273            Err(TransportError::new("transport.timeout", "round timed out")),
2274            Ok(completed_response(Vec::new())),
2275            Ok(completed_response(Vec::new())),
2276        ]);
2277        for _ in 0..3 {
2278            client.sync(&mut transport);
2279        }
2280        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2281        assert_eq!(
2282            carried_round_failures(&transport.requests[1]),
2283            vec!["transport.timeout"]
2284        );
2285        assert!(
2286            matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2287                && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2288            "the report sits immediately after REQ_HEADER"
2289        );
2290        assert!(
2291            carried_round_failures(&transport.requests[2]).is_empty(),
2292            "a complete response ends the report"
2293        );
2294    }
2295
2296    #[test]
2297    fn a_newer_round_failure_replaces_the_kept_code() {
2298        let mut client = client();
2299        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2300        let mut transport = failure_transport([
2301            Err(TransportError::new("transport.timeout", "round timed out")),
2302            Err(TransportError::new("transport.failed", "socket closed")),
2303            Ok(completed_response(Vec::new())),
2304            Ok(completed_response(Vec::new())),
2305        ]);
2306        for _ in 0..4 {
2307            client.sync(&mut transport);
2308        }
2309        assert_eq!(
2310            carried_round_failures(&transport.requests[1]),
2311            vec!["transport.timeout"]
2312        );
2313        assert_eq!(
2314            carried_round_failures(&transport.requests[2]),
2315            vec!["transport.failed"]
2316        );
2317        assert!(carried_round_failures(&transport.requests[3]).is_empty());
2318    }
2319
2320    #[test]
2321    fn an_in_band_error_completes_the_round_and_is_never_reported() {
2322        let mut client = client();
2323        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2324        let mut transport = failure_transport([
2325            Err(TransportError::new("transport.timeout", "round timed out")),
2326            Ok(completed_response(vec![Frame::Error {
2327                code: "sync.internal_error".into(),
2328                message: "server error".into(),
2329                category: "internal".into(),
2330                retryable: false,
2331                recommended_action: "retryLater".into(),
2332                details: None,
2333            }])),
2334            Ok(completed_response(Vec::new())),
2335        ]);
2336        client.sync(&mut transport);
2337        assert!(matches!(
2338            client.sync(&mut transport),
2339            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2340        ));
2341        client.sync(&mut transport);
2342        assert_eq!(
2343            carried_round_failures(&transport.requests[1]),
2344            vec!["transport.timeout"]
2345        );
2346        assert!(
2347            carried_round_failures(&transport.requests[2]).is_empty(),
2348            "the server already recorded an in-band ERROR"
2349        );
2350    }
2351
2352    #[test]
2353    fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2354        let mut client = client();
2355        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2356        client
2357            .subscribe(
2358                "tasks".into(),
2359                "tasks".into(),
2360                vec![("project_id".into(), vec!["p1".into()])],
2361                None,
2362            )
2363            .unwrap();
2364        client.subs[0].cursor = 0;
2365        client.subs[0].synced_once = true;
2366        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2367        client.persist_sub(&client.subs[0]).unwrap();
2368        client.drain_change_batches();
2369        client.drain_sync_intents();
2370        let mut transport = failure_transport([
2371            Ok(completed_response(vec![
2372                Frame::SubStart {
2373                    id: "tasks".into(),
2374                    status: SubStatus::Active,
2375                    reason_code: String::new(),
2376                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2377                    bootstrap: false,
2378                },
2379                Frame::SegmentRef {
2380                    segment_id: "sha256:stalled".into(),
2381                    media_type: MediaType::Rows,
2382                    table: "tasks".into(),
2383                    byte_length: 1,
2384                    row_count: 1,
2385                    as_of_commit_seq: 1,
2386                    scope_digest: "digest".into(),
2387                    row_cursor: None,
2388                    next_row_cursor: None,
2389                    url: None,
2390                    url_expires_at_ms: None,
2391                },
2392                Frame::SubEnd {
2393                    next_cursor: 1,
2394                    bootstrap_state: None,
2395                },
2396            ])),
2397            Ok(completed_response(Vec::new())),
2398            Ok(completed_response(Vec::new())),
2399        ]);
2400
2401        assert!(matches!(
2402            client.sync(&mut transport),
2403            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2404        ));
2405        assert!(
2406            matches!(
2407                client.drain_sync_intents().as_slice(),
2408                [SyncIntent::Background { delay_ms: 250 }]
2409            ),
2410            "a silent transfer asks the host for a Background retry"
2411        );
2412        client.sync(&mut transport);
2413        client.sync(&mut transport);
2414        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2415        assert_eq!(
2416            carried_round_failures(&transport.requests[1]),
2417            vec!["transport.timeout"],
2418            "the round's own response was complete, so the fetch failure rides the next request"
2419        );
2420        assert!(
2421            carried_round_failures(&transport.requests[2]).is_empty(),
2422            "a complete response ends the report"
2423        );
2424    }
2425
2426    #[test]
2427    fn a_code_the_frame_grammar_rejects_is_not_attached() {
2428        let mut client = client();
2429        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2430        let mut transport = failure_transport([
2431            Err(TransportError::new("offline", "no dotted code")),
2432            Ok(completed_response(Vec::new())),
2433        ]);
2434        client.sync(&mut transport);
2435        client.sync(&mut transport);
2436        assert!(carried_round_failures(&transport.requests[1]).is_empty());
2437    }
2438
2439    #[test]
2440    fn sync_report_merge_schema_floor_is_latest_state() {
2441        let mut aggregate = SyncReport {
2442            schema_floor: Some(SchemaFloor {
2443                required_schema_version: Some(2),
2444                latest_schema_version: Some(2),
2445            }),
2446            ..SyncReport::default()
2447        };
2448        aggregate.merge(&SyncReport::default());
2449        assert!(
2450            aggregate.schema_floor.is_none(),
2451            "a later round without a floor clears it"
2452        );
2453    }
2454
2455    #[test]
2456    fn push_capacity_limits_are_hard_and_keep_intent() {
2457        // The configured operation cap is hard for the first commit.
2458        let mut c = client();
2459        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2460        c.limits.max_push_operations_per_request = Some(1);
2461        let id = c
2462            .mutate(vec![
2463                Mutation::Upsert {
2464                    table: "tasks".into(),
2465                    values: Map::from_iter([
2466                        ("id".into(), json!("a")),
2467                        ("project_id".into(), json!("p1")),
2468                    ]),
2469                    base_version: None,
2470                },
2471                Mutation::Upsert {
2472                    table: "tasks".into(),
2473                    values: Map::from_iter([
2474                        ("id".into(), json!("b")),
2475                        ("project_id".into(), json!("p1")),
2476                    ]),
2477                    base_version: None,
2478                },
2479            ])
2480            .unwrap();
2481        let error = c.build_request(false).unwrap_err();
2482        let SyncOutcome::Failed {
2483            error_code,
2484            details,
2485            ..
2486        } = *error
2487        else {
2488            panic!("expected a capacity failure");
2489        };
2490        assert_eq!(error_code, "client.push_request_too_large");
2491        let details = details.expect("capacity details");
2492        assert_eq!(details["kind"], json!("operations"));
2493        assert_eq!(details["limit"], json!(1));
2494        assert_eq!(details["size"], json!(2));
2495        assert_eq!(details["clientCommitId"], json!(id));
2496        assert_eq!(c.pending_commit_ids(), vec![id]);
2497
2498        // The commit-count cap defers whole later commits.
2499        let mut c = client();
2500        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2501        c.limits.max_push_commits_per_request = Some(1);
2502        let first = c
2503            .mutate(vec![Mutation::Upsert {
2504                table: "tasks".into(),
2505                values: Map::from_iter([
2506                    ("id".into(), json!("one")),
2507                    ("project_id".into(), json!("p1")),
2508                ]),
2509                base_version: None,
2510            }])
2511            .unwrap();
2512        let second = c
2513            .mutate(vec![Mutation::Upsert {
2514                table: "tasks".into(),
2515                values: Map::from_iter([
2516                    ("id".into(), json!("two")),
2517                    ("project_id".into(), json!("p1")),
2518                ]),
2519                base_version: None,
2520            }])
2521            .unwrap();
2522        let (_, meta) = c.build_request(false).unwrap();
2523        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2524        assert_eq!(meta.deferred_commits, 1);
2525        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2526    }
2527
2528    #[test]
2529    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2530        // Two small commits: a cap just below the full request defers the
2531        // last commit and sends the fitting prefix (header + first commit).
2532        let mut c = client();
2533        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2534        let first = c
2535            .mutate(vec![Mutation::Upsert {
2536                table: "tasks".into(),
2537                values: Map::from_iter([
2538                    ("id".into(), json!("one")),
2539                    ("project_id".into(), json!("p1")),
2540                ]),
2541                base_version: None,
2542            }])
2543            .unwrap();
2544        let second = c
2545            .mutate(vec![Mutation::Upsert {
2546                table: "tasks".into(),
2547                values: Map::from_iter([
2548                    ("id".into(), json!("two")),
2549                    ("project_id".into(), json!("p1")),
2550                ]),
2551                base_version: None,
2552            }])
2553            .unwrap();
2554        let (message, meta) = c.build_request(false).unwrap();
2555        assert_eq!(meta.pushed_ids.len(), 2);
2556        let full = encode_message(&message).len();
2557        c.limits.max_push_request_bytes = Some(full - 1);
2558        let (_, meta) = c.build_request(false).unwrap();
2559        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2560        assert_eq!(meta.deferred_commits, 1);
2561        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2562
2563        // A single commit larger than the budget is a typed capacity error and
2564        // stays queued, reporting the full projected request size.
2565        let mut c = client();
2566        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2567        let huge = c
2568            .mutate(vec![Mutation::Upsert {
2569                table: "tasks".into(),
2570                values: Map::from_iter([
2571                    ("id".into(), json!("x".repeat(20_000))),
2572                    ("project_id".into(), json!("p1")),
2573                ]),
2574                base_version: None,
2575            }])
2576            .unwrap();
2577        let (message, meta) = c.build_request(false).unwrap();
2578        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2579        let with_huge = encode_message(&message).len();
2580        c.limits.max_push_request_bytes = Some(with_huge - 1);
2581        let error = c.build_request(false).unwrap_err();
2582        let SyncOutcome::Failed {
2583            error_code,
2584            details,
2585            ..
2586        } = *error
2587        else {
2588            panic!("expected a capacity failure");
2589        };
2590        assert_eq!(error_code, "client.push_request_too_large");
2591        let details = details.expect("capacity details");
2592        assert_eq!(details["kind"], json!("bytes"));
2593        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2594        assert_eq!(details["size"], json!(with_huge as u64));
2595        assert_eq!(details["clientCommitId"], json!(huge));
2596        assert_eq!(c.pending_commit_ids(), vec![huge]);
2597
2598        // A cap smaller than the fixed frames reports no blocked commit.
2599        let mut c = client();
2600        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2601        c.limits.max_push_request_bytes = Some(1);
2602        let error = c.build_request(false).unwrap_err();
2603        let SyncOutcome::Failed { details, .. } = *error else {
2604            panic!("expected a capacity failure");
2605        };
2606        let details = details.expect("capacity details");
2607        assert_eq!(details["kind"], json!("bytes"));
2608        assert!(details.get("clientCommitId").is_none(), "{details}");
2609    }
2610
2611    #[test]
2612    fn push_limits_validate_at_construction() {
2613        for limits in [
2614            ClientLimits {
2615                max_push_commits_per_request: Some(0),
2616                ..Default::default()
2617            },
2618            ClientLimits {
2619                max_push_operations_per_request: Some(0),
2620                ..Default::default()
2621            },
2622            ClientLimits {
2623                max_push_request_bytes: Some(0),
2624                ..Default::default()
2625            },
2626            ClientLimits {
2627                max_push_request_bytes: Some(u32::MAX as usize + 1),
2628                ..Default::default()
2629            },
2630        ] {
2631            let schema = json!({"version":1,"tables":[]});
2632            let error = SyncClient::with_connection(
2633                "bad-limits".into(),
2634                &schema,
2635                limits,
2636                Connection::open_in_memory().unwrap(),
2637            )
2638            .err();
2639            assert_eq!(
2640                error.as_deref(),
2641                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2642            );
2643        }
2644    }
2645
2646    #[test]
2647    fn open_path_rejects_invalid_push_limits_before_storage() {
2648        for identity in [false, true] {
2649            let path = std::env::temp_dir().join(format!(
2650                "syncular-bad-limits-{}.sqlite",
2651                uuid::Uuid::new_v4()
2652            ));
2653            let schema = json!({"version":1,"tables":[]});
2654            let limits = ClientLimits {
2655                max_push_request_bytes: Some(0),
2656                ..Default::default()
2657            };
2658            let error = if identity {
2659                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2660            } else {
2661                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2662            }
2663            .err();
2664            assert_eq!(
2665                error.as_deref(),
2666                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2667            );
2668            assert!(
2669                !path.exists(),
2670                "an invalid limit is rejected before the database file is created"
2671            );
2672        }
2673    }
2674
2675    #[test]
2676    fn failed_progress_retry_delay_doubles_to_the_cap() {
2677        let mut client = client();
2678        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2679        let mut transport = ServerErrorTransport {
2680            code: "sync.internal_error",
2681            retryable: true,
2682        };
2683        let delays: Vec<Option<u64>> = (0..9)
2684            .map(|_| {
2685                client.sync(&mut transport);
2686                client.progress().snapshot().unwrap().retry_delay_ms
2687            })
2688            .collect();
2689        assert_eq!(
2690            delays,
2691            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2692        );
2693    }
2694
2695    #[test]
2696    fn background_retry_deadlines_back_off_and_reset() {
2697        let mut client = client();
2698        client.schedule_background_retry();
2699        assert!(matches!(
2700            client.drain_sync_intents().as_slice(),
2701            [SyncIntent::Background { delay_ms: 250 }]
2702        ));
2703        client.schedule_background_retry();
2704        assert!(matches!(
2705            client.drain_sync_intents().as_slice(),
2706            [SyncIntent::Background { delay_ms: 500 }]
2707        ));
2708        client.reset_background_retry();
2709        client.schedule_background_retry();
2710        assert!(matches!(
2711            client.drain_sync_intents().as_slice(),
2712            [SyncIntent::Background { delay_ms: 250 }]
2713        ));
2714    }
2715
2716    #[test]
2717    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2718        let mut client = client();
2719        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2720        client
2721            .subscribe(
2722                "epoch-tasks".to_owned(),
2723                "tasks".to_owned(),
2724                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2725                None,
2726            )
2727            .expect("subscribe");
2728        client.drain_sync_intents();
2729
2730        client
2731            .run_log_epoch_reset("epoch-2")
2732            .expect("reset partition log epoch");
2733
2734        assert!(matches!(
2735            client.drain_sync_intents().as_slice(),
2736            [SyncIntent::Interactive]
2737        ));
2738    }
2739
2740    #[derive(Default)]
2741    struct CountingRealtimeTransport {
2742        connects: usize,
2743        closes: usize,
2744        messages: Vec<String>,
2745        blob_uploads: Vec<(String, Vec<u8>)>,
2746        blob_download: Option<Vec<u8>>,
2747        sync_calls: usize,
2748        realtime_sync_calls: usize,
2749        refuse_connect: bool,
2750    }
2751
2752    impl Transport for CountingRealtimeTransport {
2753        fn blob_upload(
2754            &mut self,
2755            blob_id: &str,
2756            bytes: &[u8],
2757            _media_type: Option<&str>,
2758        ) -> Result<(), TransportError> {
2759            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2760            Ok(())
2761        }
2762
2763        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2764            self.blob_download
2765                .clone()
2766                .map(BlobDownload::Bytes)
2767                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2768        }
2769
2770        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2771            self.sync_calls += 1;
2772            Err(TransportError::new("sync.transport_failed", "offline"))
2773        }
2774
2775        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2776            self.realtime_sync_calls += 1;
2777            Err(TransportError::new("sync.transport_failed", "offline"))
2778        }
2779
2780        fn download_segment(
2781            &mut self,
2782            _request: &SegmentRequest,
2783            _on_progress: &mut dyn FnMut(u64),
2784        ) -> Result<Vec<u8>, TransportError> {
2785            Err(TransportError::new("sync.transport_failed", "offline"))
2786        }
2787
2788        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2789            self.connects += 1;
2790            if self.refuse_connect {
2791                return Err(TransportError::new("transport.failed", "refused"));
2792            }
2793            Ok(())
2794        }
2795
2796        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2797            self.messages.push(text.to_owned());
2798            Ok(())
2799        }
2800
2801        fn realtime_close(&mut self) -> Result<(), TransportError> {
2802            self.closes += 1;
2803            Ok(())
2804        }
2805    }
2806
2807    #[test]
2808    fn blob_results_own_downloaded_bytes() {
2809        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2810            {"name":"id","type":"string","nullable":false},
2811            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2812        let bytes = b"owned typed result".to_vec();
2813        let blob_id = blob_id_for(&bytes);
2814        let mut transport = CountingRealtimeTransport {
2815            blob_download: Some(bytes.clone()),
2816            ..Default::default()
2817        };
2818        let result = {
2819            let mut client =
2820                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2821                    .unwrap();
2822            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2823            assert_eq!(result.blob_id, blob_id);
2824            assert_eq!(result.byte_length, bytes.len() as i64);
2825            assert_eq!(result.bytes, bytes);
2826            assert_eq!(result.media_type, None);
2827            assert_eq!(
2828                client
2829                    .conn
2830                    .query_row(
2831                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2832                        rusqlite::params![blob_id],
2833                        |row| row.get::<_, i64>(0),
2834                    )
2835                    .unwrap(),
2836                0
2837            );
2838            result
2839        };
2840        assert_eq!(result.bytes, bytes);
2841
2842        let mut client =
2843            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2844        assert_eq!(
2845            client
2846                .fetch_blob_bytes(&mut transport, "not json")
2847                .unwrap_err(),
2848            (
2849                "client.failed".to_owned(),
2850                "blob ref is not JSON".to_owned()
2851            )
2852        );
2853    }
2854
2855    #[test]
2856    fn visible_blob_references_survive_cache_trimming() {
2857        let schema = json!({"version":1,"tables":[
2858            {"name":"attachments","primaryKey":"id","columns":[
2859                {"name":"id","type":"string","nullable":false},
2860                {"name":"body","type":"blob_ref","nullable":true},
2861                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2862            {"name":"avatars","primaryKey":"id","columns":[
2863                {"name":"id","type":"string","nullable":false},
2864                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2865        ]});
2866        let bytes = b"referenced above cap".to_vec();
2867        let blob_id = blob_id_for(&bytes);
2868        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2869        let mut client = SyncClient::new(
2870            "visible-blob-refs".to_owned(),
2871            &schema,
2872            ClientLimits {
2873                blob_cache_max_bytes: Some(1),
2874                ..Default::default()
2875            },
2876        )
2877        .unwrap();
2878        client.conn.execute(
2879            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2880            rusqlite::params![reference, reference, reference, reference],
2881        ).unwrap();
2882        client
2883            .conn
2884            .execute(
2885                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2886                rusqlite::params![reference],
2887            )
2888            .unwrap();
2889        client.conn.execute(
2890            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2891            [],
2892        ).unwrap();
2893        let mut transport = CountingRealtimeTransport {
2894            blob_download: Some(bytes.clone()),
2895            ..Default::default()
2896        };
2897
2898        assert_eq!(
2899            client
2900                .fetch_blob_bytes(&mut transport, &blob_id)
2901                .unwrap()
2902                .bytes,
2903            bytes
2904        );
2905        let rows = client
2906            .conn
2907            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2908            .unwrap()
2909            .query_map([], |row| row.get::<_, String>(0))
2910            .unwrap()
2911            .collect::<Result<Vec<_>, _>>()
2912            .unwrap();
2913        assert_eq!(rows, vec![blob_id]);
2914    }
2915
2916    #[test]
2917    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2918        let mut transport = CountingRealtimeTransport::default();
2919        let path = std::env::temp_dir().join(format!(
2920            "syncular-transport-gate-{}.db",
2921            uuid::Uuid::new_v4()
2922        ));
2923        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2924            {"name":"id","type":"string","nullable":false},
2925            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2926        let mut client = SyncClient::open_path(
2927            "gate".into(),
2928            &schema,
2929            ClientLimits::default(),
2930            path.to_str().unwrap(),
2931        )
2932        .unwrap();
2933        client.connect_realtime(&mut transport).unwrap();
2934        client.set_transport_enabled(&mut transport, false);
2935        assert_eq!(transport.closes, 1);
2936        client.begin_security_preflight();
2937        client.activate_security(Default::default()).unwrap();
2938        client
2939            .subscribe(
2940                "tasks".into(),
2941                "tasks".into(),
2942                vec![("project_id".into(), vec!["p1".into()])],
2943                None,
2944            )
2945            .unwrap();
2946        for id in ["one", "two"] {
2947            client
2948                .mutate(vec![Mutation::Upsert {
2949                    table: "tasks".into(),
2950                    values: Map::from_iter([
2951                        ("id".into(), json!(id)),
2952                        ("project_id".into(), json!("p1")),
2953                    ]),
2954                    base_version: None,
2955                }])
2956                .unwrap();
2957        }
2958        assert_eq!(
2959            client
2960                .query("SELECT id FROM tasks ORDER BY id", &[])
2961                .unwrap()
2962                .len(),
2963            2
2964        );
2965        assert!(
2966            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2967        );
2968        assert!(client
2969            .connect_realtime(&mut transport)
2970            .unwrap_err()
2971            .starts_with("sync.offline:"));
2972        assert!(client
2973            .set_presence(&mut transport, "project:p1", None)
2974            .unwrap_err()
2975            .starts_with("sync.offline:"));
2976        assert_eq!(transport.sync_calls, 0);
2977        assert_eq!(transport.realtime_sync_calls, 0);
2978        assert_eq!(transport.connects, 1);
2979        assert!(transport.messages.is_empty());
2980        assert!(matches!(
2981            client.drain_sync_intents().as_slice(),
2982            [SyncIntent::None]
2983        ));
2984        let pending = client.pending_commit_ids();
2985        drop(client);
2986        let mut reopened = SyncClient::open_path(
2987            "gate".into(),
2988            &schema,
2989            ClientLimits::default(),
2990            path.to_str().unwrap(),
2991        )
2992        .unwrap();
2993        assert!(reopened.transport_enabled());
2994        reopened.set_transport_enabled(&mut transport, false);
2995        assert_eq!(reopened.pending_commit_ids(), pending);
2996        assert_eq!(
2997            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2998            2
2999        );
3000        reopened.set_transport_enabled(&mut transport, true);
3001        assert!(matches!(
3002            reopened.drain_sync_intents().as_slice(),
3003            [SyncIntent::Interactive]
3004        ));
3005        reopened.set_transport_enabled(&mut transport, true);
3006        assert!(reopened.drain_sync_intents().is_empty());
3007        drop(reopened);
3008        std::fs::remove_file(path).unwrap();
3009    }
3010
3011    #[test]
3012    fn realtime_connection_ownership_is_idempotent() {
3013        let mut client = client();
3014        let mut transport = CountingRealtimeTransport {
3015            connects: 0,
3016            closes: 0,
3017            ..Default::default()
3018        };
3019        client
3020            .connect_realtime(&mut transport)
3021            .expect("first connect");
3022        client
3023            .connect_realtime(&mut transport)
3024            .expect("idempotent connect");
3025        assert_eq!(transport.connects, 1);
3026        client.disconnect_realtime(&mut transport);
3027        client.disconnect_realtime(&mut transport);
3028        assert_eq!(transport.closes, 1);
3029        client
3030            .connect_realtime(&mut transport)
3031            .expect("deliberate reconnect");
3032        assert_eq!(transport.connects, 2);
3033    }
3034
3035    #[test]
3036    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3037        let mut client = client();
3038        client
3039            .set_realtime_policy(RealtimePolicy::Required)
3040            .expect("set policy");
3041        let mut transport = CountingRealtimeTransport::default();
3042        match client.sync(&mut transport) {
3043            SyncOutcome::RealtimeUnavailable {
3044                state,
3045                reason_code,
3046                retry_delay_ms,
3047            } => {
3048                assert_eq!(state, RealtimeState::Disconnected);
3049                assert_eq!(reason_code, None);
3050                assert_eq!(retry_delay_ms, 250);
3051            }
3052            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3053        }
3054        assert_eq!(transport.sync_calls, 0);
3055        assert!(matches!(
3056            client.drain_sync_intents().as_slice(),
3057            [SyncIntent::Background { delay_ms: 250 }]
3058        ));
3059        let snapshot = client
3060            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3061            .expect("diagnostics");
3062        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3063        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3064        assert_eq!(snapshot.host.realtime_reason_code, None);
3065        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3066        assert_eq!(
3067            snapshot
3068                .last_round
3069                .as_ref()
3070                .and_then(|round| round.error_code.clone()),
3071            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3072        );
3073    }
3074
3075    #[test]
3076    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3077        let mut client = client();
3078        client
3079            .set_realtime_policy(RealtimePolicy::Required)
3080            .expect("set policy");
3081        let mut transport = CountingRealtimeTransport {
3082            refuse_connect: true,
3083            ..Default::default()
3084        };
3085        assert!(client.connect_realtime(&mut transport).is_err());
3086        assert_eq!(client.realtime_state(), RealtimeState::Refused);
3087        let snapshot = client
3088            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3089            .expect("diagnostics");
3090        assert_eq!(
3091            snapshot.host.realtime_reason_code.as_deref(),
3092            Some("transport.failed")
3093        );
3094        match client.sync(&mut transport) {
3095            SyncOutcome::RealtimeUnavailable {
3096                state, reason_code, ..
3097            } => {
3098                assert_eq!(state, RealtimeState::Refused);
3099                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3100            }
3101            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3102        }
3103        assert_eq!(transport.sync_calls, 0);
3104        assert_eq!(transport.realtime_sync_calls, 0);
3105    }
3106
3107    #[test]
3108    fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3109        let mut client = client();
3110        let mut transport = CountingRealtimeTransport::default();
3111        let ended = TransportError::new("transport.failed", "realtime connection closed");
3112        client.realtime_lost(&mut transport, &ended);
3113        assert_eq!(
3114            client.realtime_state(),
3115            RealtimeState::Disconnected,
3116            "a socket that was never connected has nothing to lose"
3117        );
3118        assert!(client.drain_sync_intents().is_empty());
3119
3120        client.connect_realtime(&mut transport).expect("connect");
3121        client.realtime_lost(&mut transport, &ended);
3122        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3123        assert_eq!(transport.closes, 1);
3124        assert!(matches!(
3125            client.drain_sync_intents().as_slice(),
3126            [SyncIntent::Background { delay_ms: 250 }]
3127        ));
3128        let snapshot = client
3129            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3130            .expect("diagnostics");
3131        assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3132        assert_eq!(
3133            snapshot.host.realtime_reason_code.as_deref(),
3134            Some("transport.failed")
3135        );
3136        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3137
3138        client.realtime_lost(&mut transport, &ended);
3139        assert!(
3140            client.drain_sync_intents().is_empty(),
3141            "a second report for the same loss schedules nothing"
3142        );
3143        client.connect_realtime(&mut transport).expect("reconnect");
3144        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3145    }
3146
3147    #[test]
3148    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3149        let mut client = client();
3150        client
3151            .set_realtime_policy(RealtimePolicy::Required)
3152            .expect("set policy");
3153        let mut transport = CountingRealtimeTransport::default();
3154        client.connect_realtime(&mut transport).expect("connect");
3155        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3156        assert!(
3157            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3158            "a policy change cannot silently re-route a connected socket"
3159        );
3160        assert!(matches!(
3161            client.sync(&mut transport),
3162            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3163        ));
3164        assert_eq!(client.realtime_state(), RealtimeState::Lost);
3165        assert_eq!(transport.realtime_sync_calls, 1);
3166        assert_eq!(transport.sync_calls, 0);
3167        let snapshot = client
3168            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3169            .expect("diagnostics");
3170        assert_eq!(
3171            snapshot.host.realtime_reason_code.as_deref(),
3172            Some("sync.transport_failed")
3173        );
3174        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3175        match client.sync(&mut transport) {
3176            SyncOutcome::RealtimeUnavailable {
3177                state, reason_code, ..
3178            } => {
3179                assert_eq!(state, RealtimeState::Lost);
3180                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3181            }
3182            other => panic!("expected RealtimeUnavailable, got {other:?}"),
3183        }
3184        assert_eq!(transport.sync_calls, 0);
3185        client.disconnect_realtime(&mut transport);
3186        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3187        assert_eq!(transport.closes, 1);
3188        client.connect_realtime(&mut transport).expect("reconnect");
3189        assert_eq!(client.realtime_state(), RealtimeState::Connected);
3190        assert_eq!(transport.connects, 2);
3191    }
3192
3193    #[test]
3194    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3195        let mut client = client();
3196        client
3197            .set_realtime_policy(RealtimePolicy::Off)
3198            .expect("set policy");
3199        let mut transport = CountingRealtimeTransport::default();
3200        assert!(client.connect_realtime(&mut transport).is_err());
3201        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3202        assert!(matches!(
3203            client.sync(&mut transport),
3204            SyncOutcome::Failed { .. }
3205        ));
3206        assert_eq!(transport.sync_calls, 1);
3207        assert_eq!(transport.realtime_sync_calls, 0);
3208        let snapshot = client
3209            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3210            .expect("diagnostics");
3211        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3212        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3213    }
3214
3215    #[test]
3216    fn optional_policy_keeps_the_http_round() {
3217        let mut client = client();
3218        let mut transport = CountingRealtimeTransport::default();
3219        assert!(matches!(
3220            client.sync(&mut transport),
3221            SyncOutcome::Failed { .. }
3222        ));
3223        assert_eq!(transport.sync_calls, 1);
3224        assert_eq!(transport.realtime_sync_calls, 0);
3225        let snapshot = client
3226            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3227            .expect("diagnostics");
3228        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3229        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3230    }
3231
3232    #[test]
3233    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3234        let mut client = client();
3235        client
3236            .subscribe(
3237                "repair-tasks".to_owned(),
3238                "tasks".to_owned(),
3239                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3240                None,
3241            )
3242            .expect("subscribe");
3243        {
3244            let sub = client
3245                .subs
3246                .iter_mut()
3247                .find(|sub| sub.id == "repair-tasks")
3248                .expect("subscription");
3249            sub.cursor = 42;
3250            sub.synced_once = true;
3251            let persisted = sub.clone();
3252            client.persist_sub(&persisted).unwrap();
3253        }
3254        for table in ["_syncular_base_tasks", "tasks"] {
3255            client
3256                .conn
3257                .execute(
3258                    &format!(
3259                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3260                    ),
3261                    rusqlite::params!["server-row", "p1"],
3262                )
3263                .expect("seed server row");
3264        }
3265        let pending = client
3266            .mutate(vec![Mutation::Upsert {
3267                table: "tasks".to_owned(),
3268                values: Map::from_iter([
3269                    ("id".to_owned(), Value::from("offline-row")),
3270                    ("project_id".to_owned(), Value::from("p1")),
3271                ]),
3272                base_version: None,
3273            }])
3274            .expect("queue offline work");
3275        client.drain_change_batches();
3276        client.drain_sync_intents();
3277
3278        assert_eq!(
3279            client
3280                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3281                    rebootstrap_id: "support-case-001".to_owned(),
3282                })
3283                .expect("rebootstrap"),
3284            LocalDataRebootstrapResult {
3285                already_applied: false,
3286                retained_commits: 1,
3287                reset_subscriptions: 1,
3288            }
3289        );
3290        let visible_ids = client
3291            .conn
3292            .prepare("SELECT id FROM tasks ORDER BY id")
3293            .expect("prepare visible ids")
3294            .query_map([], |row| row.get::<_, String>(0))
3295            .expect("query visible ids")
3296            .collect::<Result<Vec<_>, _>>()
3297            .expect("collect visible ids");
3298        assert_eq!(visible_ids, vec!["offline-row"]);
3299        assert_eq!(client.pending_commit_ids(), vec![pending]);
3300        assert_eq!(
3301            client
3302                .subscription_state("repair-tasks")
3303                .expect("subscription")
3304                .cursor,
3305            -1
3306        );
3307        assert!(client.upgrading());
3308        assert!(client.sync_needed());
3309        assert!(matches!(
3310            client.drain_sync_intents().as_slice(),
3311            [SyncIntent::Interactive]
3312        ));
3313        assert_eq!(client.drain_change_batches().len(), 1);
3314
3315        assert_eq!(
3316            client
3317                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3318                    rebootstrap_id: "support-case-001".to_owned(),
3319                })
3320                .expect("idempotent retry"),
3321            LocalDataRebootstrapResult {
3322                already_applied: true,
3323                retained_commits: 1,
3324                reset_subscriptions: 1,
3325            }
3326        );
3327        assert!(client.drain_change_batches().is_empty());
3328    }
3329
3330    #[test]
3331    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3332        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3333        assert_eq!(
3334            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3335            (3, 4)
3336        );
3337        assert_eq!(
3338            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3339            (0, 0)
3340        );
3341        for malformed in [
3342            "",
3343            "{}",
3344            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3345            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3346            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3347        ] {
3348            assert_eq!(
3349                decode_local_rebootstrap_receipt(malformed)
3350                    .expect_err("malformed receipt must fail"),
3351                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3352            );
3353        }
3354    }
3355
3356    #[test]
3357    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3358        let path = std::env::temp_dir().join(format!(
3359            "syncular-rebootstrap-receipt-{}.db",
3360            uuid::Uuid::new_v4()
3361        ));
3362        let schema = json!({
3363            "version": 1,
3364            "tables": [{
3365                "name": "tasks",
3366                "primaryKey": "id",
3367                "columns": [
3368                    { "name": "id", "type": "string", "nullable": false },
3369                    { "name": "project_id", "type": "string", "nullable": false }
3370                ],
3371                "scopes": [{ "pattern": "project:{project_id}" }]
3372            }]
3373        });
3374        let path_string = path.to_str().expect("UTF-8 temp path");
3375
3376        {
3377            let mut first = SyncClient::open_path(
3378                "repair-restart-client".to_owned(),
3379                &schema,
3380                ClientLimits::default(),
3381                path_string,
3382            )
3383            .expect("first open");
3384            first
3385                .subscribe(
3386                    "repair-tasks".to_owned(),
3387                    "tasks".to_owned(),
3388                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3389                    None,
3390                )
3391                .expect("subscribe");
3392            first
3393                .mutate(vec![Mutation::Upsert {
3394                    table: "tasks".to_owned(),
3395                    values: Map::from_iter([
3396                        ("id".to_owned(), Value::from("offline-row")),
3397                        ("project_id".to_owned(), Value::from("p1")),
3398                    ]),
3399                    base_version: None,
3400                }])
3401                .expect("queue offline work");
3402            assert_eq!(
3403                first
3404                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
3405                        rebootstrap_id: "restart-receipt".to_owned(),
3406                    })
3407                    .expect("first rebootstrap"),
3408                LocalDataRebootstrapResult {
3409                    already_applied: false,
3410                    retained_commits: 1,
3411                    reset_subscriptions: 1,
3412                }
3413            );
3414        }
3415
3416        let mut reopened = SyncClient::open_path(
3417            "repair-restart-client".to_owned(),
3418            &schema,
3419            ClientLimits::default(),
3420            path_string,
3421        )
3422        .expect("reopen");
3423        reopened.drain_change_batches();
3424        reopened.drain_sync_intents();
3425        assert_eq!(
3426            reopened
3427                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3428                    rebootstrap_id: "restart-receipt".to_owned(),
3429                })
3430                .expect("receipt replay"),
3431            LocalDataRebootstrapResult {
3432                already_applied: true,
3433                retained_commits: 1,
3434                reset_subscriptions: 1,
3435            }
3436        );
3437        assert!(reopened.drain_change_batches().is_empty());
3438        assert!(reopened.drain_sync_intents().is_empty());
3439        drop(reopened);
3440        std::fs::remove_file(path).expect("remove temp database");
3441    }
3442
3443    #[test]
3444    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3445        let mut malformed = client();
3446        malformed
3447            .subscribe(
3448                "repair-tasks".to_owned(),
3449                "tasks".to_owned(),
3450                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3451                None,
3452            )
3453            .expect("subscribe");
3454        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3455        malformed.drain_change_batches();
3456        malformed.drain_sync_intents();
3457        let malformed_error = malformed
3458            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3459                rebootstrap_id: "malformed".to_owned(),
3460            })
3461            .expect_err("malformed receipt must fail closed");
3462        assert_eq!(
3463            malformed_error,
3464            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3465        );
3466        assert!(!malformed.upgrading());
3467        assert_eq!(
3468            malformed
3469                .subscription_state("repair-tasks")
3470                .expect("unchanged subscription")
3471                .cursor,
3472            -1
3473        );
3474        assert!(malformed.drain_change_batches().is_empty());
3475        assert!(malformed.drain_sync_intents().is_empty());
3476
3477        let mut unreadable = client();
3478        unreadable
3479            .conn
3480            .execute(
3481                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3482                rusqlite::params!["server-row", "p1"],
3483            )
3484            .expect("seed visible row");
3485        unreadable
3486            .conn
3487            .execute("DROP TABLE _syncular_meta", [])
3488            .expect("break marker storage");
3489        let unreadable_error = unreadable
3490            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3491                rebootstrap_id: "unreadable".to_owned(),
3492            })
3493            .expect_err("unreadable marker storage must fail closed");
3494        assert_eq!(
3495            unreadable_error,
3496            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3497        );
3498        let visible_rows = unreadable
3499            .conn
3500            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3501            .expect("visible projection remains");
3502        assert_eq!(visible_rows, 1);
3503        assert!(!unreadable.upgrading());
3504        assert!(unreadable.drain_change_batches().is_empty());
3505        assert!(unreadable.drain_sync_intents().is_empty());
3506    }
3507
3508    #[test]
3509    fn local_rebootstrap_cannot_bypass_schema_floor() {
3510        let mut client = client();
3511        client.set_schema_floor(Some(SchemaFloor {
3512            required_schema_version: Some(2),
3513            latest_schema_version: Some(2),
3514        }));
3515        let error = client
3516            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3517                rebootstrap_id: "blocked-floor".to_owned(),
3518            })
3519            .expect_err("schema floor must block repair");
3520        assert!(error.contains("cannot bypass an active schema-floor stop"));
3521    }
3522
3523    #[test]
3524    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3525        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3526            "columns":[{"name":"id","type":"string","nullable":false},
3527                {"name":"project_id","type":"string","nullable":false},
3528                {"name":"starts_at_ms","type":"integer","nullable":false},
3529                {"name":"version","type":"integer","nullable":false}],
3530            "scopes":[{"pattern":"project:{project_id}"}]}]});
3531        let mut client =
3532            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3533        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3534        client
3535            .write_base_row(
3536                "tasks",
3537                &vec![
3538                    Some(ColumnValue::String("t1".into())),
3539                    Some(ColumnValue::String("p1".into())),
3540                    Some(ColumnValue::Integer(100)),
3541                    Some(ColumnValue::Integer(1)),
3542                ],
3543                1,
3544            )
3545            .unwrap();
3546        client.rebuild_overlay().expect("rebuild overlay");
3547        let first = client
3548            .patch(
3549                "tasks",
3550                "t1",
3551                Map::from_iter([
3552                    ("starts_at_ms".into(), json!(200)),
3553                    ("version".into(), json!(2)),
3554                ]),
3555                None,
3556            )
3557            .unwrap();
3558        let (_, first_request) = client.build_request(false).unwrap();
3559        // This second edit happens while the first request is in flight.
3560        let second = client
3561            .patch(
3562                "tasks",
3563                "t1",
3564                Map::from_iter([
3565                    ("starts_at_ms".into(), json!(300)),
3566                    ("version".into(), json!(3)),
3567                ]),
3568                None,
3569            )
3570            .unwrap();
3571        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3572        for (id, seq, meta) in [
3573            (first, 2, first_request),
3574            (second, 3, client.build_request(false).unwrap().1),
3575        ] {
3576            let response = Message {
3577                wire_version: WIRE_VERSION,
3578                msg_kind: MsgKind::Response,
3579                frames: vec![
3580                    Frame::RespHeader {
3581                        required_schema_version: None,
3582                        latest_schema_version: None,
3583                        log_epoch: Some("epoch-1".into()),
3584                        reset_required: Some(false),
3585                    },
3586                    Frame::PushResult {
3587                        client_commit_id: id,
3588                        status: PushStatus::Applied,
3589                        commit_seq: Some(seq),
3590                        results: vec![OpResult::Applied { op_index: 0 }],
3591                    },
3592                ],
3593            };
3594            assert!(matches!(
3595                client.process_response(&mut transport, response, &meta),
3596                SyncOutcome::Ok(_)
3597            ));
3598            let row = &client
3599                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3600                .unwrap()[0];
3601            assert_eq!(
3602                row["starts_at_ms"],
3603                json!(300),
3604                "an applied ACK without a pull image must not expose the old base"
3605            );
3606            assert_eq!(
3607                row["version"],
3608                json!(3),
3609                "the queued domain version must remain monotonic"
3610            );
3611        }
3612    }
3613
3614    #[test]
3615    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3616        for stored in [None, Some("epoch-old")] {
3617            let mut client = client();
3618            client
3619                .subscribe(
3620                    "tasks".into(),
3621                    "tasks".into(),
3622                    vec![("project_id".into(), vec!["p1".into()])],
3623                    None,
3624                )
3625                .unwrap();
3626            client
3627                .conn
3628                .execute_batch(
3629                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3630                )
3631                .unwrap();
3632            let id = client
3633                .mutate(vec![Mutation::Upsert {
3634                    table: "tasks".into(),
3635                    values: Map::from_iter([
3636                        ("id".into(), json!("offline")),
3637                        ("project_id".into(), json!("p1")),
3638                    ]),
3639                    base_version: None,
3640                }])
3641                .unwrap();
3642            if let Some(epoch) = stored {
3643                client.set_meta(LOG_EPOCH_KEY, epoch);
3644            }
3645            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3646            let (_, meta) = client.build_request(false).unwrap();
3647            client.drain_change_batches();
3648            client.drain_sync_intents();
3649            let response = Message {
3650                wire_version: WIRE_VERSION,
3651                msg_kind: MsgKind::Response,
3652                frames: vec![Frame::RespHeader {
3653                    required_schema_version: None,
3654                    latest_schema_version: None,
3655                    log_epoch: Some("epoch-new".into()),
3656                    reset_required: Some(true),
3657                }],
3658            };
3659            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3660            let outcome = client.process_response(&mut transport, response, &meta);
3661            let SyncOutcome::Ok(report) = outcome else {
3662                panic!("epoch round failed: {outcome:?}");
3663            };
3664            assert_eq!(
3665                report.resets,
3666                if stored.is_none() {
3667                    Vec::<String>::new()
3668                } else {
3669                    vec!["tasks".into()]
3670                }
3671            );
3672            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3673            assert_eq!(client.pending_commit_ids(), vec![id]);
3674            assert_eq!(
3675                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3676                json!("offline")
3677            );
3678            let trigger: i64 = client
3679                .conn
3680                .query_row(
3681                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3682                    [],
3683                    |row| row.get(0),
3684                )
3685                .unwrap();
3686            if stored.is_none() {
3687                assert!(!client.upgrading);
3688                assert!(client
3689                    .drain_change_batches()
3690                    .iter()
3691                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3692                assert_eq!(
3693                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3694                    before
3695                );
3696                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3697            } else {
3698                assert!(client.upgrading);
3699                assert_eq!(trigger, 0, "an epoch change must drop the old table");
3700            }
3701            assert!(matches!(
3702                client.drain_sync_intents().as_slice(),
3703                [SyncIntent::Interactive]
3704            ));
3705        }
3706    }
3707
3708    #[test]
3709    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3710        for key in ["logEpoch", "localRevision"] {
3711            let mut client = client();
3712            let id = client
3713                .mutate(vec![Mutation::Upsert {
3714                    table: "tasks".into(),
3715                    values: Map::from_iter([
3716                        ("id".into(), json!("offline")),
3717                        ("project_id".into(), json!("p1")),
3718                    ]),
3719                    base_version: None,
3720                }])
3721                .unwrap();
3722            let revision = client.local_revision();
3723            let needed = client.sync_needed;
3724            client.drain_change_batches();
3725            client.drain_sync_intents();
3726            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();
3727            assert!(client.run_log_epoch_reset("epoch-new").is_err());
3728            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3729            assert!(!client.upgrading);
3730            assert_eq!(client.sync_needed, needed);
3731            assert_eq!(client.local_revision(), revision);
3732            assert_eq!(client.pending_commit_ids(), [id]);
3733            assert!(client.drain_change_batches().is_empty());
3734            assert!(client.drain_sync_intents().is_empty());
3735        }
3736    }
3737
3738    #[test]
3739    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3740        for key in [
3741            "localSchemaVersion",
3742            "localSchemaDescriptor",
3743            "logEpoch",
3744            "localRevision",
3745        ] {
3746            let mut client = client();
3747            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3748            let commit = client
3749                .mutate(vec![Mutation::Upsert {
3750                    table: "tasks".into(),
3751                    values: Map::from_iter([
3752                        ("id".into(), json!("offline")),
3753                        ("project_id".into(), json!("p1")),
3754                    ]),
3755                    base_version: None,
3756                }])
3757                .unwrap();
3758            client.active_round = Some(uuid::Uuid::new_v4());
3759            client.overlay_dirty.set(false);
3760            let round = client.active_round;
3761            let needed = client.sync_needed;
3762            let revision = client.local_revision();
3763            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3764            client.drain_change_batches();
3765            client.drain_sync_intents();
3766            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();
3767            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3768            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3769            assert!(!client.upgrading);
3770            assert_eq!(client.active_round, round);
3771            assert!(!client.overlay_dirty.get());
3772            assert_eq!(client.sync_needed, needed);
3773            assert_eq!(client.local_revision(), revision);
3774            assert_eq!(
3775                client.pending_commit_ids().as_slice(),
3776                std::slice::from_ref(&commit)
3777            );
3778            assert_eq!(
3779                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3780                rows
3781            );
3782            assert!(client.drain_change_batches().is_empty());
3783            assert!(client.drain_sync_intents().is_empty());
3784            assert!(client.conn.is_autocommit());
3785            client
3786                .conn
3787                .execute_batch("DROP TRIGGER fail_reset")
3788                .unwrap();
3789            client.run_log_epoch_reset("epoch-new").unwrap();
3790            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3791            assert_eq!(client.pending_commit_ids(), [commit]);
3792        }
3793    }
3794
3795    #[test]
3796    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3797        for subscriptions in [0, 2, 6, 12] {
3798            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3799                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3800                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3801            ] }), ClientLimits::default()).unwrap();
3802            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3803            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();
3804            for index in 0..32 {
3805                client
3806                    .mutate(vec![Mutation::Upsert {
3807                        table: "tasks".into(),
3808                        values: Map::from_iter([
3809                            ("id".into(), json!(format!("task-{index}"))),
3810                            ("project_id".into(), json!("p1")),
3811                        ]),
3812                        base_version: None,
3813                    }])
3814                    .unwrap();
3815            }
3816            let (_, meta) = client.build_request(false).unwrap();
3817            let mut frames = vec![Frame::RespHeader {
3818                required_schema_version: None,
3819                latest_schema_version: None,
3820                log_epoch: Some("epoch-1".into()),
3821                reset_required: Some(false),
3822            }];
3823            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3824                Frame::PushResult {
3825                    client_commit_id: id.clone(),
3826                    status: PushStatus::Applied,
3827                    commit_seq: Some(index as i64 + 1),
3828                    results: vec![OpResult::Applied { op_index: 0 }],
3829                }
3830            }));
3831            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3832            client.acknowledged_replay_count.set(0);
3833            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3834            assert!(matches!(
3835                client.process_response(
3836                    &mut transport,
3837                    Message {
3838                        wire_version: WIRE_VERSION,
3839                        msg_kind: MsgKind::Response,
3840                        frames
3841                    },
3842                    &meta
3843                ),
3844                SyncOutcome::Ok(_)
3845            ));
3846            assert_eq!(client.acknowledged_replay_count.get(), 32);
3847            assert_eq!(
3848                client
3849                    .query(
3850                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3851                        &[]
3852                    )
3853                    .unwrap()[0]["n"],
3854                json!(32)
3855            );
3856            assert_eq!(
3857                client
3858                    .query(
3859                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3860                        &[]
3861                    )
3862                    .unwrap()[0]["n"],
3863                json!(32)
3864            );
3865            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3866            client.acknowledged_replay_count.set(0);
3867            for index in 0..subscriptions {
3868                client
3869                    .subscribe(
3870                        format!("docs-{index}"),
3871                        "docs".into(),
3872                        vec![("project_id".into(), vec![format!("scope-{index}")])],
3873                        None,
3874                    )
3875                    .unwrap();
3876                let sub = client.subs.len() - 1;
3877                client.subs[sub].effective =
3878                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3879                let segment = RowsSegment {
3880                    table: "docs".into(),
3881                    schema_version: 1,
3882                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3883                    blocks: if index % 2 == 0 {
3884                        vec![vec![ssp2::segment::SegmentRow {
3885                            values: vec![
3886                                Some(ColumnValue::String(format!("doc-{index}"))),
3887                                Some(ColumnValue::String(format!("scope-{index}"))),
3888                            ],
3889                            server_version: 1,
3890                        }]]
3891                    } else {
3892                        vec![]
3893                    },
3894                };
3895                assert!(client.apply_segment(sub, &segment, true).is_ok());
3896            }
3897            assert_eq!(
3898                client
3899                    .query("SELECT count(*) AS n FROM ack_writes", &[])
3900                    .unwrap()[0]["n"],
3901                json!(0),
3902                "unrelated bootstrap must not restore/replay tasks"
3903            );
3904            assert_eq!(
3905                client.acknowledged_replay_count.get(),
3906                0,
3907                "unrelated bootstrap must not decode task ACKs"
3908            );
3909            assert_eq!(
3910                client
3911                    .query("SELECT count(*) AS n FROM tasks", &[])
3912                    .unwrap()[0]["n"],
3913                json!(32)
3914            );
3915            assert_eq!(
3916                client
3917                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3918                    .unwrap()[0]["n"],
3919                json!(32)
3920            );
3921        }
3922    }
3923
3924    #[test]
3925    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3926        let mut client = client();
3927        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3928        const COMMIT_COUNT: usize = 32;
3929
3930        for index in 0..COMMIT_COUNT {
3931            client
3932                .mutate(vec![Mutation::Upsert {
3933                    table: "tasks".to_owned(),
3934                    values: Map::from_iter([
3935                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3936                        ("project_id".to_owned(), Value::from("project-1")),
3937                    ]),
3938                    base_version: None,
3939                }])
3940                .expect("queue commit");
3941        }
3942
3943        let (_, request_meta) = client.build_request(false).unwrap();
3944        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3945        let mut frames = vec![Frame::RespHeader {
3946            required_schema_version: None,
3947            latest_schema_version: None,
3948            log_epoch: Some("epoch-1".to_owned()),
3949            reset_required: Some(false),
3950        }];
3951        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3952            |(index, client_commit_id)| Frame::PushResult {
3953                client_commit_id: client_commit_id.clone(),
3954                status: PushStatus::Applied,
3955                commit_seq: Some(index as i64 + 1),
3956                results: vec![OpResult::Applied { op_index: 0 }],
3957            },
3958        ));
3959        let response = Message {
3960            wire_version: WIRE_VERSION,
3961            msg_kind: MsgKind::Response,
3962            frames,
3963        };
3964        let mut transport =
3965            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3966
3967        client.overlay_rebuild_count.set(0);
3968        client.outcome_prune_count.set(0);
3969        let outcome = client.process_response(&mut transport, response, &request_meta);
3970        assert!(matches!(outcome, SyncOutcome::Ok(_)));
3971        assert!(client.pending_commit_ids().is_empty());
3972        assert_eq!(
3973            client.overlay_rebuild_count.get(),
3974            1,
3975            "one response must reconcile its acknowledged commits with one overlay rebuild"
3976        );
3977        assert_eq!(
3978            client.outcome_prune_count.get(),
3979            1,
3980            "one response must enforce outcome retention once"
3981        );
3982    }
3983
3984    #[test]
3985    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3986        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3987            "columns": [
3988                {"name":"id", "type":"string", "nullable":false},
3989                {"name":"project_id", "type":"string", "nullable":false},
3990                {"name":"title", "type":"string", "nullable":false}
3991            ], "scopes": [{"pattern":"project:{project_id}"}],
3992            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3993            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3994        }] });
3995        let mut incremental =
3996            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3997        let mut reference =
3998            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3999        let tables = vec!["tasks".to_owned()];
4000        for index in 0..32 {
4001            let id = format!("task-{}", index % 8);
4002            let values = Map::from_iter([
4003                ("id".to_owned(), Value::from(id.clone())),
4004                (
4005                    "project_id".to_owned(),
4006                    Value::from(format!("p{}", index % 2)),
4007                ),
4008                ("title".to_owned(), Value::from(format!("needle{index}"))),
4009            ]);
4010            let payload = encode_row_json(
4011                incremental.schema.table("tasks").unwrap(),
4012                &id,
4013                &values,
4014                &incremental.encryption,
4015            )
4016            .unwrap();
4017            let change = ssp2::model::Change {
4018                table_index: 0,
4019                row_id: id,
4020                op: if index % 5 == 0 {
4021                    Op::Delete
4022                } else {
4023                    Op::Upsert
4024                },
4025                row_version: Some(index + 1),
4026                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4027                row: Some(payload),
4028            };
4029            reference.overlay_dirty.set(true);
4030            for instance in [&mut incremental, &mut reference] {
4031                instance.begin_observation("mirror_test").unwrap();
4032                let mut batch = ChangeAccumulator::default();
4033                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4034                instance
4035                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
4036                    .unwrap();
4037                instance
4038                    .rebuild_overlay_if_dirty()
4039                    .expect("rebuild overlay");
4040                instance.finish_observation("mirror_test", batch).unwrap();
4041            }
4042            for sql in [
4043                "SELECT * FROM tasks ORDER BY id",
4044                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4045            ] {
4046                assert_eq!(
4047                    incremental.query(sql, &[]).unwrap(),
4048                    reference.query(sql, &[]).unwrap()
4049                );
4050            }
4051            assert_eq!(incremental.local_revision(), reference.local_revision());
4052            assert_eq!(
4053                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4054                serde_json::to_value(reference.drain_change_batches()).unwrap()
4055            );
4056        }
4057        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4058        assert_eq!(reference.overlay_rebuild_count.get(), 32);
4059    }
4060
4061    #[test]
4062    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4063        let cases = [
4064            (
4065                "string",
4066                vec![
4067                    json!("1"),
4068                    json!("01"),
4069                    json!(""),
4070                    json!("nul\u{0}id"),
4071                    json!("é"),
4072                ],
4073            ),
4074            (
4075                "integer",
4076                vec![
4077                    json!(i64::MIN),
4078                    json!(-1),
4079                    json!(0),
4080                    json!(1),
4081                    json!(i64::MAX),
4082                ],
4083            ),
4084            ("boolean", vec![json!(false), json!(true)]),
4085            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4086        ];
4087        for (kind, ids) in cases {
4088            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4089                "columns":[{"name":"id","type":kind,"nullable":false},
4090                    {"name":"project_id","type":"string","nullable":false}],
4091                "scopes":[{"pattern":"project:{project_id}"}]}]});
4092            let mut client =
4093                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4094            for (index, id) in ids.iter().enumerate() {
4095                let table = client.schema.table("tasks").unwrap();
4096                client
4097                    .write_base_row(
4098                        "tasks",
4099                        &vec![
4100                            if kind == "integer" {
4101                                // Exercise local SQLite key lookup across its
4102                                // full i64 range independently of authoring.
4103                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
4104                            } else {
4105                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
4106                            },
4107                            Some(ColumnValue::String(format!("p{index}"))),
4108                        ],
4109                        1,
4110                    )
4111                    .unwrap();
4112            }
4113            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4114            for base in [true, false] {
4115                let full = if base {
4116                    base_table("tasks")
4117                } else {
4118                    visible_table("tasks")
4119                };
4120                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4121                for row_id in [
4122                    "1",
4123                    "01",
4124                    "1.0",
4125                    "1.0000000000000002",
4126                    "+1",
4127                    "1e0",
4128                    " 1",
4129                    "-1",
4130                    "0",
4131                    "9223372036854775807",
4132                    "-9223372036854775808",
4133                    "true",
4134                    "false",
4135                    "",
4136                    "nul\u{0}id",
4137                    "é",
4138                    "null",
4139                    "{\"a\":1}",
4140                ] {
4141                    let old_sql =
4142                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4143                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4144                    let old = client
4145                        .conn
4146                        .prepare(&old_sql)
4147                        .unwrap()
4148                        .query_map([row_id], |row| row.get::<_, String>(0))
4149                        .unwrap()
4150                        .collect::<Result<Vec<_>, _>>()
4151                        .unwrap();
4152                    let new = client
4153                        .conn
4154                        .prepare_cached(&new_sql)
4155                        .unwrap()
4156                        .query_map([row_id], |row| row.get::<_, String>(0))
4157                        .unwrap()
4158                        .collect::<Result<Vec<_>, _>>()
4159                        .unwrap();
4160                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4161                    let mut batch = ChangeAccumulator::default();
4162                    assert_eq!(
4163                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
4164                        !old.is_empty()
4165                    );
4166                    let expected = old.first().map(|scope| {
4167                        BTreeMap::from([(
4168                            "tasks".to_owned(),
4169                            Some(BTreeSet::from([format!("project:{scope}")])),
4170                        )])
4171                    });
4172                    assert_eq!(batch.tables, expected.unwrap_or_default());
4173                    client
4174                        .conn
4175                        .execute_batch("SAVEPOINT lookup_delete")
4176                        .unwrap();
4177                    if base {
4178                        client.delete_base_row("tasks", row_id).unwrap();
4179                    } else {
4180                        client
4181                            .apply_outbox_ops(&[OutboxOp {
4182                                upsert: false,
4183                                table: "tasks".into(),
4184                                row_id: row_id.into(),
4185                                base_version: None,
4186                                values: None,
4187                            }])
4188                            .expect("replay delete");
4189                    }
4190                    let remaining: i64 = client
4191                        .conn
4192                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4193                            row.get(0)
4194                        })
4195                        .unwrap();
4196                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4197                    client
4198                        .conn
4199                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4200                        .unwrap();
4201                    client.overlay_dirty.set(false);
4202                }
4203                for (predicate, expected) in [
4204                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4205                    (predicate, "SEARCH"),
4206                ] {
4207                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4208                    let details = client
4209                        .conn
4210                        .prepare(&sql)
4211                        .unwrap()
4212                        .query_map(["1"], |row| row.get::<_, String>(3))
4213                        .unwrap()
4214                        .collect::<Result<Vec<_>, _>>()
4215                        .unwrap();
4216                    assert!(
4217                        details.iter().any(|detail| detail.contains(expected)),
4218                        "{kind}: {details:?}"
4219                    );
4220                }
4221            }
4222        }
4223    }
4224
4225    #[test]
4226    fn primary_keys_reject_every_ineligible_column_type() {
4227        // §2.4: a `rowId` is a string, so a primary key type whose string form
4228        // differs per renderer, or cannot be reproduced by local storage
4229        // comparison, is a schema error rather than a silent lookup miss.
4230        for ty in ["float", "bytes", "crdt", "blob_ref"] {
4231            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4232                "columns":[{"name":"id","type":ty,"nullable":false},
4233                    {"name":"project_id","type":"string","nullable":false}],
4234                "scopes":[{"pattern":"project:{project_id}"}]}]});
4235            let error =
4236                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4237                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4238                    Err(error) => error,
4239                };
4240            assert!(
4241                error.contains("has an ineligible column type (§2.4)"),
4242                "{ty}: {error}"
4243            );
4244        }
4245        for ty in ["string", "integer", "boolean", "json"] {
4246            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4247                "columns":[{"name":"id","type":ty,"nullable":false},
4248                    {"name":"project_id","type":"string","nullable":false}],
4249                "scopes":[{"pattern":"project:{project_id}"}]}]});
4250            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4251                .unwrap_or_else(|error| panic!("{ty}: {error}"));
4252        }
4253    }
4254
4255    #[test]
4256    fn pending_row_reconciliation_matches_full_fifo_replay() {
4257        for unique in [false, true] {
4258            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4259                "name": name, "primaryKey": "id", "columns": [
4260                    {"name":"id", "type":"string", "nullable":false},
4261                    {"name":"project_id", "type":"string", "nullable":false},
4262                    {"name":"title", "type":"string", "nullable":false}],
4263                "scopes": [{"pattern":"project:{project_id}"}],
4264                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4265                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4266            })))});
4267            let mut incremental =
4268                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4269            let mut reference =
4270                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4271            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4272            for instance in [&mut incremental, &mut reference] {
4273                for table in &tables {
4274                    for index in 0..12 {
4275                        instance
4276                            .write_base_row(
4277                                table,
4278                                &vec![
4279                                    Some(ColumnValue::String(format!("row{index}"))),
4280                                    Some(ColumnValue::String("p0".into())),
4281                                    Some(ColumnValue::String(format!("base{index}"))),
4282                                ],
4283                                1,
4284                            )
4285                            .unwrap();
4286                    }
4287                }
4288                instance
4289                    .rebuild_overlay_if_dirty()
4290                    .expect("rebuild overlay");
4291            }
4292            for index in 0..80 {
4293                let mutations = (0..3)
4294                    .map(|offset| {
4295                        let table = tables[(index + offset) % 2].clone();
4296                        let id = format!("row{}", (index + offset) % 16);
4297                        if (index + offset) % 7 == 0 {
4298                            Mutation::Delete {
4299                                table,
4300                                row_id: id,
4301                                base_version: None,
4302                            }
4303                        } else {
4304                            Mutation::Upsert {
4305                                table,
4306                                values: Map::from_iter([
4307                                    ("id".into(), Value::from(id)),
4308                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
4309                                    (
4310                                        "title".into(),
4311                                        Value::from(format!("needle{}", (index + offset) % 16)),
4312                                    ),
4313                                ]),
4314                                base_version: None,
4315                            }
4316                        }
4317                    })
4318                    .collect::<Vec<_>>();
4319                for instance in [&mut incremental, &mut reference] {
4320                    instance.mutate(mutations.clone()).unwrap();
4321                }
4322                // Most frames touch only the table without unique constraints.
4323                // Mixed frames must retain full replay for both tables.
4324                let changes = (0..3)
4325                    .map(|offset| {
4326                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4327                        let id = format!("row{}", (index + offset) % 16);
4328                        let values = Map::from_iter([
4329                            ("id".into(), Value::from(id.clone())),
4330                            (
4331                                "project_id".into(),
4332                                Value::from(format!("p{}", (index + 1) % 3)),
4333                            ),
4334                            (
4335                                "title".into(),
4336                                Value::from(format!("remote{index}_{offset}")),
4337                            ),
4338                        ]);
4339                        ssp2::model::Change {
4340                            table_index: table_index as u16,
4341                            row_id: id.clone(),
4342                            op: if (index + offset) % 5 == 0 {
4343                                Op::Delete
4344                            } else {
4345                                Op::Upsert
4346                            },
4347                            row_version: Some(index as i64 + 2),
4348                            scopes: vec![],
4349                            row: Some(
4350                                encode_row_json(
4351                                    incremental.schema.table(&tables[table_index]).unwrap(),
4352                                    &id,
4353                                    &values,
4354                                    &incremental.encryption,
4355                                )
4356                                .unwrap(),
4357                            ),
4358                        }
4359                    })
4360                    .collect::<Vec<_>>();
4361                reference.overlay_dirty.set(true);
4362                incremental
4363                    .apply_commit_frame(&tables, &changes, None)
4364                    .unwrap();
4365                reference
4366                    .apply_commit_frame(&tables, &changes, None)
4367                    .unwrap();
4368                for table in &tables {
4369                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4370                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4371                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4372                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4373                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4374                    }
4375                }
4376                assert_eq!(incremental.local_revision(), reference.local_revision());
4377                assert_eq!(
4378                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4379                    serde_json::to_value(reference.drain_change_batches()).unwrap()
4380                );
4381            }
4382            assert_eq!(
4383                incremental.overlay_rebuild_count.get(),
4384                if unique { 81 } else { 21 }
4385            );
4386            assert_eq!(reference.overlay_rebuild_count.get(), 81);
4387        }
4388    }
4389
4390    #[test]
4391    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4392        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4393            "columns":[{"name":"id", "type":"string", "nullable":false},
4394                {"name":"title", "type":"string", "nullable":false}],
4395            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4396        let mut client =
4397            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4398        for (id, title) in [("a", "original"), ("b", "occupied")] {
4399            client
4400                .write_base_row(
4401                    "tasks",
4402                    &vec![
4403                        Some(ColumnValue::String(id.into())),
4404                        Some(ColumnValue::String(title.into())),
4405                    ],
4406                    1,
4407                )
4408                .unwrap();
4409        }
4410        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4411        let pending = client
4412            .mutate(vec![Mutation::Upsert {
4413                table: "tasks".into(),
4414                values: Map::from_iter([
4415                    ("id".into(), Value::from("a")),
4416                    ("title".into(), Value::from("available")),
4417                ]),
4418                base_version: None,
4419            }])
4420            .unwrap();
4421        assert_eq!(
4422            client
4423                .conn
4424                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4425                    .get::<_, String>(
4426                    0
4427                ))
4428                .unwrap(),
4429            "available"
4430        );
4431        for (title, expected) in [("available", "original"), ("occupied", "available")] {
4432            let change = ssp2::model::Change {
4433                table_index: 0,
4434                row_id: "b".into(),
4435                op: Op::Upsert,
4436                row_version: Some(2),
4437                scopes: vec![],
4438                row: Some(
4439                    encode_row_json(
4440                        client.schema.table("tasks").unwrap(),
4441                        "b",
4442                        &Map::from_iter([
4443                            ("id".into(), Value::from("b")),
4444                            ("title".into(), Value::from(title)),
4445                        ]),
4446                        &client.encryption,
4447                    )
4448                    .unwrap(),
4449                ),
4450            };
4451            client
4452                .apply_commit_frame(&["tasks".into()], &[change], None)
4453                .unwrap();
4454            assert_eq!(
4455                client
4456                    .conn
4457                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4458                        .get::<_, String>(
4459                        0
4460                    ))
4461                    .unwrap(),
4462                expected
4463            );
4464            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4465        }
4466        assert_eq!(client.overlay_rebuild_count.get(), 3);
4467    }
4468
4469    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4470        let schema = json!({ "version": 1, "tables": [
4471            { "name":"tasks", "primaryKey":"id", "columns":[
4472                {"name":"id","type":"string","nullable":false},
4473                {"name":"project_id","type":"string","nullable":false}],
4474                "scopes":[{"pattern":"project:{project_id}"}] },
4475            { "name":"catalogue", "primaryKey":"id", "columns":[
4476                {"name":"id","type":"string","nullable":false},
4477                {"name":"project_id","type":"string","nullable":false},
4478                {"name":"title","type":"string","nullable":false}],
4479                "scopes":[{"pattern":"project:{project_id}"}],
4480                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4481        ] });
4482        let mut client =
4483            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4484        if unchanged_rows > 0 {
4485            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();
4486        }
4487        client.rebuild_overlay().expect("rebuild overlay");
4488        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();
4489        let mut elapsed = Vec::new();
4490        for round in 1..=rounds {
4491            let pending = client
4492                .mutate(vec![Mutation::Upsert {
4493                    table: "tasks".into(),
4494                    values: Map::from_iter([
4495                        ("id".into(), json!("t1")),
4496                        ("project_id".into(), json!("p1")),
4497                    ]),
4498                    base_version: None,
4499                }])
4500                .unwrap();
4501            let started = std::time::Instant::now();
4502            client
4503                .write_base_row(
4504                    "tasks",
4505                    &vec![
4506                        Some(ColumnValue::String("t1".into())),
4507                        Some(ColumnValue::String("p1".into())),
4508                    ],
4509                    round,
4510                )
4511                .unwrap();
4512            let mut report = SyncReport::default();
4513            client
4514                .handle_push_results(
4515                    &[Frame::PushResult {
4516                        client_commit_id: pending.clone(),
4517                        status: PushStatus::Applied,
4518                        commit_seq: Some(round),
4519                        results: vec![OpResult::Applied { op_index: 0 }],
4520                    }],
4521                    &HashSet::from([pending.as_str()]),
4522                    &HashMap::new(),
4523                    &mut report,
4524                    Some(&pending),
4525                )
4526                .unwrap();
4527            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4528            elapsed.push(started.elapsed().as_nanos());
4529        }
4530        assert_eq!(
4531            client
4532                .conn
4533                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4534                .unwrap(),
4535            0,
4536            "unchanged catalogue must never be copied"
4537        );
4538        assert_eq!(
4539            client
4540                .conn
4541                .query_row(
4542                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4543                    [],
4544                    |r| r.get::<_, i64>(0)
4545                )
4546                .unwrap(),
4547            unchanged_rows
4548        );
4549        assert_eq!(
4550            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4551            json!(rounds)
4552        );
4553        elapsed
4554    }
4555
4556    #[test]
4557    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4558        let elapsed = measure_scoped_overlay(100_000, 1);
4559        eprintln!(
4560            "table-scoped overlay with 100k unchanged rows: {} ns",
4561            elapsed[0]
4562        );
4563    }
4564
4565    #[test]
4566    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4567    fn benchmark_scoped_overlay() {
4568        for rows in [0, 100_000, 200_000] {
4569            let mut elapsed = measure_scoped_overlay(rows, 30);
4570            elapsed.sort_unstable();
4571            eprintln!(
4572                "{}",
4573                json!({"unchangedRows": rows, "rounds": elapsed.len(),
4574                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4575                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4576            );
4577        }
4578    }
4579
4580    #[test]
4581    fn released_context_discards_in_flight_ack_and_server_rows() {
4582        for barrier in [
4583            "preflight",
4584            "purge",
4585            "rebootstrap",
4586            "subscription",
4587            "failed-purge-replay",
4588            "failed-purge-publish",
4589            "failed-rebootstrap",
4590        ] {
4591            let mut client = client();
4592            client.set_meta(LOG_EPOCH_KEY, "epoch");
4593            client
4594                .subscribe(
4595                    "tasks".into(),
4596                    "tasks".into(),
4597                    vec![("project_id".into(), vec!["p1".into()])],
4598                    None,
4599                )
4600                .unwrap();
4601            let commit = client
4602                .mutate(vec![Mutation::Upsert {
4603                    table: "tasks".into(),
4604                    values: Map::from_iter([
4605                        ("id".into(), json!("local")),
4606                        ("project_id".into(), json!("p1")),
4607                    ]),
4608                    base_version: None,
4609                }])
4610                .unwrap();
4611            let prepared = client.prepare_sync_round(false).unwrap();
4612            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4613            let payload = encode_row_json(
4614                &client.schema.tables[0],
4615                "server",
4616                &Map::from_iter([
4617                    ("id".into(), json!("server")),
4618                    ("project_id".into(), json!("p1")),
4619                ]),
4620                &client.encryption,
4621            )
4622            .unwrap();
4623            let completed = crate::CompletedSyncRound {
4624                prepared,
4625                exchange: crate::round::ExchangeResult::Reply {
4626                    transport_failed: false,
4627                    downloads: Default::default(),
4628                    response: Ok(Message {
4629                        wire_version: WIRE_VERSION,
4630                        msg_kind: MsgKind::Response,
4631                        frames: vec![
4632                            Frame::RespHeader {
4633                                required_schema_version: None,
4634                                latest_schema_version: None,
4635                                log_epoch: Some("epoch".into()),
4636                                reset_required: Some(false),
4637                            },
4638                            Frame::PushResult {
4639                                client_commit_id: commit.clone(),
4640                                status: PushStatus::Applied,
4641                                commit_seq: Some(1),
4642                                results: vec![OpResult::Applied { op_index: 0 }],
4643                            },
4644                            Frame::SubStart {
4645                                id: "tasks".into(),
4646                                status: SubStatus::Active,
4647                                reason_code: String::new(),
4648                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4649                                bootstrap: true,
4650                            },
4651                            Frame::Commit {
4652                                commit_seq: 1,
4653                                created_at_ms: 0,
4654                                actor_id: "actor".into(),
4655                                tables: vec!["tasks".into()],
4656                                changes: vec![ssp2::model::Change {
4657                                    table_index: 0,
4658                                    row_id: "server".into(),
4659                                    op: Op::Upsert,
4660                                    row_version: Some(1),
4661                                    scopes: vec![("project_id".into(), "p1".into())],
4662                                    row: Some(payload),
4663                                }],
4664                            },
4665                            Frame::SubEnd {
4666                                next_cursor: 1,
4667                                bootstrap_state: None,
4668                            },
4669                        ],
4670                    }),
4671                },
4672            };
4673            match barrier {
4674                "preflight" => client.begin_security_preflight(),
4675                "purge" => {
4676                    client
4677                        .purge_local_data(&LocalDataPurgeInput {
4678                            purge_id: "pending-round".into(),
4679                            targets: vec![LocalDataPurgeTarget {
4680                                table: "tasks".into(),
4681                                selectors: BTreeMap::from([(
4682                                    "project_id".into(),
4683                                    vec!["p1".into()],
4684                                )]),
4685                            }],
4686                        })
4687                        .unwrap();
4688                }
4689                "rebootstrap" => {
4690                    client
4691                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
4692                            rebootstrap_id: "pending-round".into(),
4693                        })
4694                        .unwrap();
4695                }
4696                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4697                    let trigger = if barrier == "failed-purge-replay" {
4698                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4699                    } else {
4700                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4701                    };
4702                    client.conn.execute_batch(trigger).unwrap();
4703                    let revision = client.local_revision();
4704                    if barrier == "failed-rebootstrap" {
4705                        assert!(client
4706                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
4707                                rebootstrap_id: "failed-round".into(),
4708                            })
4709                            .is_err());
4710                    } else {
4711                        assert!(client
4712                            .purge_local_data(&LocalDataPurgeInput {
4713                                purge_id: "failed-round".into(),
4714                                targets: vec![LocalDataPurgeTarget {
4715                                    table: "tasks".into(),
4716                                    selectors: BTreeMap::from([(
4717                                        "project_id".into(),
4718                                        vec!["p1".into()]
4719                                    )]),
4720                                }],
4721                            })
4722                            .is_err());
4723                    }
4724                    assert_eq!(client.local_revision(), revision);
4725                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4726                    client
4727                        .conn
4728                        .execute_batch("DROP TRIGGER fail_local")
4729                        .unwrap();
4730                    let applied = client.apply_sync_round(completed);
4731                    assert!(
4732                        matches!(
4733                            applied,
4734                            crate::AppliedSyncRound::Complete {
4735                                outcome: SyncOutcome::Ok(_),
4736                                ..
4737                            }
4738                        ),
4739                        "{barrier}"
4740                    );
4741                    assert!(client.pending_commit_ids().is_empty());
4742                    assert_eq!(
4743                        client
4744                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4745                            .unwrap()
4746                            .len(),
4747                        1
4748                    );
4749                    continue;
4750                }
4751                "subscription" => {
4752                    client.unsubscribe("tasks");
4753                    client
4754                        .subscribe(
4755                            "tasks".into(),
4756                            "tasks".into(),
4757                            vec![("project_id".into(), vec!["p2".into()])],
4758                            None,
4759                        )
4760                        .unwrap();
4761                }
4762                _ => unreachable!(),
4763            }
4764            let pending_before_reply = client.pending_commit_ids();
4765            let revision = client.local_revision();
4766            let applied = client.apply_sync_round(completed);
4767            assert!(
4768                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4769            );
4770            assert_eq!(client.local_revision(), revision);
4771            assert!(client
4772                .conn
4773                .query_row(
4774                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4775                    [],
4776                    |r| r.get::<_, bool>(0)
4777                )
4778                .unwrap());
4779            assert_eq!(client.pending_commit_ids(), pending_before_reply);
4780        }
4781    }
4782
4783    #[test]
4784    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4785        struct DeadlineRecording {
4786            budget: std::time::Duration,
4787            seen: Vec<Option<std::time::Instant>>,
4788        }
4789        impl Transport for DeadlineRecording {
4790            fn round_deadline(&self) -> Option<std::time::Duration> {
4791                Some(self.budget)
4792            }
4793            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4794                self.seen.push(deadline);
4795            }
4796            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4797                Err(TransportError::new("transport.failed", "injected"))
4798            }
4799            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4800                self.sync(request)
4801            }
4802            fn download_segment(
4803                &mut self,
4804                _: &SegmentRequest,
4805                _: &mut dyn FnMut(u64),
4806            ) -> Result<Vec<u8>, TransportError> {
4807                Err(TransportError::new("transport.failed", "injected"))
4808            }
4809            fn blob_upload_grant(
4810                &mut self,
4811                _: &str,
4812                _: u64,
4813                _: Option<&str>,
4814            ) -> Result<crate::BlobUploadGrant, TransportError> {
4815                Ok(crate::BlobUploadGrant::Present)
4816            }
4817            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4818                Ok(())
4819            }
4820            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4821                Ok(())
4822            }
4823            fn realtime_close(&mut self) -> Result<(), TransportError> {
4824                Ok(())
4825            }
4826        }
4827
4828        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4829            {"name":"id","type":"string","nullable":false},
4830            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4831        let mut client =
4832            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4833        client.set_meta(LOG_EPOCH_KEY, "epoch");
4834        client
4835            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4836            .unwrap();
4837
4838        let mut transport = DeadlineRecording {
4839            budget: std::time::Duration::from_secs(30),
4840            seen: Vec::new(),
4841        };
4842        let prepared = client.prepare_sync_round(false).unwrap();
4843        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4844        // Upload exchange -> Continue (the pin removal is persisted by apply).
4845        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4846            crate::AppliedSyncRound::Continue(prepared) => prepared,
4847            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4848        };
4849        // Main request exchange -> Complete.
4850        let completed = prepared.exchange(&mut transport);
4851        let anchor = completed
4852            .prepared
4853            .round_deadline_at
4854            .expect("the upload round anchors a deadline");
4855        assert!(matches!(
4856            client.apply_sync_round(completed),
4857            crate::AppliedSyncRound::Complete { .. }
4858        ));
4859        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4860        assert_eq!(
4861            transport.seen[0],
4862            Some(anchor),
4863            "the upload anchors the round"
4864        );
4865        assert_eq!(
4866            transport.seen[1], None,
4867            "the upload exchange clears its scope"
4868        );
4869        assert_eq!(
4870            transport.seen[2],
4871            Some(anchor),
4872            "a continuation reuses the carried anchor instead of refreshing it"
4873        );
4874        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4875
4876        // A fresh round anchors a new deadline from the transport's (now larger)
4877        // budget and never inherits the old one; the clock granule is irrelevant.
4878        transport.budget = std::time::Duration::from_secs(60);
4879        let prepared = client.prepare_sync_round(false).unwrap();
4880        let completed = prepared.exchange(&mut transport);
4881        let fresh = completed
4882            .prepared
4883            .round_deadline_at
4884            .expect("the next round anchors a deadline");
4885        client.apply_sync_round(completed);
4886        assert!(
4887            fresh >= anchor + std::time::Duration::from_secs(30),
4888            "the next round anchors a fresh deadline from the new budget"
4889        );
4890        assert_eq!(transport.seen[4], Some(fresh));
4891        assert_eq!(
4892            transport.seen[5], None,
4893            "the new round clears its scope too"
4894        );
4895    }
4896
4897    /// Records the round-deadline traffic so a test can prove whether a round
4898    /// asked for a budget and whether it ever scoped an anchor.
4899    #[derive(Default)]
4900    struct RoundDeadlineProbe {
4901        budget: Option<std::time::Duration>,
4902        budget_queries: std::cell::Cell<usize>,
4903        scoped: Vec<Option<std::time::Instant>>,
4904    }
4905
4906    impl Transport for RoundDeadlineProbe {
4907        fn round_deadline(&self) -> Option<std::time::Duration> {
4908            self.budget_queries.set(self.budget_queries.get() + 1);
4909            self.budget
4910        }
4911        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4912            self.scoped.push(deadline);
4913        }
4914        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4915            Err(TransportError::new("transport.failed", "injected"))
4916        }
4917        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4918            self.sync(request)
4919        }
4920        fn download_segment(
4921            &mut self,
4922            _: &SegmentRequest,
4923            _: &mut dyn FnMut(u64),
4924        ) -> Result<Vec<u8>, TransportError> {
4925            Err(TransportError::new("transport.failed", "injected"))
4926        }
4927        fn blob_upload_grant(
4928            &mut self,
4929            _: &str,
4930            _: u64,
4931            _: Option<&str>,
4932        ) -> Result<crate::BlobUploadGrant, TransportError> {
4933            Ok(crate::BlobUploadGrant::Present)
4934        }
4935        fn realtime_connect(&mut self) -> Result<(), TransportError> {
4936            Ok(())
4937        }
4938        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4939            Ok(())
4940        }
4941        fn realtime_close(&mut self) -> Result<(), TransportError> {
4942            Ok(())
4943        }
4944    }
4945
4946    #[test]
4947    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4948        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4949            {"name":"id","type":"string","nullable":false},
4950            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4951        let mut client =
4952            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4953        client.set_meta(LOG_EPOCH_KEY, "epoch");
4954        client
4955            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4956            .unwrap();
4957        client
4958            .conn
4959            .execute_batch("DELETE FROM _syncular_blobs")
4960            .unwrap();
4961        let prepared = client.prepare_sync_round(false).unwrap();
4962        assert!(
4963            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4964            "the staged body must fail locally"
4965        );
4966        let mut transport = RoundDeadlineProbe {
4967            budget: Some(std::time::Duration::from_secs(30)),
4968            ..Default::default()
4969        };
4970        let completed = prepared.exchange(&mut transport);
4971        assert!(matches!(
4972            completed.exchange,
4973            crate::round::ExchangeResult::Upload { result: Err(_), .. }
4974        ));
4975        assert_eq!(
4976            transport.budget_queries.get(),
4977            0,
4978            "a round with no network work never asks for a budget"
4979        );
4980        assert_eq!(transport.scoped, vec![None]);
4981    }
4982
4983    #[test]
4984    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4985        struct HugeBudget;
4986        impl Transport for HugeBudget {
4987            fn round_deadline(&self) -> Option<std::time::Duration> {
4988                Some(std::time::Duration::MAX)
4989            }
4990            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4991                panic!("no network for an invalid budget")
4992            }
4993            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4994                self.sync(request)
4995            }
4996            fn download_segment(
4997                &mut self,
4998                _: &SegmentRequest,
4999                _: &mut dyn FnMut(u64),
5000            ) -> Result<Vec<u8>, TransportError> {
5001                panic!("no network for an invalid budget")
5002            }
5003            fn realtime_connect(&mut self) -> Result<(), TransportError> {
5004                Ok(())
5005            }
5006            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5007                Ok(())
5008            }
5009            fn realtime_close(&mut self) -> Result<(), TransportError> {
5010                Ok(())
5011            }
5012        }
5013
5014        let mut client = client();
5015        client.set_meta(LOG_EPOCH_KEY, "epoch");
5016        let prepared = client.prepare_sync_round(false).unwrap();
5017        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5018            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5019            crate::AppliedSyncRound::Continue(_) => {
5020                panic!("an unrepresentable budget must not continue")
5021            }
5022        };
5023        match outcome {
5024            SyncOutcome::Failed {
5025                error_code,
5026                message,
5027                ..
5028            } => {
5029                assert_eq!(error_code, "sync.invalid_request");
5030                assert_eq!(message, "transport round deadline is out of range");
5031            }
5032            _ => panic!("an unrepresentable budget must fail synchronously"),
5033        }
5034    }
5035
5036    #[test]
5037    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5038        for failure in ["visible", "revision", "commit"] {
5039            let mut client = client();
5040            let id = client
5041                .mutate(vec![Mutation::Upsert {
5042                    table: "tasks".into(),
5043                    values: Map::from_iter([
5044                        ("id".into(), Value::from("t1")),
5045                        ("project_id".into(), Value::from("pending")),
5046                    ]),
5047                    base_version: None,
5048                }])
5049                .unwrap();
5050            client.drain_change_batches();
5051            let revision = client.local_revision();
5052            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5053            let trigger = match failure {
5054                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5055                "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"),
5056                _ => "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(),
5057            };
5058            client.conn.execute_batch(&trigger).unwrap();
5059            let change = ssp2::model::Change {
5060                table_index: 0,
5061                row_id: "t1".into(),
5062                op: Op::Upsert,
5063                row_version: Some(2),
5064                scopes: vec![],
5065                row: Some(
5066                    encode_row_json(
5067                        client.schema.table("tasks").unwrap(),
5068                        "t1",
5069                        &Map::from_iter([
5070                            ("id".into(), Value::from("t1")),
5071                            ("project_id".into(), Value::from("remote")),
5072                        ]),
5073                        &client.encryption,
5074                    )
5075                    .unwrap(),
5076                ),
5077            };
5078            assert!(client
5079                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5080                .is_err());
5081            assert!(client.conn.is_autocommit());
5082            assert!(!client.overlay_dirty.get());
5083            assert_eq!(client.local_revision(), revision);
5084            assert!(client.drain_change_batches().is_empty());
5085            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5086            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5087            assert!(client
5088                .query("SELECT * FROM _syncular_base_tasks", &[])
5089                .unwrap()
5090                .is_empty());
5091            client
5092                .conn
5093                .execute_batch("DROP TRIGGER fail_pending")
5094                .unwrap();
5095            client
5096                .apply_commit_frame(&["tasks".into()], &[change], None)
5097                .unwrap();
5098            assert_eq!(client.local_revision(), revision + 1);
5099            assert_eq!(client.pending_commit_ids(), vec![id]);
5100            // The overlay keeps the pending write visible; the row now carries
5101            // the server row's version it is layered on (§7.1).
5102            let mut expected = before[0].clone();
5103            expected.insert("_syncular_version".to_owned(), Value::from(2));
5104            assert_eq!(
5105                client.query("SELECT * FROM tasks", &[]).unwrap(),
5106                vec![expected]
5107            );
5108            assert_eq!(
5109                client
5110                    .conn
5111                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5112                        .get::<_, String>(0))
5113                    .unwrap(),
5114                "remote"
5115            );
5116        }
5117    }
5118
5119    #[test]
5120    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5121        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5122            "columns": [
5123                {"name":"id", "type":"string", "nullable":false},
5124                {"name":"project_id", "type":"string", "nullable":false},
5125                {"name":"title", "type":"string", "nullable":false}],
5126            "scopes": [{"pattern":"project:{project_id}"}],
5127            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5128            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5129        let mut incremental =
5130            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5131        let mut reference =
5132            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5133        for instance in [&mut incremental, &mut reference] {
5134            for index in 0..16 {
5135                instance
5136                    .write_base_row(
5137                        "tasks",
5138                        &vec![
5139                            Some(ColumnValue::String(format!("task{index}"))),
5140                            Some(ColumnValue::String("p0".to_owned())),
5141                            Some(ColumnValue::String(format!("base{index}"))),
5142                        ],
5143                        1,
5144                    )
5145                    .unwrap();
5146            }
5147            instance
5148                .rebuild_overlay_if_dirty()
5149                .expect("rebuild overlay");
5150            instance.overlay_rebuild_count.set(0);
5151        }
5152        for index in 0..100 {
5153            let mutations = (0..1 + index % 4)
5154                .map(|offset| {
5155                    let id = format!("task{}", (index + offset) % 24);
5156                    if (index + offset) % 7 == 0 {
5157                        Mutation::Delete {
5158                            table: "tasks".to_owned(),
5159                            row_id: id,
5160                            base_version: None,
5161                        }
5162                    } else {
5163                        Mutation::Upsert {
5164                            table: "tasks".to_owned(),
5165                            values: Map::from_iter([
5166                                ("id".to_owned(), Value::from(id)),
5167                                (
5168                                    "project_id".to_owned(),
5169                                    Value::from(format!("p{}", index % 2)),
5170                                ),
5171                                (
5172                                    "title".to_owned(),
5173                                    Value::from(format!("needle{}", (index + offset) % 24)),
5174                                ),
5175                            ]),
5176                            base_version: None,
5177                        }
5178                    }
5179                })
5180                .collect::<Vec<_>>();
5181            reference.overlay_dirty.set(true);
5182            incremental.mutate(mutations.clone()).unwrap();
5183            reference.mutate(mutations).unwrap();
5184            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5185                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5186                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5187            }
5188            assert_eq!(incremental.local_revision(), reference.local_revision());
5189            assert_eq!(
5190                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5191                serde_json::to_value(reference.drain_change_batches()).unwrap()
5192            );
5193        }
5194        assert_eq!(incremental.outbox.len(), 100);
5195        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5196        assert_eq!(reference.overlay_rebuild_count.get(), 100);
5197    }
5198
5199    #[test]
5200    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5201        for fail_at in ["outbox", "revision"] {
5202            let mut client = client();
5203            let values = Map::from_iter([
5204                ("id".to_owned(), Value::from("t1")),
5205                ("project_id".to_owned(), Value::from("p1")),
5206            ]);
5207            let mutation = Mutation::Upsert {
5208                table: "tasks".to_owned(),
5209                values,
5210                base_version: None,
5211            };
5212            let trigger = if fail_at == "outbox" {
5213                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5214            } else {
5215                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5216            };
5217            client.conn.execute_batch(&trigger).unwrap();
5218            let revision = client.local_revision();
5219            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5220            assert_eq!(client.local_revision(), revision);
5221            assert!(client.outbox.is_empty());
5222            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5223            assert_eq!(
5224                client
5225                    .conn
5226                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5227                        .get::<_, i64>(0))
5228                    .unwrap(),
5229                0
5230            );
5231            assert!(client.drain_change_batches().is_empty());
5232            assert!(!client.overlay_dirty.get());
5233            client
5234                .conn
5235                .execute_batch("DROP TRIGGER fail_append")
5236                .unwrap();
5237            client.mutate(vec![mutation]).unwrap();
5238            assert_eq!(client.outbox.len(), 1);
5239            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5240        }
5241    }
5242
5243    #[test]
5244    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5245        let mut client = client();
5246        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5247        let values = Map::from_iter([
5248            ("id".to_owned(), Value::from("t1")),
5249            ("project_id".to_owned(), Value::from("p1")),
5250        ]);
5251        let payload = encode_row_json(
5252            client.schema.table("tasks").unwrap(),
5253            "t1",
5254            &values,
5255            &client.encryption,
5256        )
5257        .unwrap();
5258        let first = ssp2::model::Change {
5259            table_index: 0,
5260            row_id: "t1".to_owned(),
5261            op: Op::Upsert,
5262            row_version: Some(1),
5263            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5264            row: Some(payload),
5265        };
5266        let mut invalid = first.clone();
5267        invalid.table_index = 1;
5268        let revision = client.local_revision();
5269        client.begin_observation("failed_mirror").unwrap();
5270        assert!(client
5271            .apply_commit_changes(&tables, &[first, invalid])
5272            .is_err());
5273        client.rollback_observation("failed_mirror");
5274        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5275        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5276        assert_eq!(
5277            client
5278                .conn
5279                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5280                    .get::<_, i64>(
5281                    0
5282                ))
5283                .unwrap(),
5284            0
5285        );
5286        assert_eq!(client.local_revision(), revision);
5287        assert!(client.drain_change_batches().is_empty());
5288    }
5289
5290    #[test]
5291    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5292        for failure in ["journal", "revision", "commit", "later-frame"] {
5293            let mut client = client();
5294            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5295            for id in ["first", "second"] {
5296                client
5297                    .mutate(vec![Mutation::Upsert {
5298                        table: "tasks".to_owned(),
5299                        values: Map::from_iter([
5300                            ("id".to_owned(), Value::from(id)),
5301                            ("project_id".to_owned(), Value::from("p1")),
5302                        ]),
5303                        base_version: None,
5304                    }])
5305                    .unwrap();
5306            }
5307            let (_, meta) = client.build_request(false).unwrap();
5308            // A malicious or stale response must not drain an unsent local commit.
5309            client
5310                .mutate(vec![Mutation::Upsert {
5311                    table: "tasks".to_owned(),
5312                    values: Map::from_iter([
5313                        ("id".to_owned(), Value::from("later")),
5314                        ("project_id".to_owned(), Value::from("p1")),
5315                    ]),
5316                    base_version: None,
5317                }])
5318                .unwrap();
5319            let ids = client.pending_commit_ids();
5320            let rows = client
5321                .query("SELECT * FROM tasks ORDER BY id", &[])
5322                .unwrap();
5323            let revision = client.local_revision();
5324            client.drain_change_batches();
5325            if failure != "later-frame" {
5326                client.conn.execute_batch(match failure {
5327                    "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",
5328                    "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",
5329                    _ => "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",
5330                }).unwrap();
5331            }
5332            let mut response = Message {
5333                wire_version: WIRE_VERSION,
5334                msg_kind: MsgKind::Response,
5335                frames: vec![Frame::RespHeader {
5336                    required_schema_version: None,
5337                    latest_schema_version: None,
5338                    log_epoch: Some("epoch-1".to_owned()),
5339                    reset_required: Some(false),
5340                }],
5341            };
5342            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5343                response.frames.push(Frame::PushResult {
5344                    client_commit_id: id.to_owned(),
5345                    status: PushStatus::Applied,
5346                    commit_seq: Some(1),
5347                    results: vec![OpResult::Applied { op_index: 0 }],
5348                });
5349            }
5350            if failure == "later-frame" {
5351                response.frames.push(Frame::Error {
5352                    code: "sync.invalid_request".to_owned(),
5353                    message: "later error".to_owned(),
5354                    category: "protocol".to_owned(),
5355                    retryable: false,
5356                    recommended_action: "retry".to_owned(),
5357                    details: None,
5358                });
5359            }
5360            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5361            let outcome = client.process_response(&mut transport, response.clone(), &meta);
5362            // §1.4 rule 4: the deferred foreign key of "commit" fails the
5363            // response's coalesced COMMIT instead of the run's RELEASE.
5364            let expected = match failure {
5365                "later-frame" => "sync.invalid_request",
5366                "commit" => "storage.failed",
5367                _ => "client.outcome_persistence_failed",
5368            };
5369            assert!(
5370                matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5371                "{failure}: {outcome:?}"
5372            );
5373            if failure == "later-frame" {
5374                assert_eq!(client.pending_commit_ids(), ids[2..]);
5375                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5376                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5377                let batches = client.drain_change_batches();
5378                assert_eq!(batches.len(), 1);
5379                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5380                assert_eq!(client.local_revision(), revision + 1);
5381                response.frames.pop();
5382            } else {
5383                assert_eq!(client.pending_commit_ids(), ids);
5384                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5385                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5386                assert_eq!(client.local_revision(), revision);
5387                assert!(client.drain_change_batches().is_empty());
5388                assert_eq!(
5389                    client
5390                        .query("SELECT * FROM tasks ORDER BY id", &[])
5391                        .unwrap(),
5392                    rows
5393                );
5394                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5395            }
5396            for frame in &mut response.frames {
5397                if let Frame::PushResult { status, .. } = frame {
5398                    *status = PushStatus::Cached;
5399                }
5400            }
5401            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5402            else {
5403                panic!("retry failed");
5404            };
5405            assert_eq!(
5406                report.applied,
5407                if failure == "later-frame" {
5408                    vec![]
5409                } else {
5410                    ids[..2].to_vec()
5411                }
5412            );
5413            assert_eq!(client.pending_commit_ids(), ids[2..]);
5414            let batches = client.drain_change_batches();
5415            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5416            if let Some(batch) = batches.first() {
5417                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5418            }
5419            assert_eq!(client.local_revision(), revision + 1);
5420        }
5421    }
5422
5423    #[test]
5424    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5425        for failure in ["revision", "journal", "commit"] {
5426            for status in ["applied", "cached", "rejected", "conflict"] {
5427                let mut client = client();
5428                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5429                for id in ["first", "later"] {
5430                    client
5431                        .mutate(vec![Mutation::Upsert {
5432                            table: "tasks".to_owned(),
5433                            values: Map::from_iter([
5434                                ("id".to_owned(), Value::from(id)),
5435                                ("project_id".to_owned(), Value::from("p1")),
5436                            ]),
5437                            base_version: None,
5438                        }])
5439                        .unwrap();
5440                }
5441                let (_, meta) = client.build_request(false).unwrap();
5442                let ids = client.pending_commit_ids();
5443                let rows = client
5444                    .query("SELECT * FROM tasks ORDER BY id", &[])
5445                    .unwrap();
5446                let revision = client.local_revision();
5447                client.drain_change_batches();
5448                let (push_status, results) = match status {
5449                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5450                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5451                    "conflict" => (
5452                        PushStatus::Rejected,
5453                        vec![OpResult::Conflict {
5454                            op_index: 0,
5455                            code: "sync.version_conflict".to_owned(),
5456                            message: "conflict".to_owned(),
5457                            server_version: 1,
5458                            server_row: encode_row_json(
5459                                client.schema.table("tasks").unwrap(),
5460                                "first",
5461                                &Map::from_iter([
5462                                    ("id".to_owned(), Value::from("first")),
5463                                    ("project_id".to_owned(), Value::from("p1")),
5464                                ]),
5465                                &client.encryption,
5466                            )
5467                            .unwrap(),
5468                            conflict_columns: Vec::new(),
5469                        }],
5470                    ),
5471                    _ => (
5472                        PushStatus::Rejected,
5473                        vec![OpResult::Error {
5474                            op_index: 0,
5475                            code: "sync.validation_failed".to_owned(),
5476                            message: "rejected".to_owned(),
5477                            retryable: false,
5478                        }],
5479                    ),
5480                };
5481                let response = Message {
5482                    wire_version: WIRE_VERSION,
5483                    msg_kind: MsgKind::Response,
5484                    frames: vec![
5485                        Frame::RespHeader {
5486                            required_schema_version: None,
5487                            latest_schema_version: None,
5488                            log_epoch: Some("epoch-1".to_owned()),
5489                            reset_required: Some(false),
5490                        },
5491                        Frame::PushResult {
5492                            client_commit_id: ids[0].clone(),
5493                            status: push_status,
5494                            commit_seq: Some(1),
5495                            results,
5496                        },
5497                    ],
5498                };
5499                client.conn.execute_batch(match failure {
5500                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5501                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5502                    _ => "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",
5503                }).unwrap();
5504                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5505                let outcome = client.process_response(&mut transport, response.clone(), &meta);
5506                assert_eq!(
5507                    client.pending_commit_ids(),
5508                    ids,
5509                    "{failure}/{status}: pending identity"
5510                );
5511                assert_eq!(
5512                    client.local_revision(),
5513                    revision,
5514                    "{failure}/{status}: revision"
5515                );
5516                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5517                assert!(client.conflicts().is_empty());
5518                assert!(client.rejections().is_empty());
5519                assert!(client.drain_change_batches().is_empty());
5520                assert_eq!(
5521                    client
5522                        .query("SELECT * FROM tasks ORDER BY id", &[])
5523                        .unwrap(),
5524                    rows
5525                );
5526                // §1.4 rule 4: the deferred foreign key of "commit" fails the
5527                // response's coalesced COMMIT instead of the result's RELEASE.
5528                let expected = if failure == "commit" {
5529                    "storage.failed"
5530                } else {
5531                    "client.outcome_persistence_failed"
5532                };
5533                assert!(
5534                    matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5535                    "{failure}/{status}: failed round"
5536                );
5537                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5538                assert!(matches!(
5539                    client.process_response(&mut transport, response, &meta),
5540                    SyncOutcome::Ok(_)
5541                ));
5542                assert_eq!(client.pending_commit_ids(), ids[1..]);
5543                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5544                assert_eq!(client.drain_change_batches().len(), 1);
5545            }
5546        }
5547    }
5548
5549    #[test]
5550    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5551        let mut client = client();
5552        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5553        for index in 0..4 {
5554            client
5555                .mutate(vec![Mutation::Upsert {
5556                    table: "tasks".to_owned(),
5557                    values: Map::from_iter([
5558                        ("id".to_owned(), Value::from(format!("task-{index}"))),
5559                        ("project_id".to_owned(), Value::from("project-1")),
5560                    ]),
5561                    base_version: None,
5562                }])
5563                .expect("queue commit");
5564        }
5565
5566        let (_, request_meta) = client.build_request(false).unwrap();
5567        let ids = &request_meta.pushed_ids;
5568        assert_eq!(ids.len(), 4);
5569        let response = Message {
5570            wire_version: WIRE_VERSION,
5571            msg_kind: MsgKind::Response,
5572            frames: vec![
5573                Frame::RespHeader {
5574                    required_schema_version: None,
5575                    latest_schema_version: None,
5576                    log_epoch: Some("epoch-1".to_owned()),
5577                    reset_required: Some(false),
5578                },
5579                Frame::PushResult {
5580                    client_commit_id: ids[0].clone(),
5581                    status: PushStatus::Applied,
5582                    commit_seq: Some(1),
5583                    results: vec![OpResult::Applied { op_index: 0 }],
5584                },
5585                Frame::PushResult {
5586                    client_commit_id: ids[1].clone(),
5587                    status: PushStatus::Cached,
5588                    commit_seq: Some(2),
5589                    results: vec![OpResult::Applied { op_index: 0 }],
5590                },
5591                Frame::PushResult {
5592                    client_commit_id: ids[2].clone(),
5593                    status: PushStatus::Rejected,
5594                    commit_seq: None,
5595                    results: vec![OpResult::Error {
5596                        op_index: 0,
5597                        code: "sync.validation_failed".to_owned(),
5598                        message: "rejected".to_owned(),
5599                        retryable: false,
5600                    }],
5601                },
5602                Frame::PushResult {
5603                    client_commit_id: ids[3].clone(),
5604                    status: PushStatus::Rejected,
5605                    commit_seq: None,
5606                    results: vec![OpResult::Error {
5607                        op_index: 0,
5608                        code: "sync.idempotency_cache_miss".to_owned(),
5609                        message: "retry".to_owned(),
5610                        retryable: true,
5611                    }],
5612                },
5613            ],
5614        };
5615        let mut transport =
5616            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5617
5618        client.overlay_rebuild_count.set(0);
5619        client.outcome_prune_count.set(0);
5620        let outcome = client.process_response(&mut transport, response, &request_meta);
5621        let SyncOutcome::Ok(report) = outcome else {
5622            panic!("mixed push-result response failed");
5623        };
5624
5625        assert_eq!(report.applied, ids[..2]);
5626        assert_eq!(report.rejected, ids[2..3]);
5627        assert_eq!(report.retryable, ids[3..4]);
5628        assert_eq!(client.pending_commit_ids(), ids[3..4]);
5629        assert_eq!(
5630            client
5631                .query("SELECT id FROM tasks ORDER BY id", &[])
5632                .expect("query visible overlay"),
5633            ["task-0", "task-1", "task-3"]
5634                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5635                .to_vec()
5636        );
5637        // The successful ACK run and final rejection have distinct durable boundaries.
5638        assert_eq!(client.overlay_rebuild_count.get(), 2);
5639        assert_eq!(client.outcome_prune_count.get(), 1);
5640    }
5641
5642    fn replay_schema(fts: bool) -> Value {
5643        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5644            {"name":"id","type":"string","nullable":false},
5645            {"name":"project_id","type":"string","nullable":false},
5646            {"name":"title","type":"string","nullable":false},
5647            {"name":"done","type":"boolean","nullable":false}
5648        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5649    }
5650
5651    #[test]
5652    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5653        for schema_reset in [false, true] {
5654            let mut schema = replay_schema(false);
5655            let mut client =
5656                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5657            let id = client
5658                .mutate(vec![Mutation::Upsert {
5659                    table: "tasks".into(),
5660                    values: Map::from_iter([
5661                        ("id".into(), json!("queued")),
5662                        ("project_id".into(), json!("p1")),
5663                        ("title".into(), json!("old")),
5664                        ("done".into(), json!(false)),
5665                    ]),
5666                    base_version: None,
5667                }])
5668                .unwrap();
5669            client.set_meta(LOG_EPOCH_KEY, "old");
5670            let revision = client.local_revision();
5671            let needed = client.sync_needed;
5672            client.drain_change_batches();
5673            client.drain_sync_intents();
5674            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();
5675            if schema_reset {
5676                schema["version"] = json!(2);
5677                schema["tables"][0]["columns"]
5678                    .as_array_mut()
5679                    .unwrap()
5680                    .retain(|column| column["name"] != "title");
5681                client.schema = parse_schema_json(&schema).unwrap();
5682                assert!(client.run_schema_reset().is_err());
5683            } else {
5684                assert!(client.run_log_epoch_reset("new").is_err());
5685            }
5686            assert!(!client.upgrading);
5687            assert_eq!(client.sync_needed, needed);
5688            assert_eq!(client.local_revision(), revision);
5689            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5690            assert!(client.rejections.is_empty());
5691            assert!(client.commit_outcome(&id).unwrap().is_none());
5692            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5693            assert_eq!(
5694                client
5695                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
5696                    .unwrap()[0]["title"],
5697                "old"
5698            );
5699            assert_eq!(
5700                client
5701                    .conn
5702                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5703                        .get::<_, i64>(0))
5704                    .unwrap(),
5705                1
5706            );
5707            assert!(client.drain_change_batches().is_empty());
5708            assert!(client.drain_sync_intents().is_empty());
5709            assert!(client.conn.is_autocommit());
5710        }
5711    }
5712
5713    #[test]
5714    fn cached_blob_cleanup_propagates_delete_failures() {
5715        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5716            {"name":"id","type":"string","nullable":false},
5717            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5718        let mut client =
5719            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5720        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5721        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5722        client
5723            .subscribe("attachments".into(), "attachments".into(), vec![], None)
5724            .unwrap();
5725        client.subs[0].effective = Some(vec![]);
5726        client.persist_sub(&client.subs[0]).unwrap();
5727        let revision = client.local_revision();
5728        let (_, meta) = client.build_request(false).unwrap();
5729        let mut transport = CountingRealtimeTransport::default();
5730        let mut report = SyncReport::default();
5731        assert!(client
5732            .process_section(
5733                &mut transport,
5734                "attachments",
5735                SubStatus::Revoked,
5736                "sync.scope_revoked",
5737                vec![],
5738                vec![],
5739                Some((0, None)),
5740                &meta,
5741                &mut report,
5742                &HashSet::new()
5743            )
5744            .is_err());
5745        assert_eq!(client.subs[0].state, SubState::Active);
5746        assert_eq!(client.local_revision(), revision);
5747        assert!(report.revoked.is_empty());
5748        assert!(client.conn.is_autocommit());
5749        client
5750            .conn
5751            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5752            .unwrap();
5753        assert!(client
5754            .process_section(
5755                &mut transport,
5756                "attachments",
5757                SubStatus::Revoked,
5758                "sync.scope_revoked",
5759                vec![],
5760                vec![],
5761                Some((0, None)),
5762                &meta,
5763                &mut report,
5764                &HashSet::new()
5765            )
5766            .is_ok());
5767        assert_eq!(client.subs[0].state, SubState::Revoked);
5768        assert_eq!(
5769            client
5770                .conn
5771                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5772                    .get::<_, i64>(0))
5773                .unwrap(),
5774            0
5775        );
5776    }
5777
5778    #[test]
5779    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5780        let path =
5781            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5782        let schema = replay_schema(false);
5783        let mut client = SyncClient::open_path(
5784            "replay".into(),
5785            &schema,
5786            ClientLimits::default(),
5787            path.to_str().unwrap(),
5788        )
5789        .unwrap();
5790        let ids = ["early", "bad"].map(|id| {
5791            client
5792                .mutate(vec![Mutation::Upsert {
5793                    table: "tasks".into(),
5794                    values: Map::from_iter([
5795                        ("id".into(), json!(id)),
5796                        ("project_id".into(), json!("p1")),
5797                        ("title".into(), json!(id)),
5798                        ("done".into(), json!(false)),
5799                    ]),
5800                    base_version: None,
5801                }])
5802                .unwrap()
5803        });
5804        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();
5805        let revision = client.local_revision();
5806        drop(client);
5807        let error = SyncClient::open_path(
5808            "replay".into(),
5809            &schema,
5810            ClientLimits::default(),
5811            path.to_str().unwrap(),
5812        )
5813        .err()
5814        .expect("reopen must fail");
5815        assert!(error.contains("replay failed"));
5816        let conn = Connection::open(&path).unwrap();
5817        assert_eq!(
5818            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5819                .unwrap(),
5820            0
5821        );
5822        assert_eq!(
5823            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5824                .get::<_, i64>(0))
5825                .unwrap(),
5826            2
5827        );
5828        assert_eq!(
5829            meta_get(&conn, LOCAL_REVISION_KEY),
5830            Some(revision.to_string())
5831        );
5832        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5833        drop(conn);
5834        let reopened = SyncClient::open_path(
5835            "replay".into(),
5836            &schema,
5837            ClientLimits::default(),
5838            path.to_str().unwrap(),
5839        )
5840        .unwrap();
5841        assert_eq!(reopened.pending_commit_ids(), ids);
5842        assert_eq!(
5843            reopened
5844                .query("SELECT id FROM tasks ORDER BY id", &[])
5845                .unwrap()
5846                .len(),
5847            2
5848        );
5849        drop(reopened);
5850        std::fs::remove_file(path).unwrap();
5851    }
5852
5853    #[test]
5854    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5855        let schema = replay_schema(false);
5856        let mut client =
5857            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5858        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5859        client
5860            .write_base_row(
5861                "tasks",
5862                &vec![
5863                    Some(ColumnValue::String("queued".into())),
5864                    Some(ColumnValue::String("p1".into())),
5865                    Some(ColumnValue::String("server".into())),
5866                    Some(ColumnValue::Boolean(false)),
5867                ],
5868                1,
5869            )
5870            .unwrap();
5871        client.rebuild_overlay().unwrap();
5872        let id = client
5873            .patch(
5874                "tasks",
5875                "queued",
5876                Map::from_iter([("title".into(), json!("local"))]),
5877                Some(1),
5878            )
5879            .unwrap();
5880        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5881        let base = client
5882            .query("SELECT * FROM _syncular_base_tasks", &[])
5883            .unwrap();
5884        let revision = client.local_revision();
5885        client.drain_change_batches();
5886        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5887        let remote = vec![
5888            Some(ColumnValue::String("queued".into())),
5889            Some(ColumnValue::String("p1".into())),
5890            Some(ColumnValue::String("remote".into())),
5891            Some(ColumnValue::Boolean(false)),
5892        ];
5893        let bytes = crate::values::encode_row_json(
5894            client.schema.table("tasks").unwrap(),
5895            "queued",
5896            &Map::from_iter(
5897                client
5898                    .schema
5899                    .table("tasks")
5900                    .unwrap()
5901                    .columns
5902                    .iter()
5903                    .zip(&remote)
5904                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5905            ),
5906            &client.encryption,
5907        )
5908        .unwrap();
5909        let change = ssp2::model::Change {
5910            table_index: 0,
5911            op: Op::Upsert,
5912            row_id: "queued".into(),
5913            row_version: Some(2),
5914            scopes: vec![("project_id".into(), "p1".into())],
5915            row: Some(bytes),
5916        };
5917        assert!(client
5918            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5919            .is_err());
5920        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5921        assert_eq!(
5922            client
5923                .query("SELECT * FROM _syncular_base_tasks", &[])
5924                .unwrap(),
5925            base
5926        );
5927        assert_eq!(client.local_revision(), revision);
5928        assert!(client.drain_change_batches().is_empty());
5929        let response = Message {
5930            wire_version: 2,
5931            msg_kind: MsgKind::Response,
5932            frames: vec![
5933                Frame::RespHeader {
5934                    required_schema_version: None,
5935                    latest_schema_version: None,
5936                    log_epoch: Some("epoch-1".into()),
5937                    reset_required: Some(false),
5938                },
5939                Frame::PushResult {
5940                    client_commit_id: id.clone(),
5941                    status: PushStatus::Applied,
5942                    commit_seq: Some(2),
5943                    results: vec![OpResult::Applied { op_index: 0 }],
5944                },
5945            ],
5946        };
5947        let (_, meta) = client.build_request(false).unwrap();
5948        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5949        assert!(
5950            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5951        );
5952        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5953        assert!(client.commit_outcome(&id).unwrap().is_none());
5954        assert_eq!(
5955            client
5956                .conn
5957                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5958                    .get::<_, i64>(
5959                    0
5960                ))
5961                .unwrap(),
5962            0
5963        );
5964        assert_eq!(
5965            client
5966                .conn
5967                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5968                    row.get::<_, i64>(0)
5969                })
5970                .unwrap(),
5971            0
5972        );
5973        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5974        assert_eq!(client.local_revision(), revision);
5975        assert!(client.drain_change_batches().is_empty());
5976        client
5977            .conn
5978            .execute_batch("DROP TRIGGER fail_replay")
5979            .unwrap();
5980        assert!(matches!(
5981            client.process_response(&mut transport, response, &meta),
5982            SyncOutcome::Ok(_)
5983        ));
5984        client
5985            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5986            .unwrap();
5987        assert!(client.pending_commit_ids().is_empty());
5988        assert_eq!(
5989            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5990            json!("remote")
5991        );
5992    }
5993
5994    #[test]
5995    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5996        let client = SyncClient::new(
5997            "replay".into(),
5998            &replay_schema(false),
5999            ClientLimits::default(),
6000        )
6001        .unwrap();
6002        let table = client.schema.table("tasks").unwrap();
6003        let patch = OutboxOp {
6004            table: "tasks".into(),
6005            row_id: "absent".into(),
6006            upsert: true,
6007            base_version: Some(1),
6008            values: Some(Map::from_iter([
6009                ("id".into(), json!("absent")),
6010                ("title".into(), json!("patch")),
6011            ])),
6012        };
6013        assert!(client.visible_row(table, "absent").unwrap().is_none());
6014        client
6015            .apply_outbox_ops([&patch])
6016            .expect("sparse replay over truly absent row");
6017        client
6018            .conn
6019            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6020            .unwrap();
6021        assert!(client
6022            .visible_row(table, "bad")
6023            .unwrap_err()
6024            .starts_with("sync.local_corrupt:"));
6025        let mut invalid = patch.clone();
6026        invalid.values = Some(Map::from_iter([
6027            ("id".into(), json!("bad-value")),
6028            ("project_id".into(), json!("p1")),
6029            ("title".into(), json!("title")),
6030            ("done".into(), json!({"bad":true})),
6031        ]));
6032        assert!(client
6033            .apply_outbox_ops([&invalid])
6034            .unwrap_err()
6035            .starts_with("sync.local_corrupt:"));
6036        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6037        assert!(
6038            client.apply_outbox_ops([&patch]).is_err(),
6039            "SQL failure must not count as absence"
6040        );
6041    }
6042
6043    #[test]
6044    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6045        let mut schema = replay_schema(false);
6046        schema["tables"][0]["indexes"] =
6047            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6048        let mut client =
6049            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6050        let id = client
6051            .mutate(vec![Mutation::Upsert {
6052                table: "tasks".into(),
6053                values: Map::from_iter([
6054                    ("id".into(), json!("queued")),
6055                    ("project_id".into(), json!("p1")),
6056                    ("title".into(), json!("occupied")),
6057                    ("done".into(), json!(false)),
6058                ]),
6059                base_version: None,
6060            }])
6061            .unwrap();
6062        client
6063            .write_base_row(
6064                "tasks",
6065                &vec![
6066                    Some(ColumnValue::String("server".into())),
6067                    Some(ColumnValue::String("p1".into())),
6068                    Some(ColumnValue::String("occupied".into())),
6069                    Some(ColumnValue::Boolean(false)),
6070                ],
6071                1,
6072            )
6073            .unwrap();
6074        client.rebuild_overlay_if_dirty().unwrap();
6075        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6076        assert!(client
6077            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6078            .unwrap()
6079            .is_none());
6080        assert!(client.rejections().is_empty());
6081        client.delete_base_row("tasks", "server").unwrap();
6082        client.rebuild_overlay_if_dirty().unwrap();
6083        assert!(client
6084            .visible_row(client.schema.table("tasks").unwrap(), "queued")
6085            .unwrap()
6086            .is_some());
6087        client
6088            .write_base_row(
6089                "tasks",
6090                &vec![
6091                    Some(ColumnValue::String("base".into())),
6092                    Some(ColumnValue::String("p1".into())),
6093                    Some(ColumnValue::String("base-title".into())),
6094                    Some(ColumnValue::Boolean(false)),
6095                ],
6096                1,
6097            )
6098            .unwrap();
6099        client.conn.execute_batch("DROP TABLE tasks").unwrap();
6100        client
6101            .retain_failed_commit(
6102                "retained",
6103                &[OutboxOp {
6104                    table: "tasks".into(),
6105                    row_id: "base".into(),
6106                    upsert: true,
6107                    base_version: Some(1),
6108                    values: Some(Map::from_iter([
6109                        ("id".into(), json!("base")),
6110                        ("title".into(), json!("patch")),
6111                    ])),
6112                }],
6113            )
6114            .expect("an existing base must not read the unavailable visible table");
6115    }
6116
6117    #[test]
6118    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6119        for failure in ["fts", "release"] {
6120            let mut client = SyncClient::new(
6121                "replay".into(),
6122                &replay_schema(true),
6123                ClientLimits::default(),
6124            )
6125            .unwrap();
6126            client
6127                .mutate(vec![Mutation::Upsert {
6128                    table: "tasks".into(),
6129                    values: Map::from_iter([
6130                        ("id".into(), json!("queued")),
6131                        ("project_id".into(), json!("p1")),
6132                        ("title".into(), json!("needle")),
6133                        ("done".into(), json!(false)),
6134                    ]),
6135                    base_version: None,
6136                }])
6137                .unwrap();
6138            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6139            let fts_before = client
6140                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6141                .unwrap();
6142            let triggers_before: Vec<String> = client
6143                .conn
6144                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6145                .unwrap()
6146                .query_map([], |row| row.get(0))
6147                .unwrap()
6148                .collect::<Result<_, _>>()
6149                .unwrap();
6150            if failure == "fts" {
6151                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6152            } else {
6153                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();
6154            }
6155            client.overlay_dirty.set(true);
6156            assert!(client.rebuild_overlay().is_err());
6157            assert!(client.overlay_dirty.get());
6158            assert!(client.conn.is_autocommit());
6159            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6160            assert_eq!(
6161                client
6162                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6163                    .unwrap(),
6164                fts_before
6165            );
6166            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();
6167            assert_eq!(original_triggers, triggers_before);
6168            assert_eq!(client.pending_commit_ids().len(), 1);
6169            client
6170                .conn
6171                .execute_batch(if failure == "fts" {
6172                    "DROP TRIGGER fail_fts"
6173                } else {
6174                    "DROP TRIGGER fail_release"
6175                })
6176                .unwrap();
6177            client
6178                .rebuild_overlay()
6179                .expect("retry after removing the fault");
6180        }
6181    }
6182
6183    #[test]
6184    fn secondary_unique_collision_preserves_existing_synced_row() {
6185        let client = SyncClient::new(
6186            "unique-upsert-test".to_owned(),
6187            &json!({
6188                "version": 1,
6189                "tables": [{
6190                    "name": "tasks",
6191                    "primaryKey": "id",
6192                    "columns": [
6193                        { "name": "id", "type": "string", "nullable": false },
6194                        { "name": "project_id", "type": "string", "nullable": false },
6195                        { "name": "title", "type": "string", "nullable": false }
6196                    ],
6197                    "scopes": [{ "pattern": "project:{project_id}" }],
6198                    "indexes": [{
6199                        "name": "tasks_by_project_title",
6200                        "columns": ["project_id", "title"],
6201                        "unique": true
6202                    }]
6203                }]
6204            }),
6205            ClientLimits::default(),
6206        )
6207        .expect("test client");
6208        let table = client.schema.table("tasks").expect("tasks table");
6209        let sql = client.insert_row_sql(&base_table("tasks"), table);
6210
6211        client
6212            .conn
6213            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6214            .expect("insert first row");
6215        client
6216            .conn
6217            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6218            .expect("update same primary key");
6219        client
6220            .conn
6221            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6222            .expect("insert second row");
6223        assert!(client
6224            .conn
6225            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6226            .is_err());
6227
6228        let rows = client
6229            .conn
6230            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6231            .expect("prepare rows")
6232            .query_map([], |row| {
6233                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6234            })
6235            .expect("query rows")
6236            .collect::<Result<Vec<_>, _>>()
6237            .expect("collect rows");
6238        assert_eq!(
6239            rows,
6240            vec![
6241                ("t1".to_owned(), "updated".to_owned()),
6242                ("t2".to_owned(), "original".to_owned())
6243            ]
6244        );
6245    }
6246
6247    #[test]
6248    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6249        let path = std::env::temp_dir().join(format!(
6250            "syncular-startup-intent-{}.db",
6251            uuid::Uuid::new_v4()
6252        ));
6253        let schema = json!({
6254            "version": 1,
6255            "tables": [{
6256                "name": "tasks",
6257                "primaryKey": "id",
6258                "columns": [
6259                    { "name": "id", "type": "string", "nullable": false },
6260                    { "name": "project_id", "type": "string", "nullable": false }
6261                ],
6262                "scopes": [{ "pattern": "project:{project_id}" }]
6263            }]
6264        });
6265
6266        {
6267            let mut first = SyncClient::open_path_with_identity(
6268                None,
6269                &schema,
6270                ClientLimits::default(),
6271                path.to_str().expect("UTF-8 temp path"),
6272            )
6273            .expect("first open");
6274            first
6275                .set_window(
6276                    &WindowBase {
6277                        table: "tasks".to_owned(),
6278                        variable: "project_id".to_owned(),
6279                        fixed_scopes: Vec::new(),
6280                        params: None,
6281                    },
6282                    &["persisted".to_owned()],
6283                )
6284                .expect("persist window");
6285        }
6286
6287        let mut reopened = SyncClient::open_path_with_identity(
6288            None,
6289            &schema,
6290            ClientLimits::default(),
6291            path.to_str().expect("UTF-8 temp path"),
6292        )
6293        .expect("reopen");
6294        assert!(reopened.sync_needed());
6295        assert!(matches!(
6296            reopened.drain_sync_intents().as_slice(),
6297            [SyncIntent::Interactive]
6298        ));
6299        drop(reopened);
6300        std::fs::remove_file(path).expect("remove temp database");
6301    }
6302
6303    #[test]
6304    fn reopening_preserves_immutable_subscription_identity_and_progress() {
6305        let path = std::env::temp_dir().join(format!(
6306            "syncular-subscription-identity-{}.db",
6307            uuid::Uuid::new_v4()
6308        ));
6309        let schema = json!({
6310            "version": 1,
6311            "tables": [
6312                {
6313                    "name": "tasks",
6314                    "primaryKey": "id",
6315                    "columns": [
6316                        { "name": "id", "type": "string", "nullable": false },
6317                        { "name": "project_id", "type": "string", "nullable": false }
6318                    ],
6319                    "scopes": [{ "pattern": "project:{project_id}" }]
6320                },
6321                {
6322                    "name": "docs",
6323                    "primaryKey": "id",
6324                    "columns": [
6325                        { "name": "id", "type": "string", "nullable": false },
6326                        { "name": "org_id", "type": "string", "nullable": false },
6327                        { "name": "project_id", "type": "string", "nullable": false }
6328                    ],
6329                    "scopes": [
6330                        { "pattern": "org:{org_id}" },
6331                        { "pattern": "project:{projectId}", "column": "project_id" }
6332                    ]
6333                }
6334            ]
6335        });
6336
6337        {
6338            let mut first = SyncClient::open_path_with_identity(
6339                None,
6340                &schema,
6341                ClientLimits::default(),
6342                path.to_str().expect("UTF-8 temp path"),
6343            )
6344            .expect("first open");
6345            first
6346                .subscribe(
6347                    "stable-subscription".to_owned(),
6348                    "tasks".to_owned(),
6349                    vec![(
6350                        "project_id".to_owned(),
6351                        vec!["p2".to_owned(), "p1".to_owned()],
6352                    )],
6353                    Some(r#"{"view":"v1"}"#.to_owned()),
6354                )
6355                .expect("persist subscription");
6356            let persisted = {
6357                let subscription = first
6358                    .subs
6359                    .iter_mut()
6360                    .find(|subscription| subscription.id == "stable-subscription")
6361                    .expect("subscription");
6362                subscription.cursor = 41;
6363                subscription.bootstrap_state = Some("resume-token".to_owned());
6364                subscription.effective = Some(vec![(
6365                    "project_id".to_owned(),
6366                    vec!["p1".to_owned(), "p2".to_owned()],
6367                )]);
6368                subscription.synced_once = true;
6369                subscription.clone()
6370            };
6371            first.persist_sub(&persisted).unwrap();
6372        }
6373
6374        let mut reopened = SyncClient::open_path_with_identity(
6375            None,
6376            &schema,
6377            ClientLimits::default(),
6378            path.to_str().expect("UTF-8 temp path"),
6379        )
6380        .expect("reopen");
6381        let progress = reopened
6382            .subscription_state("stable-subscription")
6383            .expect("persisted state");
6384        assert_eq!(progress.cursor, 41);
6385        assert!(progress.has_resume_token);
6386
6387        reopened
6388            .subscribe(
6389                "stable-subscription".to_owned(),
6390                "tasks".to_owned(),
6391                vec![(
6392                    "project_id".to_owned(),
6393                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6394                )],
6395                Some(r#"{"view":"v1"}"#.to_owned()),
6396            )
6397            .expect("canonical intent is idempotent");
6398        assert_eq!(
6399            reopened
6400                .subscription_state("stable-subscription")
6401                .expect("unchanged state")
6402                .cursor,
6403            progress.cursor
6404        );
6405
6406        for (table, scopes, params) in [
6407            (
6408                "tasks",
6409                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6410                Some(r#"{"view":"v1"}"#.to_owned()),
6411            ),
6412            (
6413                "tasks",
6414                vec![(
6415                    "project_id".to_owned(),
6416                    vec!["p2".to_owned(), "p1".to_owned()],
6417                )],
6418                Some(r#"{"view":"v2"}"#.to_owned()),
6419            ),
6420            (
6421                "docs",
6422                vec![
6423                    ("org_id".to_owned(), vec!["o1".to_owned()]),
6424                    ("projectId".to_owned(), vec!["p1".to_owned()]),
6425                ],
6426                Some(r#"{"view":"v1"}"#.to_owned()),
6427            ),
6428        ] {
6429            let error = reopened
6430                .subscribe(
6431                    "stable-subscription".to_owned(),
6432                    table.to_owned(),
6433                    scopes,
6434                    params,
6435                )
6436                .expect_err("identity rebind must fail");
6437            assert!(error.starts_with("client.subscription_intent_mismatch:"));
6438            assert_eq!(
6439                reopened
6440                    .subscription_state("stable-subscription")
6441                    .expect("unchanged state")
6442                    .cursor,
6443                progress.cursor
6444            );
6445        }
6446
6447        drop(reopened);
6448        std::fs::remove_file(path).expect("remove temp database");
6449    }
6450
6451    #[test]
6452    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6453        let path = std::env::temp_dir().join(format!(
6454            "syncular-satisfied-schema-floor-{}.db",
6455            uuid::Uuid::new_v4()
6456        ));
6457        let schema = json!({
6458            "version": 23,
6459            "tables": [{
6460                "name": "tasks",
6461                "primaryKey": "id",
6462                "columns": [
6463                    { "name": "id", "type": "string", "nullable": false },
6464                    { "name": "project_id", "type": "string", "nullable": false }
6465                ],
6466                "scopes": [{ "pattern": "project:{project_id}" }]
6467            }]
6468        });
6469
6470        {
6471            let mut first = SyncClient::open_path_with_identity(
6472                None,
6473                &schema,
6474                ClientLimits::default(),
6475                path.to_str().expect("UTF-8 temp path"),
6476            )
6477            .expect("first open");
6478            first
6479                .subscribe(
6480                    "tasks".to_owned(),
6481                    "tasks".to_owned(),
6482                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6483                    None,
6484                )
6485                .expect("persist subscription");
6486            first.set_schema_floor(Some(SchemaFloor {
6487                required_schema_version: Some(22),
6488                latest_schema_version: Some(22),
6489            }));
6490        }
6491
6492        let mut reopened = SyncClient::open_path_with_identity(
6493            None,
6494            &schema,
6495            ClientLimits::default(),
6496            path.to_str().expect("UTF-8 temp path"),
6497        )
6498        .expect("reopen");
6499        assert!(reopened.schema_floor().is_none());
6500        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6501        assert!(reopened.sync_needed());
6502        assert!(matches!(
6503            reopened.drain_sync_intents().as_slice(),
6504            [SyncIntent::Interactive]
6505        ));
6506        drop(reopened);
6507        std::fs::remove_file(path).expect("remove temp database");
6508    }
6509
6510    #[test]
6511    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6512        let path = std::env::temp_dir().join(format!(
6513            "syncular-unsatisfied-schema-floor-{}.db",
6514            uuid::Uuid::new_v4()
6515        ));
6516        let schema = json!({
6517            "version": 1,
6518            "tables": [{
6519                "name": "tasks",
6520                "primaryKey": "id",
6521                "columns": [
6522                    { "name": "id", "type": "string", "nullable": false },
6523                    { "name": "project_id", "type": "string", "nullable": false }
6524                ],
6525                "scopes": [{ "pattern": "project:{project_id}" }]
6526            }]
6527        });
6528
6529        {
6530            let mut first = SyncClient::open_path_with_identity(
6531                None,
6532                &schema,
6533                ClientLimits::default(),
6534                path.to_str().expect("UTF-8 temp path"),
6535            )
6536            .expect("first open");
6537            first
6538                .subscribe(
6539                    "tasks".to_owned(),
6540                    "tasks".to_owned(),
6541                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6542                    None,
6543                )
6544                .expect("persist subscription");
6545            first.set_schema_floor(Some(SchemaFloor {
6546                required_schema_version: Some(2),
6547                latest_schema_version: Some(2),
6548            }));
6549        }
6550
6551        let reopened = SyncClient::open_path_with_identity(
6552            None,
6553            &schema,
6554            ClientLimits::default(),
6555            path.to_str().expect("UTF-8 temp path"),
6556        )
6557        .expect("reopen");
6558        assert_eq!(
6559            reopened.schema_floor(),
6560            Some(&SchemaFloor {
6561                required_schema_version: Some(2),
6562                latest_schema_version: Some(2),
6563            })
6564        );
6565        assert!(!reopened.sync_needed());
6566        drop(reopened);
6567        std::fs::remove_file(path).expect("remove temp database");
6568    }
6569
6570    #[test]
6571    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6572        let path = std::env::temp_dir().join(format!(
6573            "syncular-indexed-column-bump-{}.db",
6574            uuid::Uuid::new_v4()
6575        ));
6576        let old_schema = json!({
6577            "version": 1,
6578            "tables": [
6579                {
6580                    "name": "tasks",
6581                    "primaryKey": "id",
6582                    "columns": [
6583                        { "name": "id", "type": "string", "nullable": false },
6584                        { "name": "project_id", "type": "string", "nullable": false }
6585                    ],
6586                    "scopes": [{ "pattern": "project:{project_id}" }]
6587                },
6588                {
6589                    "name": "legacy",
6590                    "primaryKey": "id",
6591                    "columns": [
6592                        { "name": "id", "type": "string", "nullable": false },
6593                        { "name": "project_id", "type": "string", "nullable": false }
6594                    ],
6595                    "scopes": [{ "pattern": "project:{project_id}" }]
6596                }
6597            ]
6598        });
6599        {
6600            let mut first = SyncClient::open_path_with_identity(
6601                None,
6602                &old_schema,
6603                ClientLimits::default(),
6604                path.to_str().expect("UTF-8 temp path"),
6605            )
6606            .expect("open old schema");
6607            first
6608                .subscribe(
6609                    "legacy-sub".to_owned(),
6610                    "legacy".to_owned(),
6611                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6612                    None,
6613                )
6614                .expect("persist legacy subscription");
6615        }
6616
6617        let new_schema = json!({
6618            "version": 2,
6619            "tables": [{
6620                "name": "tasks",
6621                "primaryKey": "id",
6622                "columns": [
6623                    { "name": "id", "type": "string", "nullable": false },
6624                    { "name": "project_id", "type": "string", "nullable": false },
6625                    { "name": "facility_membership_id", "type": "string", "nullable": true }
6626                ],
6627                "scopes": [{ "pattern": "project:{project_id}" }],
6628                "indexes": [{
6629                    "name": "tasks_by_membership",
6630                    "columns": ["project_id", "facility_membership_id"],
6631                    "unique": false
6632                }]
6633            }]
6634        });
6635        let upgraded = SyncClient::open_path_with_identity(
6636            None,
6637            &new_schema,
6638            ClientLimits::default(),
6639            path.to_str().expect("UTF-8 temp path"),
6640        )
6641        .expect("open upgraded schema");
6642        let columns = upgraded
6643            .conn
6644            .prepare("PRAGMA table_info(tasks)")
6645            .expect("prepare columns")
6646            .query_map([], |row| row.get::<_, String>(1))
6647            .expect("query columns")
6648            .collect::<Result<Vec<_>, _>>()
6649            .expect("collect columns");
6650        assert!(columns.contains(&"facility_membership_id".to_owned()));
6651        let index_count: i64 = upgraded
6652            .conn
6653            .query_row(
6654                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6655                [],
6656                |row| row.get(0),
6657            )
6658            .expect("query index");
6659        assert_eq!(index_count, 1);
6660        assert!(upgraded.subscription_state("legacy-sub").is_none());
6661        drop(upgraded);
6662        std::fs::remove_file(path).expect("remove temp database");
6663    }
6664
6665    #[test]
6666    fn migrates_pre_envelope_outcome_journal_additively() {
6667        let path = std::env::temp_dir().join(format!(
6668            "syncular-outcome-migration-{}.db",
6669            uuid::Uuid::new_v4()
6670        ));
6671        let conn = Connection::open(&path).expect("open legacy database");
6672        conn.execute_batch(
6673            "CREATE TABLE _syncular_commit_outcomes (
6674               seq INTEGER PRIMARY KEY AUTOINCREMENT,
6675               client_commit_id TEXT NOT NULL UNIQUE,
6676               status TEXT NOT NULL,
6677               recorded_at_ms INTEGER NOT NULL,
6678               results_json TEXT NOT NULL,
6679               resolution TEXT NOT NULL DEFAULT 'active',
6680               resolved_at_ms INTEGER,
6681               replacement_client_commit_id TEXT);",
6682        )
6683        .expect("create legacy outcome journal");
6684        drop(conn);
6685        let schema = json!({
6686            "version": 1,
6687            "tables": [{
6688                "name": "tasks",
6689                "primaryKey": "id",
6690                "columns": [
6691                    { "name": "id", "type": "string", "nullable": false },
6692                    { "name": "project_id", "type": "string", "nullable": false }
6693                ],
6694                "scopes": [{ "pattern": "project:{project_id}" }]
6695            }]
6696        });
6697        let client = SyncClient::open_path(
6698            "migration-native".to_owned(),
6699            &schema,
6700            ClientLimits::default(),
6701            path.to_str().expect("UTF-8 temp path"),
6702        )
6703        .expect("migrate database");
6704        let has_operations = client
6705            .conn
6706            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6707            .expect("prepare table info")
6708            .query_map([], |row| row.get::<_, String>(1))
6709            .expect("query table info")
6710            .filter_map(Result::ok)
6711            .any(|column| column == "operations_json");
6712        assert!(has_operations);
6713        drop(client);
6714        std::fs::remove_file(path).expect("remove temp database");
6715    }
6716
6717    #[test]
6718    fn retained_rejection_survives_file_reopen_and_security_purge() {
6719        let path =
6720            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6721        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6722            { "name": "id", "type": "string", "nullable": false },
6723            { "name": "project_id", "type": "string", "nullable": false },
6724            { "name": "title", "type": "string", "nullable": false }
6725        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6726        {
6727            let mut first = SyncClient::open_path(
6728                "retained-native".to_owned(),
6729                &schema,
6730                ClientLimits::default(),
6731                path.to_str().unwrap(),
6732            )
6733            .unwrap();
6734            first
6735                .write_base_row(
6736                    "tasks",
6737                    &vec![
6738                        Some(ColumnValue::String("t1".into())),
6739                        Some(ColumnValue::String("p1".into())),
6740                        Some(ColumnValue::String("server".into())),
6741                    ],
6742                    1,
6743                )
6744                .unwrap();
6745            first.rebuild_overlay().expect("rebuild overlay");
6746            let operation = OutboxOp {
6747                table: "tasks".into(),
6748                row_id: "t1".into(),
6749                upsert: true,
6750                base_version: Some(1),
6751                values: Some(Map::from_iter([
6752                    ("id".into(), json!("t1")),
6753                    ("title".into(), json!("mine")),
6754                ])),
6755            };
6756            first
6757                .retain_failed_commit("retained", std::slice::from_ref(&operation))
6758                .unwrap();
6759            first
6760                .persist_commit_outcome(
6761                    "retained",
6762                    CommitOutcomeStatus::Rejected,
6763                    &[CommitOperationOutcome::Error {
6764                        rejection: RejectionRecord {
6765                            client_commit_id: "retained".into(),
6766                            op_index: 0,
6767                            code: "validation.denied".into(),
6768                            message: "denied".into(),
6769                            retryable: false,
6770                            details: None,
6771                            operation: None,
6772                        },
6773                    }],
6774                    Some(&[operation]),
6775                )
6776                .unwrap();
6777            first.rebuild_overlay().expect("rebuild overlay");
6778        }
6779        {
6780            let mut reopened = SyncClient::open_path(
6781                "retained-native".to_owned(),
6782                &schema,
6783                ClientLimits::default(),
6784                path.to_str().unwrap(),
6785            )
6786            .unwrap();
6787            let row = reopened
6788                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6789                .expect("read visible row")
6790                .unwrap();
6791            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6792            assert_eq!(
6793                reopened
6794                    .commit_outcome("retained")
6795                    .unwrap()
6796                    .unwrap()
6797                    .retained_rows
6798                    .unwrap()[0]
6799                    .server_row
6800                    .as_ref()
6801                    .unwrap()["title"],
6802                json!("server")
6803            );
6804            reopened
6805                .conn
6806                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6807                .unwrap();
6808            reopened.rebuild_overlay().expect("rebuild overlay");
6809            assert!(reopened
6810                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6811                .expect("read visible row")
6812                .is_none());
6813            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6814            assert!(retained.retained_rows.is_some());
6815            assert_eq!(
6816                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6817                json!("mine")
6818            );
6819            reopened
6820                .purge_local_data(&LocalDataPurgeInput {
6821                    purge_id: "protected-purge".into(),
6822                    targets: vec![LocalDataPurgeTarget {
6823                        table: "tasks".into(),
6824                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6825                    }],
6826                })
6827                .unwrap();
6828            assert!(reopened
6829                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6830                .expect("read visible row")
6831                .is_none());
6832            assert!(reopened
6833                .commit_outcome("retained")
6834                .unwrap()
6835                .unwrap()
6836                .retained_rows
6837                .is_none());
6838        }
6839        std::fs::remove_file(path).unwrap();
6840    }
6841
6842    #[test]
6843    fn durable_conflict_outcome_and_resolution_survive_reopen() {
6844        let path = std::env::temp_dir().join(format!(
6845            "syncular-durable-outcome-{}.db",
6846            uuid::Uuid::new_v4()
6847        ));
6848        let schema = json!({
6849            "version": 1,
6850            "tables": [{
6851                "name": "tasks",
6852                "primaryKey": "id",
6853                "columns": [
6854                    { "name": "id", "type": "string", "nullable": false },
6855                    { "name": "project_id", "type": "string", "nullable": false }
6856                ],
6857                "scopes": [{ "pattern": "project:{project_id}" }]
6858            }]
6859        });
6860        let conflict = ConflictRecord {
6861            client_commit_id: "losing-commit".to_owned(),
6862            op_index: 0,
6863            table: "tasks".to_owned(),
6864            row_id: "t1".to_owned(),
6865            code: "sync.version_conflict".to_owned(),
6866            message: "stale base version".to_owned(),
6867            server_version: 2,
6868            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6869            conflict_columns: vec!["project_id".to_owned()],
6870            operation: Some(CommitOperation {
6871                table: "tasks".to_owned(),
6872                row_id: "t1".to_owned(),
6873                op: "upsert".to_owned(),
6874                base_version: Some(1),
6875                values: Some(Map::from_iter([
6876                    ("id".into(), json!("t1")),
6877                    ("project_id".into(), json!("p1")),
6878                ])),
6879            }),
6880        };
6881        let failed_operations = vec![
6882            OutboxOp {
6883                upsert: true,
6884                table: "tasks".to_owned(),
6885                row_id: "t1".to_owned(),
6886                base_version: Some(1),
6887                values: Some(Map::from_iter([
6888                    ("id".into(), json!("t1")),
6889                    ("project_id".into(), json!("p1")),
6890                ])),
6891            },
6892            OutboxOp {
6893                upsert: true,
6894                table: "tasks".to_owned(),
6895                row_id: "status-event-1".to_owned(),
6896                base_version: Some(0),
6897                values: Some(Map::from_iter([
6898                    ("id".into(), json!("status-event-1")),
6899                    ("project_id".into(), json!("p1")),
6900                ])),
6901            },
6902        ];
6903
6904        {
6905            let mut first = SyncClient::open_path(
6906                "durable-native".to_owned(),
6907                &schema,
6908                ClientLimits::default(),
6909                path.to_str().expect("UTF-8 temp path"),
6910            )
6911            .expect("first open");
6912            first
6913                .begin_observation("test_outcome")
6914                .expect("begin outcome");
6915            first
6916                .persist_commit_outcome(
6917                    "losing-commit",
6918                    CommitOutcomeStatus::Conflict,
6919                    &[CommitOperationOutcome::Conflict {
6920                        conflict: conflict.clone(),
6921                    }],
6922                    Some(&failed_operations),
6923                )
6924                .expect("persist outcome");
6925            first.conflicts.push(conflict);
6926            first
6927                .finish_observation(
6928                    "test_outcome",
6929                    ChangeAccumulator {
6930                        conflicts: true,
6931                        outcomes: true,
6932                        ..ChangeAccumulator::default()
6933                    },
6934                )
6935                .expect("commit outcome");
6936        }
6937
6938        {
6939            let mut reopened = SyncClient::open_path(
6940                "durable-native".to_owned(),
6941                &schema,
6942                ClientLimits::default(),
6943                path.to_str().expect("UTF-8 temp path"),
6944            )
6945            .expect("reopen");
6946            assert_eq!(reopened.conflicts().len(), 1);
6947            assert_eq!(
6948                reopened.conflicts()[0].conflict_columns,
6949                vec!["project_id".to_owned()],
6950                "conflictColumns survives the journal round trip"
6951            );
6952            let outcome = reopened
6953                .commit_outcome("losing-commit")
6954                .expect("read outcome")
6955                .expect("outcome");
6956            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6957            let operations = outcome.operations.expect("aggregate envelope");
6958            assert_eq!(operations.len(), 2);
6959            assert_eq!(operations[1].row_id, "status-event-1");
6960            let resolved = reopened
6961                .resolve_commit_outcome(ResolveCommitOutcomeInput {
6962                    client_commit_id: "losing-commit".to_owned(),
6963                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
6964                    replacement_client_commit_id: None,
6965                })
6966                .expect("resolve");
6967            assert_eq!(
6968                resolved.resolution,
6969                CommitOutcomeResolution::ResolvedKeepServer
6970            );
6971            assert!(reopened.conflicts().is_empty());
6972        }
6973
6974        let reopened = SyncClient::open_path(
6975            "durable-native".to_owned(),
6976            &schema,
6977            ClientLimits::default(),
6978            path.to_str().expect("UTF-8 temp path"),
6979        )
6980        .expect("second reopen");
6981        assert!(reopened.conflicts().is_empty());
6982        assert_eq!(
6983            reopened
6984                .commit_outcome("losing-commit")
6985                .expect("read outcome")
6986                .expect("outcome")
6987                .resolution,
6988            CommitOutcomeResolution::ResolvedKeepServer
6989        );
6990        drop(reopened);
6991        std::fs::remove_file(path).expect("remove temp database");
6992    }
6993
6994    #[test]
6995    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6996        let path =
6997            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6998        let schema = json!({
6999            "version": 1,
7000            "tables": [{
7001                "name": "tasks",
7002                "primaryKey": "id",
7003                "columns": [
7004                    { "name": "id", "type": "string", "nullable": false },
7005                    { "name": "project_id", "type": "string", "nullable": false }
7006                ],
7007                "scopes": [{ "pattern": "project:{project_id}" }]
7008            }]
7009        });
7010        let mut client = SyncClient::open_path_with_identity(
7011            Some("sidecar-client".to_owned()),
7012            &schema,
7013            ClientLimits::default(),
7014            path.to_str().expect("UTF-8 temp path"),
7015        )
7016        .expect("open owner");
7017        let base = WindowBase {
7018            table: "tasks".to_owned(),
7019            variable: "project_id".to_owned(),
7020            fixed_scopes: Vec::new(),
7021            params: None,
7022        };
7023        client
7024            .set_window(&base, &["one".to_owned()])
7025            .expect("set window");
7026        client
7027            .mutate(vec![Mutation::Upsert {
7028                table: "tasks".to_owned(),
7029                values: Map::from_iter([
7030                    ("id".to_owned(), Value::from("t1")),
7031                    ("project_id".to_owned(), Value::from("one")),
7032                ]),
7033                base_version: None,
7034            }])
7035            .expect("local mutate");
7036
7037        let coverage = [WindowCoverage {
7038            base,
7039            units: vec!["one".to_owned(), "missing".to_owned()],
7040        }];
7041        let owner = client
7042            .query_snapshot(
7043                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7044                &[],
7045                &coverage,
7046                None,
7047            )
7048            .expect("owner snapshot");
7049        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7050        let sidecar = reader
7051            .query_snapshot(
7052                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7053                &[],
7054                &coverage,
7055            )
7056            .expect("sidecar snapshot");
7057
7058        assert_eq!(sidecar.revision, owner.revision);
7059        assert_eq!(sidecar.rows, owner.rows);
7060        assert_eq!(
7061            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7062            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7063        );
7064        assert_eq!(sidecar.revision, "2");
7065        assert_eq!(sidecar.rows[0]["id"], "t1");
7066        assert_eq!(sidecar.rows[0]["server_version"], -1);
7067        assert!(!sidecar.coverage.complete);
7068        assert_eq!(sidecar.coverage.pending.len(), 1);
7069        assert_eq!(sidecar.coverage.missing.len(), 1);
7070
7071        drop(reader);
7072        drop(client);
7073        std::fs::remove_file(path).expect("remove temp database");
7074    }
7075
7076    #[test]
7077    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7078        let path =
7079            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7080        let schema = json!({
7081            "version": 1,
7082            "tables": [{
7083                "name": "tasks",
7084                "primaryKey": "id",
7085                "columns": [
7086                    { "name": "id", "type": "string", "nullable": false },
7087                    { "name": "project_id", "type": "string", "nullable": false }
7088                ],
7089                "scopes": [{ "pattern": "project:{project_id}" }]
7090            }]
7091        });
7092        let mut client = SyncClient::open_path_with_identity(
7093            Some("sidecar-catchup".to_owned()),
7094            &schema,
7095            ClientLimits::default(),
7096            path.to_str().expect("UTF-8 temp path"),
7097        )
7098        .expect("open owner");
7099        client
7100            .subscribe(
7101                "s1".into(),
7102                "tasks".into(),
7103                vec![("project_id".into(), vec!["one".into()])],
7104                None,
7105            )
7106            .expect("subscribe");
7107        let commit_id = client
7108            .mutate(vec![Mutation::Upsert {
7109                table: "tasks".to_owned(),
7110                values: Map::from_iter([
7111                    ("id".to_owned(), Value::from("t1")),
7112                    ("project_id".to_owned(), Value::from("one")),
7113                ]),
7114                base_version: None,
7115            }])
7116            .expect("mutate");
7117        let revision = client.local_revision().to_string();
7118
7119        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7120        assert_eq!(reader.local_revision().expect("revision"), revision);
7121
7122        let read = reader
7123            .snapshot_read(&SnapshotReadRequest {
7124                statements: vec![
7125                    SnapshotStatement {
7126                        sql: "SELECT 1 AS n",
7127                        params: &[],
7128                    },
7129                    SnapshotStatement {
7130                        sql: "SELECT id FROM tasks ORDER BY id",
7131                        params: &[],
7132                    },
7133                ],
7134                coverage: &[],
7135                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7136                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7137            })
7138            .expect("snapshot read");
7139        assert_eq!(read.revision, revision);
7140        assert_eq!(read.queries.len(), 2);
7141        assert_eq!(read.queries[0][0]["n"], 1);
7142        assert_eq!(read.queries[1][0]["id"], "t1");
7143        match &read.subscriptions[0] {
7144            SubscriptionCatchup::Known {
7145                status,
7146                cursor,
7147                bootstrap_complete,
7148                known_pending_pages,
7149                ..
7150            } => {
7151                assert_eq!(status, "active");
7152                assert_eq!(*cursor, -1);
7153                assert!(!bootstrap_complete);
7154                assert!(known_pending_pages);
7155            }
7156            other => panic!("expected a known subscription: {other:?}"),
7157        }
7158        assert!(matches!(
7159            &read.subscriptions[1],
7160            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7161        ));
7162        assert!(matches!(
7163            &read.deliveries[0],
7164            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7165        ));
7166        assert!(matches!(
7167            &read.deliveries[1],
7168            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7169        ));
7170
7171        // A bad statement rolls the whole read back and releases the connection.
7172        assert!(reader
7173            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7174            .is_err());
7175        assert_eq!(
7176            reader.local_revision().expect("revision after rollback"),
7177            revision
7178        );
7179        // The read-only guard refuses a write on any statement.
7180        assert!(reader
7181            .query_snapshot("DELETE FROM tasks", &[], &[])
7182            .is_err());
7183
7184        // A corrupt revision marker fails typed, not as an invented zero.
7185        client
7186            .conn
7187            .execute(
7188                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7189                [],
7190            )
7191            .expect("corrupt revision");
7192        let failure = reader.local_revision().expect_err("corrupt revision fails");
7193        assert_eq!(failure.code, Some("sync.local_corrupt"));
7194
7195        drop(reader);
7196        drop(client);
7197        std::fs::remove_file(path).expect("remove temp database");
7198    }
7199
7200    #[test]
7201    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7202        let path =
7203            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7204        let schema = json!({
7205            "version": 1,
7206            "tables": [{
7207                "name": "tasks",
7208                "primaryKey": "id",
7209                "columns": [
7210                    { "name": "id", "type": "string", "nullable": false },
7211                    { "name": "project_id", "type": "string", "nullable": false }
7212                ],
7213                "scopes": [{ "pattern": "project:{project_id}" }]
7214            }]
7215        });
7216        let mut client = SyncClient::open_path_with_identity(
7217            Some("sidecar-delivery".to_owned()),
7218            &schema,
7219            ClientLimits::default(),
7220            path.to_str().expect("UTF-8 temp path"),
7221        )
7222        .expect("open owner");
7223        client.begin_observation("test_delivery").expect("observe");
7224        let operations = [OutboxOp {
7225            upsert: true,
7226            table: "tasks".to_owned(),
7227            row_id: "t1".to_owned(),
7228            base_version: Some(1),
7229            values: Some(Map::from_iter([
7230                ("id".into(), json!("t1")),
7231                ("project_id".into(), json!("p1")),
7232            ])),
7233        }];
7234        client
7235            .persist_commit_outcome(
7236                "conflict-commit",
7237                CommitOutcomeStatus::Conflict,
7238                &[CommitOperationOutcome::Conflict {
7239                    conflict: ConflictRecord {
7240                        client_commit_id: "conflict-commit".to_owned(),
7241                        op_index: 0,
7242                        table: "tasks".to_owned(),
7243                        row_id: "t1".to_owned(),
7244                        code: "sync.version_conflict".to_owned(),
7245                        message: "stale base version".to_owned(),
7246                        server_version: 2,
7247                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7248                        conflict_columns: vec!["project_id".to_owned()],
7249                        operation: None,
7250                    },
7251                }],
7252                Some(&operations),
7253            )
7254            .expect("persist conflict");
7255        client
7256            .persist_commit_outcome(
7257                "rejected-commit",
7258                CommitOutcomeStatus::Rejected,
7259                &[CommitOperationOutcome::Error {
7260                    rejection: RejectionRecord {
7261                        client_commit_id: "rejected-commit".to_owned(),
7262                        op_index: 0,
7263                        code: "sync.unknown_table".to_owned(),
7264                        message: "unknown table".to_owned(),
7265                        retryable: false,
7266                        details: None,
7267                        operation: None,
7268                    },
7269                }],
7270                Some(&operations),
7271            )
7272            .expect("persist rejection");
7273        client
7274            .finish_observation(
7275                "test_delivery",
7276                ChangeAccumulator {
7277                    conflicts: true,
7278                    outcomes: true,
7279                    ..ChangeAccumulator::default()
7280                },
7281            )
7282            .expect("commit outcomes");
7283
7284        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7285        match reader.commit_delivery("conflict-commit").expect("conflict") {
7286            CommitDelivery::Known { outcome, .. } => {
7287                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7288                assert!(matches!(
7289                    outcome.results.as_slice(),
7290                    [CommitOperationOutcome::Conflict { conflict }]
7291                        if conflict.code == "sync.version_conflict"
7292                            && conflict.server_version == 2
7293                ));
7294                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7295                assert!(
7296                    outcome.retained_rows.is_none(),
7297                    "the sidecar reports the persisted view, not owner-derived retained rows"
7298                );
7299            }
7300            other => panic!("expected a known conflict: {other:?}"),
7301        }
7302        match reader
7303            .commit_delivery("rejected-commit")
7304            .expect("rejection")
7305        {
7306            CommitDelivery::Known { outcome, .. } => {
7307                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7308                assert!(matches!(
7309                    outcome.results.as_slice(),
7310                    [CommitOperationOutcome::Error { rejection }]
7311                        if rejection.code == "sync.unknown_table"
7312                ));
7313                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7314                assert!(outcome.retained_rows.is_none());
7315            }
7316            other => panic!("expected a known rejection: {other:?}"),
7317        }
7318        drop(reader);
7319        drop(client);
7320        std::fs::remove_file(path).expect("remove temp database");
7321    }
7322
7323    #[test]
7324    fn snapshot_read_isolates_a_concurrent_writer_commit() {
7325        use std::sync::mpsc::channel;
7326        let path = std::env::temp_dir().join(format!(
7327            "syncular-read-concurrent-{}.db",
7328            uuid::Uuid::new_v4()
7329        ));
7330        let schema = json!({
7331            "version": 1,
7332            "tables": [{
7333                "name": "tasks",
7334                "primaryKey": "id",
7335                "columns": [
7336                    { "name": "id", "type": "string", "nullable": false },
7337                    { "name": "project_id", "type": "string", "nullable": false }
7338                ],
7339                "scopes": [{ "pattern": "project:{project_id}" }]
7340            }]
7341        });
7342        let mut client = SyncClient::open_path_with_identity(
7343            Some("sidecar-concurrent".to_owned()),
7344            &schema,
7345            ClientLimits::default(),
7346            path.to_str().expect("UTF-8 temp path"),
7347        )
7348        .expect("open owner");
7349        client
7350            .mutate(vec![Mutation::Upsert {
7351                table: "tasks".to_owned(),
7352                values: Map::from_iter([
7353                    ("id".to_owned(), Value::from("t1")),
7354                    ("project_id".to_owned(), Value::from("one")),
7355                ]),
7356                base_version: None,
7357            }])
7358            .expect("seed row");
7359        let revision_before = client.local_revision().to_string();
7360
7361        // A writer on a second connection commits while the reader is between
7362        // statements; the hook rendezvous is a barrier, not a sleep.
7363        let (ready_tx, ready_rx) = channel::<()>();
7364        let (done_tx, done_rx) = channel::<()>();
7365        let writer_path = path.to_string_lossy().into_owned();
7366        let writer_schema = schema.clone();
7367        let writer = std::thread::spawn(move || {
7368            let mut writer = SyncClient::open_path_with_identity(
7369                Some("sidecar-concurrent".to_owned()),
7370                &writer_schema,
7371                ClientLimits::default(),
7372                &writer_path,
7373            )
7374            .expect("open writer");
7375            ready_rx.recv().expect("reader signalled");
7376            writer
7377                .mutate(vec![Mutation::Upsert {
7378                    table: "tasks".to_owned(),
7379                    values: Map::from_iter([
7380                        ("id".to_owned(), Value::from("t2")),
7381                        ("project_id".to_owned(), Value::from("one")),
7382                    ]),
7383                    base_version: None,
7384                }])
7385                .expect("writer commit");
7386            let _ = done_tx.send(());
7387        });
7388        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7389        SNAPSHOT_READ_HOOK.with(|slot| {
7390            *slot.borrow_mut() = Some(Box::new(move || {
7391                ready_tx.send(()).expect("signal writer");
7392                done_rx.recv().expect("writer finished");
7393            }));
7394        });
7395        let read = reader
7396            .snapshot_read(&SnapshotReadRequest {
7397                statements: vec![
7398                    SnapshotStatement {
7399                        sql: "SELECT count(*) AS n FROM tasks",
7400                        params: &[],
7401                    },
7402                    SnapshotStatement {
7403                        sql: "SELECT count(*) AS n FROM tasks",
7404                        params: &[],
7405                    },
7406                ],
7407                coverage: &[],
7408                subscriptions: Vec::new(),
7409                commit_ids: Vec::new(),
7410            })
7411            .expect("snapshot read");
7412        assert_eq!(
7413            read.queries[0][0]["n"], 1,
7414            "first statement sees the pre-commit row"
7415        );
7416        assert_eq!(
7417            read.queries[1][0]["n"], 1,
7418            "a later statement keeps the one read snapshot"
7419        );
7420        assert_eq!(
7421            read.revision, revision_before,
7422            "the read reports the snapshot revision, not the writer's"
7423        );
7424        writer.join().expect("join writer");
7425
7426        assert_ne!(
7427            reader.local_revision().expect("revision after commit"),
7428            revision_before,
7429            "the next read observes the committed revision"
7430        );
7431        let after = reader
7432            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7433            .expect("count after commit");
7434        assert_eq!(
7435            after.rows[0]["n"], 2,
7436            "the committed row is visible afterwards"
7437        );
7438
7439        drop(reader);
7440        drop(client);
7441        std::fs::remove_file(path).expect("remove temp database");
7442    }
7443
7444    #[test]
7445    fn snapshot_read_rejects_every_non_canonical_revision() {
7446        let path = std::env::temp_dir().join(format!(
7447            "syncular-read-revision-{}.db",
7448            uuid::Uuid::new_v4()
7449        ));
7450        let schema = json!({
7451            "version": 1,
7452            "tables": [{
7453                "name": "tasks",
7454                "primaryKey": "id",
7455                "columns": [
7456                    { "name": "id", "type": "string", "nullable": false },
7457                    { "name": "project_id", "type": "string", "nullable": false }
7458                ],
7459                "scopes": [{ "pattern": "project:{project_id}" }]
7460            }]
7461        });
7462        let client = SyncClient::open_path_with_identity(
7463            Some("sidecar-revision".to_owned()),
7464            &schema,
7465            ClientLimits::default(),
7466            path.to_str().expect("UTF-8 temp path"),
7467        )
7468        .expect("open owner");
7469        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7470        for bad in [
7471            "+1",
7472            "01",
7473            "-1",
7474            " 1",
7475            "1 ",
7476            "18446744073709551616",
7477            "1.0",
7478            "abc",
7479            "",
7480        ] {
7481            client
7482                .conn
7483                .execute(
7484                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7485                    rusqlite::params![bad],
7486                )
7487                .expect("corrupt revision");
7488            let failure = reader.local_revision().expect_err("corrupt marker fails");
7489            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7490        }
7491        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7492            client
7493                .conn
7494                .execute_batch(&format!(
7495                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7496                ))
7497                .expect("corrupt stored revision type");
7498            assert_eq!(
7499                reader.local_revision().expect_err("invalid SQL field").code,
7500                Some("sync.local_corrupt")
7501            );
7502        }
7503        // The absent marker is the legacy zero, not corruption.
7504        client
7505            .conn
7506            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7507            .expect("delete marker");
7508        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7509
7510        drop(reader);
7511        drop(client);
7512        std::fs::remove_file(path).expect("remove temp database");
7513    }
7514
7515    #[test]
7516    fn snapshot_read_rejects_corrupt_subscription_state() {
7517        let path = std::env::temp_dir().join(format!(
7518            "syncular-read-substate-{}.db",
7519            uuid::Uuid::new_v4()
7520        ));
7521        let schema = json!({
7522            "version": 1,
7523            "tables": [{
7524                "name": "tasks",
7525                "primaryKey": "id",
7526                "columns": [
7527                    { "name": "id", "type": "string", "nullable": false },
7528                    { "name": "project_id", "type": "string", "nullable": false }
7529                ],
7530                "scopes": [{ "pattern": "project:{project_id}" }]
7531            }]
7532        });
7533        let mut client = SyncClient::open_path_with_identity(
7534            Some("sidecar-substate".to_owned()),
7535            &schema,
7536            ClientLimits::default(),
7537            path.to_str().expect("UTF-8 temp path"),
7538        )
7539        .expect("open owner");
7540        client
7541            .subscribe(
7542                "s1".into(),
7543                "tasks".into(),
7544                vec![("project_id".into(), vec!["one".into()])],
7545                None,
7546            )
7547            .expect("subscribe");
7548        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7549        for bad in [
7550            "not json".to_owned(),
7551            "{}".to_owned(),
7552            json!({ "status": "bogus", "cursor": 0 }).to_string(),
7553            json!({ "status": "active", "cursor": "x" }).to_string(),
7554            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7555            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7556        ] {
7557            client
7558                .conn
7559                .execute(
7560                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7561                    rusqlite::params![bad],
7562                )
7563                .expect("corrupt subscription state");
7564            let failure = reader
7565                .subscription_catchup("s1")
7566                .expect_err("corrupt state fails");
7567            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7568        }
7569
7570        for assignment in [
7571            "tbl=X'7461736b73'",
7572            "state_json=X'7b7d'",
7573            "state_json=CAST(X'FF' AS TEXT)",
7574        ] {
7575            client.conn.execute_batch(&format!(
7576                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7577            )).expect("corrupt stored subscription type");
7578            assert_eq!(
7579                reader
7580                    .subscription_catchup("s1")
7581                    .expect_err("invalid SQL field")
7582                    .code,
7583                Some("sync.local_corrupt")
7584            );
7585        }
7586
7587        drop(reader);
7588        drop(client);
7589        std::fs::remove_file(path).expect("remove temp database");
7590    }
7591
7592    #[test]
7593    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7594        let path = std::env::temp_dir().join(format!(
7595            "syncular-read-rollback-{}.db",
7596            uuid::Uuid::new_v4()
7597        ));
7598        let schema = json!({
7599            "version": 1,
7600            "tables": [{
7601                "name": "tasks",
7602                "primaryKey": "id",
7603                "columns": [
7604                    { "name": "id", "type": "string", "nullable": false },
7605                    { "name": "project_id", "type": "string", "nullable": false }
7606                ],
7607                "scopes": [{ "pattern": "project:{project_id}" }]
7608            }]
7609        });
7610        let mut client = SyncClient::open_path_with_identity(
7611            Some("sidecar-rollback".to_owned()),
7612            &schema,
7613            ClientLimits::default(),
7614            path.to_str().expect("UTF-8 temp path"),
7615        )
7616        .expect("open owner");
7617        let commit_id = client
7618            .mutate(vec![Mutation::Upsert {
7619                table: "tasks".to_owned(),
7620                values: Map::from_iter([
7621                    ("id".to_owned(), Value::from("t1")),
7622                    ("project_id".to_owned(), Value::from("one")),
7623                ]),
7624                base_version: None,
7625            }])
7626            .expect("seed row");
7627        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7628        let revision = reader.local_revision().expect("revision");
7629
7630        // The second statement fails: the whole read rolls back and the
7631        // connection releases, so the same reader still works afterwards.
7632        let failure = reader
7633            .snapshot_read(&SnapshotReadRequest {
7634                statements: vec![
7635                    SnapshotStatement {
7636                        sql: "SELECT id FROM tasks",
7637                        params: &[],
7638                    },
7639                    SnapshotStatement {
7640                        sql: "SELECT * FROM does_not_exist",
7641                        params: &[],
7642                    },
7643                ],
7644                coverage: &[],
7645                subscriptions: Vec::new(),
7646                commit_ids: vec![commit_id.clone()],
7647            })
7648            .expect_err("second statement fails");
7649        assert_eq!(failure.code, None);
7650        assert_eq!(
7651            reader.local_revision().expect("revision after rollback"),
7652            revision,
7653            "a rolled-back read consumes no revision"
7654        );
7655        assert!(
7656            !matches!(
7657                reader
7658                    .commit_delivery(&commit_id)
7659                    .expect("delivery after rollback"),
7660                CommitDelivery::Unknown { .. }
7661            ),
7662            "the same reader serves the next read after releasing the connection"
7663        );
7664
7665        // The read-only guard rejects a write in the first position too.
7666        let guard = reader
7667            .snapshot_read(&SnapshotReadRequest {
7668                statements: vec![SnapshotStatement {
7669                    sql: "DELETE FROM tasks",
7670                    params: &[],
7671                }],
7672                coverage: &[],
7673                subscriptions: Vec::new(),
7674                commit_ids: Vec::new(),
7675            })
7676            .expect_err("write guard");
7677        assert!(guard.message.contains("read-only"), "{}", guard.message);
7678
7679        drop(reader);
7680        drop(client);
7681        std::fs::remove_file(path).expect("remove temp database");
7682    }
7683
7684    #[test]
7685    fn owned_query_failures_classify_order_bound_and_clear() {
7686        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7687            rusqlite::ffi::Error::new(266),
7688            Some("private SQLite prose".to_owned()),
7689        ));
7690        assert_eq!(io.code, Some("client.storage_io"));
7691        assert_eq!(io.sqlite_code, Some(266));
7692        assert_eq!(io.message, "local SQLite storage I/O failed");
7693        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7694            rusqlite::ffi::Error::new(26),
7695            None,
7696        ));
7697        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7698        assert!(!io.retryable());
7699        assert!(!corrupt.retryable());
7700
7701        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
7702        // transient lock contention: classified busy and retryable.
7703        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7704            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7705                rusqlite::ffi::Error::new(raw),
7706                Some("database is locked".to_owned()),
7707            ));
7708            assert_eq!(busy.code, Some("client.storage_busy"));
7709            assert_eq!(busy.sqlite_code, Some(extended));
7710            assert_eq!(busy.message, "local SQLite storage is busy");
7711            assert!(busy.retryable());
7712        }
7713        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7714            rusqlite::ffi::Error::new(1),
7715            Some("generic SQLite prose".to_owned()),
7716        ));
7717        assert_eq!(unrelated.code, None);
7718        assert!(!unrelated.retryable());
7719
7720        let mut client = client();
7721        let generic = QueryReadFailure::from("private query prose".to_owned());
7722        client.record_query_read(
7723            QueryOwner {
7724                id: "first",
7725                tables: &["tasks", "tasks"],
7726            },
7727            Some(&generic),
7728        );
7729        client.record_query_read(
7730            QueryOwner {
7731                id: "second",
7732                tables: &["tasks"],
7733            },
7734            Some(&generic),
7735        );
7736        client.record_query_read(
7737            QueryOwner {
7738                id: "first",
7739                tables: &["docs"],
7740            },
7741            Some(&io),
7742        );
7743        let failures = &client
7744            .diagnostics_snapshot(&Default::default())
7745            .expect("diagnostics")
7746            .query_failures;
7747        assert_eq!(
7748            failures
7749                .iter()
7750                .map(|entry| entry.id.as_str())
7751                .collect::<Vec<_>>(),
7752            vec!["second", "first"]
7753        );
7754        assert_eq!(failures[1].tables, vec!["docs"]);
7755        assert_eq!(failures[1].code, "client.storage_io");
7756
7757        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7758            client.record_query_read(
7759                QueryOwner {
7760                    id: &format!("query-{index}"),
7761                    tables: &["tasks"],
7762                },
7763                Some(&generic),
7764            );
7765        }
7766        let failures = &client
7767            .diagnostics_snapshot(&Default::default())
7768            .expect("bounded diagnostics")
7769            .query_failures;
7770        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7771        assert_eq!(failures.last().expect("last failure").id, "query-256");
7772
7773        client
7774            .query_snapshot(
7775                "SELECT id FROM tasks",
7776                &[],
7777                &[],
7778                Some(QueryOwner {
7779                    id: "query-256",
7780                    tables: &["tasks"],
7781                }),
7782            )
7783            .expect("successful owned read");
7784        assert!(client
7785            .diagnostics_snapshot(&Default::default())
7786            .expect("cleared diagnostics")
7787            .query_failures
7788            .iter()
7789            .all(|entry| entry.id != "query-256"));
7790    }
7791
7792    #[test]
7793    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7794        let mut client = client();
7795        let image = Connection::open_in_memory().unwrap();
7796        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7797            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7798            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7799            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7800            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7801        let table = client.schema.table("tasks").unwrap().clone();
7802        let scopes = vec![("project_id".into(), vec!["p1".into()])];
7803        assert!(client
7804            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7805            .is_err());
7806        assert!(client.conn.is_autocommit());
7807        assert_eq!(
7808            client
7809                .query("SELECT count(*) AS n FROM tasks", &[])
7810                .unwrap()[0]["n"],
7811            1024
7812        );
7813        assert_eq!(client.drain_change_batches().len(), 1);
7814        image
7815            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7816            .unwrap();
7817        assert!(matches!(
7818            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7819            Ok(2050)
7820        ));
7821        assert_eq!(
7822            client
7823                .query("SELECT count(*) AS n FROM tasks", &[])
7824                .unwrap()[0]["n"],
7825            2050
7826        );
7827        assert_eq!(client.drain_change_batches().len(), 3);
7828    }
7829
7830    #[test]
7831    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7832        let mut client = client();
7833        let base = WindowBase {
7834            table: "tasks".into(),
7835            variable: "project_id".into(),
7836            fixed_scopes: Vec::new(),
7837            params: None,
7838        };
7839        client
7840            .set_window(&base, &["p1".into(), "p2".into()])
7841            .unwrap();
7842        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7843            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7844            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7845            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7846            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7847        client.drain_change_batches();
7848        assert!(client.set_window(&base, &["p2".into()]).is_err());
7849        assert!(client.conn.is_autocommit());
7850        assert_eq!(
7851            client
7852                .query("SELECT count(*) AS n FROM tasks", &[])
7853                .unwrap()[0]["n"],
7854            1027
7855        );
7856        assert_eq!(client.window_state(&base).units, vec!["p2"]);
7857        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7858        assert_eq!(client.drain_change_batches().len(), 1);
7859        client
7860            .conn
7861            .execute_batch("DROP TRIGGER interrupt_evict")
7862            .unwrap();
7863        client.drain_pending_evictions().unwrap();
7864        assert!(client.load_pending_evictions().unwrap().is_empty());
7865        assert_eq!(
7866            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7867            "held"
7868        );
7869        assert_eq!(client.drain_change_batches().len(), 2);
7870    }
7871
7872    #[test]
7873    fn eviction_marker_failures_roll_back_window_changes() {
7874        let mut client = client();
7875        let base = WindowBase {
7876            table: "tasks".into(),
7877            variable: "project_id".into(),
7878            fixed_scopes: Vec::new(),
7879            params: None,
7880        };
7881        client.set_window(&base, &["p1".into()]).unwrap();
7882        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7883            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7884            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7885            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7886        let revision = client.local_revision();
7887        assert!(client.set_window(&base, &[]).is_err());
7888        assert!(client.conn.is_autocommit());
7889        assert_eq!(client.local_revision(), revision);
7890        assert_eq!(
7891            client
7892                .query("SELECT count(*) AS n FROM tasks", &[])
7893                .unwrap()[0]["n"],
7894            2050
7895        );
7896        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7897        client.conn.execute_batch("DROP TRIGGER fail_marker;
7898            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7899        assert!(client.set_window(&base, &[]).is_err());
7900        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7901            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7902        assert!(client.drain_pending_evictions().is_err());
7903        assert!(client.conn.is_autocommit());
7904        assert_eq!(
7905            client
7906                .query("SELECT count(*) AS n FROM tasks", &[])
7907                .unwrap()[0]["n"],
7908            2
7909        );
7910        assert!(client.set_window(&base, &["p1".into()]).is_err());
7911        assert!(client.conn.is_autocommit());
7912        assert!(client.window_state(&base).units.is_empty());
7913        assert!(client.subs.is_empty());
7914        assert_eq!(
7915            client
7916                .query(
7917                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7918                    &[]
7919                )
7920                .unwrap()[0]["n"],
7921            1
7922        );
7923        client
7924            .conn
7925            .execute_batch("DROP TRIGGER fail_marker")
7926            .unwrap();
7927        client.set_window(&base, &["p1".into()]).unwrap();
7928        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7929    }
7930
7931    #[test]
7932    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7933        let mut client = client();
7934        client
7935            .conn
7936            .execute(
7937                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7938                [],
7939            )
7940            .unwrap();
7941        assert_eq!(
7942            client.drain_pending_evictions().unwrap_err(),
7943            "sync.local_corrupt: invalid pending eviction scopes"
7944        );
7945        assert!(client.conn.is_autocommit());
7946        assert!(client.enqueue_startup_sync_if_needed().is_err());
7947    }
7948
7949    #[test]
7950    fn image_chunks_reject_null_primary_keys() {
7951        let mut client = client();
7952        let image = Connection::open_in_memory().unwrap();
7953        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7954            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7955            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7956            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7957            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7958        let table = client.schema.table("tasks").unwrap().clone();
7959        assert!(client
7960            .apply_sqlite_image(
7961                &image,
7962                &table,
7963                true,
7964                &[("project_id".into(), vec!["p1".into()])],
7965                1025,
7966                7,
7967                "digest"
7968            )
7969            .is_err());
7970        assert!(client.conn.is_autocommit());
7971        assert_eq!(
7972            client
7973                .query("SELECT count(*) AS n FROM tasks", &[])
7974                .unwrap()[0]["n"],
7975            0
7976        );
7977    }
7978
7979    #[test]
7980    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7981        for images in [false, true] {
7982            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7983                "name":name,"primaryKey":"id","columns":[
7984                    {"name":"id","type":"string","nullable":false},
7985                    {"name":"project_id","type":"string","nullable":false},
7986                    {"name":"title","type":"string","nullable":false}],
7987                "scopes":[{"pattern":"project:{project_id}"}],
7988                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7989                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7990            })))});
7991            let mut actual =
7992                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7993            let mut reference =
7994                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7995            let scopes = vec![("project_id".into(), vec!["p1".into()])];
7996            for client in [&mut actual, &mut reference] {
7997                client
7998                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7999                    .unwrap();
8000                client.subs[0].effective = Some(scopes.clone());
8001                for (table, id, title) in [
8002                    ("tasks", "a", "original"),
8003                    ("tasks", "b", "occupied"),
8004                    ("tasks", "c", "deleted"),
8005                    ("notes", "n", "untouched"),
8006                ] {
8007                    client
8008                        .write_base_row(
8009                            table,
8010                            &vec![
8011                                Some(ColumnValue::String(id.into())),
8012                                Some(ColumnValue::String("p1".into())),
8013                                Some(ColumnValue::String(title.into())),
8014                            ],
8015                            1,
8016                        )
8017                        .unwrap();
8018                }
8019                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8020                client
8021                    .mutate(vec![
8022                        Mutation::Upsert {
8023                            table: "tasks".into(),
8024                            values: Map::from_iter([
8025                                ("id".into(), Value::from("a")),
8026                                ("project_id".into(), Value::from("p1")),
8027                                ("title".into(), Value::from("available")),
8028                            ]),
8029                            base_version: None,
8030                        },
8031                        Mutation::Delete {
8032                            table: "tasks".into(),
8033                            row_id: "c".into(),
8034                            base_version: None,
8035                        },
8036                        Mutation::Upsert {
8037                            table: "tasks".into(),
8038                            values: Map::from_iter([
8039                                ("id".into(), Value::from("local")),
8040                                ("project_id".into(), Value::from("p1")),
8041                                ("title".into(), Value::from("local value")),
8042                            ]),
8043                            base_version: None,
8044                        },
8045                        Mutation::Upsert {
8046                            table: "notes".into(),
8047                            values: Map::from_iter([
8048                                ("id".into(), Value::from("n")),
8049                                ("project_id".into(), Value::from("p1")),
8050                                ("title".into(), Value::from("pending note")),
8051                            ]),
8052                            base_version: None,
8053                        },
8054                    ])
8055                    .unwrap();
8056            }
8057            let pending = actual.pending_commit_ids();
8058            actual.overlay_rebuild_count.set(0);
8059            for (id, title) in [
8060                ("b", "available"),
8061                ("b", "occupied"),
8062                ("a", "server original"),
8063                ("d", "local value"),
8064                ("d", "released"),
8065                ("c", "redelivered"),
8066            ] {
8067                let table = actual.schema.table("tasks").unwrap().clone();
8068                let values = vec![
8069                    Some(ColumnValue::String(id.into())),
8070                    Some(ColumnValue::String("p1".into())),
8071                    Some(ColumnValue::String(title.into())),
8072                ];
8073                if images {
8074                    let image = Connection::open_in_memory().unwrap();
8075                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8076                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8077                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8078                    image
8079                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8080                        .unwrap();
8081                    assert!(matches!(
8082                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8083                        Ok(1)
8084                    ));
8085                } else {
8086                    let segment = RowsSegment {
8087                        table: "tasks".into(),
8088                        schema_version: 1,
8089                        columns: table.wire_columns.clone(),
8090                        blocks: vec![vec![ssp2::segment::SegmentRow {
8091                            server_version: 2,
8092                            values: values.clone(),
8093                        }]],
8094                    };
8095                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8096                }
8097                reference.write_base_row("tasks", &values, 2).unwrap();
8098                reference.rebuild_overlay().expect("rebuild overlay");
8099                for table in ["tasks", "notes"] {
8100                    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")] {
8101                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8102                    }
8103                }
8104                assert_eq!(actual.pending_commit_ids(), pending);
8105            }
8106            assert_eq!(
8107                actual.overlay_rebuild_count.get(),
8108                0,
8109                "each chunk must leave unrelated tables alone"
8110            );
8111            let before = actual
8112                .query("SELECT * FROM tasks ORDER BY id", &[])
8113                .unwrap();
8114            let fts_before = actual
8115                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8116                .unwrap();
8117            let revision = actual.local_revision();
8118            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8119            let table = actual.schema.table("tasks").unwrap().clone();
8120            let segment = RowsSegment {
8121                table: "tasks".into(),
8122                schema_version: 1,
8123                columns: table.wire_columns.clone(),
8124                blocks: vec![vec![ssp2::segment::SegmentRow {
8125                    server_version: 3,
8126                    values: vec![
8127                        Some(ColumnValue::String("b".into())),
8128                        Some(ColumnValue::String("p1".into())),
8129                        Some(ColumnValue::String("fresh".into())),
8130                    ],
8131                }]],
8132            };
8133            assert!(actual.apply_segment(0, &segment, false).is_err());
8134            assert!(actual.conn.is_autocommit());
8135            assert_eq!(
8136                actual
8137                    .query("SELECT * FROM tasks ORDER BY id", &[])
8138                    .unwrap(),
8139                before
8140            );
8141            assert_eq!(
8142                actual
8143                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8144                    .unwrap(),
8145                fts_before
8146            );
8147            assert_eq!(actual.local_revision(), revision);
8148            assert_eq!(actual.pending_commit_ids(), pending);
8149        }
8150    }
8151
8152    #[test]
8153    fn import_reconciliation_preserves_typed_pending_primary_keys() {
8154        for (kind, a, b) in [
8155            ("string", json!("a"), json!("b")),
8156            ("integer", json!(1), json!(2)),
8157            ("boolean", json!(true), json!(false)),
8158            ("json", json!("1"), json!("2")),
8159        ] {
8160            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}]}]});
8161            for images in [false, true] {
8162                let mut client =
8163                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8164                let table = client.schema.table("tasks").unwrap().clone();
8165                client
8166                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
8167                    .unwrap();
8168                for (id, title) in [(&a, "original"), (&b, "occupied")] {
8169                    client
8170                        .write_base_row(
8171                            "tasks",
8172                            &vec![
8173                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8174                                Some(ColumnValue::String(title.into())),
8175                            ],
8176                            1,
8177                        )
8178                        .unwrap();
8179                }
8180                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8181                client
8182                    .mutate(vec![Mutation::Upsert {
8183                        table: "tasks".into(),
8184                        values: Map::from_iter([
8185                            ("id".into(), a.clone()),
8186                            ("title".into(), json!("available")),
8187                        ]),
8188                        base_version: None,
8189                    }])
8190                    .unwrap();
8191                client.overlay_rebuild_count.set(0);
8192                for (title, expected) in [("available", "original"), ("occupied", "available")] {
8193                    let values = vec![
8194                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8195                        Some(ColumnValue::String(title.into())),
8196                    ];
8197                    if images {
8198                        let image = Connection::open_in_memory().unwrap();
8199                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8200                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8201                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8202                        image
8203                            .execute(
8204                                "INSERT INTO tasks VALUES (?1,?2,2)",
8205                                rusqlite::params![RowParam::Cell(&values[0]), title],
8206                            )
8207                            .unwrap();
8208                        assert!(
8209                            matches!(
8210                                client.apply_sqlite_image(
8211                                    &image,
8212                                    &table,
8213                                    false,
8214                                    &[],
8215                                    1,
8216                                    7,
8217                                    "digest"
8218                                ),
8219                                Ok(1)
8220                            ),
8221                            "{kind}, images={images}"
8222                        );
8223                    } else {
8224                        let segment = RowsSegment {
8225                            table: "tasks".into(),
8226                            schema_version: 1,
8227                            columns: table.wire_columns.clone(),
8228                            blocks: vec![vec![ssp2::segment::SegmentRow {
8229                                server_version: 2,
8230                                values,
8231                            }]],
8232                        };
8233                        assert!(
8234                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
8235                            "{kind}"
8236                        );
8237                    }
8238                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8239                    let actual: String = client
8240                        .conn
8241                        .query_row(
8242                            "SELECT title FROM tasks WHERE id=?1",
8243                            [RowParam::Cell(&key)],
8244                            |row| row.get(0),
8245                        )
8246                        .unwrap();
8247                    assert_eq!(actual, expected, "{kind}, images={images}");
8248                }
8249                assert_eq!(client.overlay_rebuild_count.get(), 0);
8250            }
8251        }
8252    }
8253
8254    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8255        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8256            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8257            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8258            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8259        })))});
8260        let mut client =
8261            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8262        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();
8263        client.rebuild_overlay().expect("rebuild overlay");
8264        client
8265            .mutate(vec![Mutation::Upsert {
8266                table: "tasks".into(),
8267                values: Map::from_iter([
8268                    ("id".into(), json!("offline")),
8269                    ("project_id".into(), json!("p1")),
8270                    ("title".into(), json!("pending edit")),
8271                ]),
8272                base_version: None,
8273            }])
8274            .unwrap();
8275        let image = Connection::open_in_memory().unwrap();
8276        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8277            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8278        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();
8279        image
8280            .execute(
8281                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8282                [rows],
8283            )
8284            .unwrap();
8285        client.overlay_rebuild_count.set(0);
8286        let table = client.schema.table("tasks").unwrap().clone();
8287        (client, image, table)
8288    }
8289
8290    #[test]
8291    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8292        let (mut client, image, table) = unique_import_fixture(1);
8293        image
8294            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8295            .unwrap();
8296        client
8297            .conn
8298            .execute_batch("PRAGMA max_page_count = 1")
8299            .unwrap();
8300        let effective = [("project_id".into(), vec!["p1".into()])];
8301        assert!(client
8302            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8303            .is_err());
8304        let failure = client.storage_failure.borrow().clone().unwrap();
8305        assert_eq!(failure.code, Some("client.storage_full"));
8306        assert_eq!(failure.sqlite_code, Some(13));
8307        assert!(!failure.message.contains("rollback"));
8308        assert!(client.conn.is_autocommit());
8309        client
8310            .conn
8311            .execute_batch("PRAGMA max_page_count = 1073741823")
8312            .unwrap();
8313        assert!(matches!(
8314            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8315            Ok(1)
8316        ));
8317        assert!(client.conn.is_autocommit());
8318    }
8319
8320    #[test]
8321    fn authoring_storage_failures_are_structured_and_scoped() {
8322        let path = std::env::temp_dir().join(format!(
8323            "syncular-authoring-failure-{}.db",
8324            uuid::Uuid::new_v4()
8325        ));
8326        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8327            {"name":"id","type":"string","nullable":false},
8328            {"name":"project_id","type":"string","nullable":false},
8329            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8330        let mut client = SyncClient::open_path(
8331            "authoring".into(),
8332            &schema,
8333            ClientLimits::default(),
8334            path.to_str().unwrap(),
8335        )
8336        .expect("open client");
8337        client.create_synced_tables().expect("create synced tables");
8338        // Return BUSY immediately rather than waiting out the file timeout.
8339        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8340
8341        let upsert = |id: &str| {
8342            vec![Mutation::Upsert {
8343                table: "tasks".into(),
8344                values: Map::from_iter([
8345                    ("id".into(), json!(id)),
8346                    ("project_id".into(), json!("p1")),
8347                    ("title".into(), json!("first")),
8348                ]),
8349                base_version: None,
8350            }]
8351        };
8352
8353        let locker = Connection::open(&path).expect("second connection");
8354        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8355        let busy = client.mutate(upsert("t1")).expect_err("busy write");
8356        assert_eq!(busy.code, "client.storage_busy");
8357        assert!(busy.retryable);
8358        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8359        assert!(client.pending_commit_ids().is_empty());
8360        assert_eq!(client.local_revision(), 0);
8361
8362        // A stale classified failure must not classify a later unrelated one.
8363        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8364        assert_eq!(invalid.code, "sync.invalid_request");
8365        assert_eq!(invalid.message, "the authoring request is invalid");
8366        assert!(
8367            invalid.details.as_ref().unwrap()["legacyCause"]
8368                .as_str()
8369                .unwrap()
8370                .contains("at least one operation"),
8371            "{invalid:?}"
8372        );
8373
8374        locker.execute_batch("ROLLBACK").unwrap();
8375        let id = client.mutate(upsert("t1")).expect("retry after busy");
8376        assert_eq!(client.pending_commit_ids(), vec![id]);
8377        assert_eq!(client.local_revision(), 1);
8378
8379        // A later storage failure reports its own code and details.
8380        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8381        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8382        assert_eq!(busy_again.code, "client.storage_busy");
8383        assert!(busy_again.retryable);
8384        locker.execute_batch("ROLLBACK").unwrap();
8385
8386        // Non-retryable classified failures keep their sqlite evidence too.
8387        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8388            rusqlite::ffi::Error::new(13),
8389            Some("database or disk is full".to_owned()),
8390        )));
8391        assert_eq!(full.code, "client.storage_full");
8392        assert!(!full.retryable);
8393        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8394
8395        drop(locker);
8396        drop(client);
8397        let _ = std::fs::remove_file(&path);
8398    }
8399
8400    #[test]
8401    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8402        let path = std::env::temp_dir().join(format!(
8403            "syncular-caller-values-{}.db",
8404            uuid::Uuid::new_v4()
8405        ));
8406        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8407            {"name":"id","type":"string","nullable":false},
8408            {"name":"project_id","type":"string","nullable":false},
8409            {"name":"title","type":"string","nullable":false},
8410            {"name":"priority","type":"integer","nullable":false},
8411            {"name":"payload","type":"bytes","nullable":false},
8412            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8413        let mut client = SyncClient::open_path(
8414            "caller-values".into(),
8415            &schema,
8416            ClientLimits::default(),
8417            path.to_str().unwrap(),
8418        )
8419        .expect("open client");
8420        client.create_synced_tables().expect("create synced tables");
8421
8422        let values = |id: &str| {
8423            Map::from_iter([
8424                ("id".to_owned(), json!(id)),
8425                ("project_id".to_owned(), json!("p1")),
8426                ("title".to_owned(), json!("first")),
8427                ("priority".to_owned(), json!(1)),
8428                ("payload".to_owned(), json!({"$bytes": "0a"})),
8429            ])
8430        };
8431        let upsert = |values: Map<String, Value>| {
8432            vec![Mutation::Upsert {
8433                table: "tasks".into(),
8434                values,
8435                base_version: None,
8436            }]
8437        };
8438        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8439        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8440
8441        // §6.1/§7.1: every caller value the authoring seam rejects reports the
8442        // same stable identity instead of the internal-failure default, keeps
8443        // the dynamic cause in `details.legacyCause`, and leaves the durable
8444        // outbox, the local revision and the projection untouched.
8445        for (label, override_value, cause) in [
8446            ("unknown column", ("nope", json!(1)), "unknown column"),
8447            (
8448                "internal sync column",
8449                ("_sync_version", json!(1)),
8450                "internal sync column",
8451            ),
8452            ("wrong value type", ("title", json!(7)), "expected a string"),
8453            (
8454                "malformed bytes envelope",
8455                ("payload", json!({"$bytes": "zz"})),
8456                "expected exactly",
8457            ),
8458            (
8459                "odd-length bytes envelope",
8460                ("payload", json!({"$bytes": "abc"})),
8461                "expected exactly",
8462            ),
8463            (
8464                "plus-sign bytes envelope",
8465                ("payload", json!({"$bytes": "+a"})),
8466                "expected exactly",
8467            ),
8468            (
8469                "bytes envelope with a second key",
8470                ("payload", json!({"$bytes": "0a", "extra": 1})),
8471                "expected exactly",
8472            ),
8473            (
8474                "no bytes envelope",
8475                ("payload", json!({"nested": true})),
8476                "expected exactly",
8477            ),
8478        ] {
8479            let mut full = values("t2");
8480            full.insert(override_value.0.to_owned(), override_value.1);
8481            let error = client.mutate(upsert(full)).expect_err(label);
8482            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8483            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8484            assert!(!error.retryable, "{label}");
8485            let legacy = error.details.as_ref().unwrap()["legacyCause"]
8486                .as_str()
8487                .unwrap();
8488            assert!(
8489                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8490                "{label}: {legacy}"
8491            );
8492            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8493            assert_eq!(client.local_revision(), 1, "{label}");
8494            assert_eq!(
8495                client.query("SELECT * FROM tasks", &[]).unwrap(),
8496                rows,
8497                "{label}"
8498            );
8499        }
8500
8501        // A full-row upsert presents every column, so a missing required one is
8502        // the caller's failure too.
8503        let mut missing = values("t3");
8504        missing.remove("payload");
8505        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8506        assert_eq!(missing_error.code, "sync.invalid_request");
8507        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8508            .as_str()
8509            .unwrap()
8510            .contains("is not nullable"));
8511
8512        // A primary key the wire cannot render is rejected before any op is
8513        // recorded.
8514        let mut bad_row_id = values("t4");
8515        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8516        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8517        assert_eq!(row_id_error.code, "sync.invalid_request");
8518
8519        // The sparse route classifies the same value failures.
8520        for (label, partial, cause) in [
8521            (
8522                "patch wrong value type",
8523                ("priority", json!("high")),
8524                "expected an integer",
8525            ),
8526            ("patch unknown column", ("nope", json!(1)), "unknown column"),
8527        ] {
8528            let error = client
8529                .patch(
8530                    "tasks",
8531                    "t1",
8532                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
8533                    None,
8534                )
8535                .expect_err(label);
8536            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8537            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8538            assert!(
8539                error.details.as_ref().unwrap()["legacyCause"]
8540                    .as_str()
8541                    .unwrap()
8542                    .contains(cause),
8543                "{label}"
8544            );
8545        }
8546        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8547        assert_eq!(client.local_revision(), 1);
8548        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8549
8550        // Nullable explicit null, an empty envelope, and uppercase digits stay
8551        // accepted; the stored payload is the decoded byte sequence.
8552        for (label, payload) in [
8553            ("explicit null", json!(null)),
8554            ("empty envelope", json!({"$bytes": ""})),
8555            ("uppercase envelope", json!({"$bytes": "0AFF"})),
8556        ] {
8557            let mut accepted = values("t-ok");
8558            accepted.insert("extra".to_owned(), payload);
8559            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8560                panic!("{label} must be accepted: {error:?}");
8561            });
8562            assert!(!encoded.is_empty(), "{label}");
8563        }
8564
8565        // Nothing is poisoned: the next valid authoring call succeeds.
8566        let next = client
8567            .mutate(upsert(values("t5")))
8568            .expect("valid after failures");
8569        assert_ne!(next, seed);
8570
8571        drop(client);
8572        let _ = std::fs::remove_file(&path);
8573    }
8574
8575    #[test]
8576    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8577        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8578            {"name":"id","type":"string","nullable":false},
8579            {"name":"project_id","type":"string","nullable":false},
8580            {"name":"payload","type":"bytes","nullable":true}],
8581            "scopes":[{"pattern":"project:{project_id}"}]}]});
8582        let open = |path: &std::path::Path| {
8583            SyncClient::open_path(
8584                "legacy-values".into(),
8585                &schema,
8586                ClientLimits::default(),
8587                path.to_str().unwrap(),
8588            )
8589            .expect("open client")
8590        };
8591        let row = |id: &str| {
8592            Map::from_iter([
8593                ("id".to_owned(), json!(id)),
8594                ("project_id".to_owned(), json!("p1")),
8595                ("payload".to_owned(), json!({"$bytes": "0a"})),
8596            ])
8597        };
8598        let upsert = |values| {
8599            vec![Mutation::Upsert {
8600                table: "tasks".into(),
8601                values,
8602                base_version: None,
8603            }]
8604        };
8605        for (label, stored_cell, representable) in [
8606            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8607            ("non-hex envelope", json!({"$bytes": "zz"}), false),
8608            ("odd-length envelope", json!({"$bytes": "abc"}), false),
8609            ("no envelope", json!({"nested": true}), false),
8610            // A wrong declared type and a stored null for a non-nullable column
8611            // are journalable as stored, so their rejection keeps the operation
8612            // envelope.
8613            ("wrong declared type", json!("high"), true),
8614            ("stored null for a non-nullable column", json!(null), true),
8615        ] {
8616            let path = std::env::temp_dir().join(format!(
8617                "syncular-legacy-values-{}-{}.db",
8618                label.replace(' ', "-"),
8619                uuid::Uuid::new_v4()
8620            ));
8621            let mut client = open(&path);
8622            client.create_synced_tables().expect("create synced tables");
8623            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8624            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8625            drop(client);
8626
8627            // The stored intent an earlier version accepted.
8628            let connection = Connection::open(&path).expect("raw connection");
8629            let (column, cell) = if stored_cell.is_null() {
8630                ("project_id", stored_cell.clone())
8631            } else {
8632                ("payload", stored_cell.clone())
8633            };
8634            let mut planted_values = json!({
8635                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8636            });
8637            planted_values[column] = cell;
8638            let planted = json!([{
8639                "op": "upsert",
8640                "table": "tasks",
8641                "rowId": "t1",
8642                "baseVersion": null,
8643                "values": planted_values
8644            }]);
8645            connection
8646                .execute(
8647                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8648                    rusqlite::params![planted.to_string(), stale],
8649                )
8650                .expect("plant legacy intent");
8651            drop(connection);
8652
8653            // Reopen: the startup reconciliation recovers the commit before any
8654            // replay, so the observable phase matches the browser core.
8655            let reopened = open(&path);
8656            assert_eq!(
8657                reopened.pending_commit_ids(),
8658                vec![successor.clone()],
8659                "{label}"
8660            );
8661            let rejection = reopened
8662                .rejections()
8663                .iter()
8664                .find(|rejection| rejection.client_commit_id == stale)
8665                .unwrap_or_else(|| panic!("{label}: rejection"));
8666            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8667            assert_eq!(
8668                rejection.message, "the persisted commit carries values the current codec refuses",
8669                "{label}"
8670            );
8671            assert_eq!(rejection.operation.is_some(), representable, "{label}");
8672            assert_eq!(
8673                rejection
8674                    .details
8675                    .as_ref()
8676                    .and_then(|details| details.reason.as_deref()),
8677                Some(INVALID_STORED_VALUES_REASON),
8678                "{label}"
8679            );
8680            // The rejection stays readable after a further reopen.
8681            let journal = reopened
8682                .commit_outcome(&stale)
8683                .expect("read outcome")
8684                .expect("journal entry");
8685            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8686            drop(reopened);
8687            let mut reopened = open(&path);
8688            let reread = reopened
8689                .commit_outcome(&stale)
8690                .expect("read outcome")
8691                .expect("journal entry");
8692            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8693
8694            // The legacy row's optimistic projection is gone, the successor
8695            // survives and still encodes, and the round path keeps the queue.
8696            assert_eq!(
8697                reopened
8698                    .query("SELECT id FROM tasks ORDER BY id", &[])
8699                    .unwrap(),
8700                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8701                "{label}"
8702            );
8703            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8704            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8705            assert!(!reopened
8706                .drop_unencodable_outbox(false, None)
8707                .expect("idempotent prepass"));
8708            let mut transport = CountingRealtimeTransport::default();
8709            let outcome = reopened.sync(&mut transport);
8710            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8711            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8712            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8713            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8714            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8715            drop(reopened);
8716            let _ = std::fs::remove_file(&path);
8717        }
8718    }
8719
8720    #[test]
8721    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8722        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8723            {"name":"id","type":"string","nullable":false},
8724            {"name":"project_id","type":"string","nullable":false},
8725            {"name":"payload","type":"bytes","nullable":true}],
8726            "scopes":[{"pattern":"project:{project_id}"}]}]});
8727        for (label, column, planted) in [
8728            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8729            (
8730                "stored null for a non-nullable column",
8731                "project_id",
8732                json!(null),
8733            ),
8734            ("wrong declared type", "payload", json!(7)),
8735        ] {
8736            let path = std::env::temp_dir().join(format!(
8737                "syncular-shared-row-{}-{}.db",
8738                label.replace(' ', "-"),
8739                uuid::Uuid::new_v4()
8740            ));
8741            let open = |path: &std::path::Path| {
8742                SyncClient::open_path(
8743                    "shared-row".into(),
8744                    &schema,
8745                    ClientLimits::default(),
8746                    path.to_str().unwrap(),
8747                )
8748                .expect("open client")
8749            };
8750            let row = |id: &str| {
8751                Map::from_iter([
8752                    ("id".to_owned(), json!(id)),
8753                    ("project_id".to_owned(), json!("p1")),
8754                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8755                ])
8756            };
8757            let upsert = |values| {
8758                vec![Mutation::Upsert {
8759                    table: "tasks".into(),
8760                    values,
8761                    base_version: None,
8762                }]
8763            };
8764            let mut client = open(&path);
8765            client.create_synced_tables().expect("create synced tables");
8766            let older = client.mutate(upsert(row("t1"))).expect("older commit");
8767            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8768            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8769            drop(client);
8770
8771            let connection = Connection::open(&path).expect("raw connection");
8772            for commit_id in [&older, &newer] {
8773                let mut values = row("t1");
8774                values.insert(column.to_owned(), planted.clone());
8775                let planted_ops = json!([{
8776                    "op": "upsert",
8777                    "table": "tasks",
8778                    "rowId": "t1",
8779                    "baseVersion": null,
8780                    "values": values
8781                }]);
8782                connection
8783                    .execute(
8784                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8785                        rusqlite::params![planted_ops.to_string(), commit_id],
8786                    )
8787                    .expect("plant legacy intent");
8788            }
8789            drop(connection);
8790
8791            // Startup recovery drops both, FIFO, and publishes one batch.
8792            let mut reopened = open(&path);
8793            assert_eq!(
8794                reopened.pending_commit_ids(),
8795                vec![successor.clone()],
8796                "{label}"
8797            );
8798            assert_eq!(
8799                reopened
8800                    .rejections()
8801                    .iter()
8802                    .map(|rejection| rejection.client_commit_id.clone())
8803                    .collect::<Vec<_>>(),
8804                vec![older.clone(), newer.clone()],
8805                "{label}"
8806            );
8807            assert!(
8808                reopened
8809                    .rejections()
8810                    .iter()
8811                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8812                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8813                            == Some(INVALID_STORED_VALUES_REASON)),
8814                "{label}"
8815            );
8816            for commit_id in [&older, &newer] {
8817                assert!(
8818                    matches!(
8819                        reopened
8820                            .commit_outcome(commit_id)
8821                            .expect("outcome")
8822                            .map(|o| o.status),
8823                        Some(CommitOutcomeStatus::Rejected)
8824                    ),
8825                    "{label}"
8826                );
8827            }
8828            assert_eq!(
8829                reopened
8830                    .query("SELECT id FROM tasks ORDER BY id", &[])
8831                    .unwrap(),
8832                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8833                "{label}"
8834            );
8835            let revision = reopened.local_revision();
8836            let batches = reopened.drain_change_batches();
8837            assert!(
8838                batches.iter().any(|batch| batch.rejections_changed
8839                    && batch.outcomes_changed
8840                    && batch.tables.iter().any(|change| change.table == "tasks")
8841                    && batch.revision == revision.to_string()),
8842                "{label}: {batches:?}"
8843            );
8844            drop(reopened);
8845
8846            // An ordinary reopen with nothing to recover publishes nothing and
8847            // leaves the revision alone.
8848            let mut stable = open(&path);
8849            assert_eq!(stable.local_revision(), revision, "{label}");
8850            assert!(stable.drain_change_batches().is_empty(), "{label}");
8851            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8852            drop(stable);
8853            let _ = std::fs::remove_file(&path);
8854        }
8855    }
8856
8857    #[test]
8858    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8859        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8860            {"name":"id","type":"string","nullable":false},
8861            {"name":"project_id","type":"string","nullable":false},
8862            {"name":"payload","type":"bytes","nullable":true}],
8863            "scopes":[{"pattern":"project:{project_id}"}]}]});
8864        for (label, second_op) in [
8865            (
8866                "delete carrying values",
8867                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8868            ),
8869            (
8870                "baseVersion beyond the safe bound",
8871                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8872                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8873                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8874            ),
8875        ] {
8876            let path = std::env::temp_dir().join(format!(
8877                "syncular-journal-shape-{}-{}.db",
8878                label.replace(' ', "-"),
8879                uuid::Uuid::new_v4()
8880            ));
8881            let open = |path: &std::path::Path| {
8882                SyncClient::open_path(
8883                    "journal-shape".into(),
8884                    &schema,
8885                    ClientLimits::default(),
8886                    path.to_str().unwrap(),
8887                )
8888                .expect("open client")
8889            };
8890            let values = |id: &str| {
8891                Map::from_iter([
8892                    ("id".to_owned(), json!(id)),
8893                    ("project_id".to_owned(), json!("p1")),
8894                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8895                ])
8896            };
8897            let upsert = |values| {
8898                vec![Mutation::Upsert {
8899                    table: "tasks".into(),
8900                    values,
8901                    base_version: None,
8902                }]
8903            };
8904            let mut client = open(&path);
8905            client.create_synced_tables().expect("create synced tables");
8906            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8907            drop(client);
8908
8909            let connection = Connection::open(&path).expect("raw connection");
8910            // The first operation triggers the drop with canonical cells (a
8911            // stored null for a non-nullable column); the second one is the
8912            // operation shape the journal reader refuses.
8913            let planted = json!([
8914                {
8915                    "op": "upsert",
8916                    "table": "tasks",
8917                    "rowId": "t1",
8918                    "baseVersion": null,
8919                    "values": {"id": "t1", "project_id": null,
8920                        "payload": {"$bytes": "0a"}}
8921                },
8922                second_op
8923            ]);
8924            connection
8925                .execute(
8926                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8927                    rusqlite::params![planted.to_string(), stale],
8928                )
8929                .expect("plant legacy intent");
8930            drop(connection);
8931
8932            let reopened = open(&path);
8933            let journal = reopened
8934                .commit_outcome(&stale)
8935                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8936                .unwrap_or_else(|| panic!("{label}: journal entry"));
8937            assert!(
8938                matches!(journal.status, CommitOutcomeStatus::Rejected),
8939                "{label}"
8940            );
8941            assert!(journal.operations.is_none(), "{label}");
8942            assert!(
8943                reopened
8944                    .commit_outcomes(CommitOutcomeQuery::default())
8945                    .is_ok(),
8946                "{label}: outcome list query"
8947            );
8948            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8949            drop(reopened);
8950            let _ = std::fs::remove_file(&path);
8951        }
8952    }
8953
8954    #[test]
8955    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8956        let schema = |version: i32, extra: bool| {
8957            let mut columns = vec![
8958                json!({"name":"id","type":"string","nullable":false}),
8959                json!({"name":"project_id","type":"string","nullable":false}),
8960                json!({"name":"payload","type":"bytes","nullable":true}),
8961            ];
8962            if extra {
8963                columns.push(json!({"name":"later","type":"string","nullable":true}));
8964            }
8965            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8966                "scopes":[{"pattern":"project:{project_id}"}]}]})
8967        };
8968        for (label, bump) in [
8969            ("same-version recreation", false),
8970            ("version-bump reset", true),
8971            ("version-only bump reset", true),
8972        ] {
8973            let path = std::env::temp_dir().join(format!(
8974                "syncular-legacy-reset-{}-{}.db",
8975                label.replace(' ', "-"),
8976                uuid::Uuid::new_v4()
8977            ));
8978            let v1 = schema(1, false);
8979            let open = |schema: &Value, path: &std::path::Path| {
8980                SyncClient::open_path(
8981                    "legacy-reset".into(),
8982                    schema,
8983                    ClientLimits::default(),
8984                    path.to_str().unwrap(),
8985                )
8986                .expect("open client")
8987            };
8988            let row = |id: &str| {
8989                Map::from_iter([
8990                    ("id".to_owned(), json!(id)),
8991                    ("project_id".to_owned(), json!("p1")),
8992                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8993                ])
8994            };
8995            let upsert = |values| {
8996                vec![Mutation::Upsert {
8997                    table: "tasks".into(),
8998                    values,
8999                    base_version: None,
9000                }]
9001            };
9002            let mut client = open(&v1, &path);
9003            client.create_synced_tables().expect("create synced tables");
9004            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9005            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9006            drop(client);
9007
9008            let connection = Connection::open(&path).expect("raw connection");
9009            let planted = json!([{
9010                "op": "upsert",
9011                "table": "tasks",
9012                "rowId": "t1",
9013                "baseVersion": null,
9014                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9015            }]);
9016            connection
9017                .execute(
9018                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9019                    rusqlite::params![planted.to_string(), stale],
9020                )
9021                .expect("plant legacy intent");
9022            drop(connection);
9023
9024            let target = if bump {
9025                schema(2, label == "version-bump reset")
9026            } else {
9027                v1.clone()
9028            };
9029            let reopened = open(&target, &path);
9030            let rejection = reopened
9031                .rejections()
9032                .iter()
9033                .find(|rejection| rejection.client_commit_id == stale)
9034                .unwrap_or_else(|| panic!("{label}: rejection"));
9035            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9036            assert_eq!(
9037                rejection
9038                    .details
9039                    .as_ref()
9040                    .and_then(|details| details.reason.as_deref()),
9041                Some(INVALID_STORED_VALUES_REASON),
9042                "{label}"
9043            );
9044            assert!(rejection.operation.is_some(), "{label}");
9045            assert_eq!(
9046                reopened.pending_commit_ids(),
9047                vec![successor.clone()],
9048                "{label}"
9049            );
9050            // The successor survives and still encodes. A bump that adds a
9051            // column leaves the older full-row upsert partial over the empty
9052            // base, so its visible row appears when the server answers it
9053            // (§7.1); the same-version recreation replays it locally.
9054            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9055            if label == "version-bump reset" {
9056                assert_eq!(
9057                    reopened
9058                        .query("SELECT id FROM tasks ORDER BY id", &[])
9059                        .unwrap(),
9060                    Vec::<Map<String, Value>>::new(),
9061                    "{label}"
9062                );
9063                // The guarded bump reset owns the publication, so its change
9064                // batch carries the recovery's rejection, outcome, and table
9065                // domains at the revision it advanced to.
9066                let mut next = reopened;
9067                let revision = next.local_revision();
9068                let batches = next.drain_change_batches();
9069                assert!(
9070                    batches.iter().any(|batch| batch.rejections_changed
9071                        && batch.outcomes_changed
9072                        && batch.tables.iter().any(|change| change.table == "tasks")
9073                        && batch.revision == revision.to_string()),
9074                    "{label}: {batches:?}"
9075                );
9076                next.mutate(upsert(Map::from_iter([
9077                    ("id".to_owned(), json!("t3")),
9078                    ("project_id".to_owned(), json!("p1")),
9079                    ("payload".to_owned(), json!({"$bytes": "0b"})),
9080                    ("later".to_owned(), json!("v2")),
9081                ])))
9082                .expect("v2 authoring after the reset");
9083                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9084                assert_eq!(next.pending_payloads().len(), 2, "{label}");
9085                drop(next);
9086            } else {
9087                assert_eq!(
9088                    reopened
9089                        .query("SELECT id FROM tasks ORDER BY id", &[])
9090                        .unwrap(),
9091                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9092                    "{label}"
9093                );
9094                drop(reopened);
9095            }
9096
9097            // Queue and corrupt a new commit while the client stays open so only
9098            // recreation can recover it.
9099            if !bump {
9100                let mut recreated = open(&v1, &path);
9101                let fresh = recreated
9102                    .mutate(upsert(row("t3")))
9103                    .expect("fresh commit while open");
9104                recreated.drain_change_batches();
9105                let planted_fresh = json!([{
9106                    "op": "upsert",
9107                    "table": "tasks",
9108                    "rowId": "t3",
9109                    "baseVersion": null,
9110                    "values": {"id": "t3", "project_id": "p1",
9111                        "payload": {"$bytes": "zz"}}
9112                }]);
9113                let connection = Connection::open(&path).expect("raw connection");
9114                let affected = connection
9115                    .execute(
9116                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9117                        rusqlite::params![planted_fresh.to_string(), fresh],
9118                    )
9119                    .expect("plant fresh legacy intent");
9120                assert_eq!(
9121                    affected, 1,
9122                    "{label}: the in-place plant must hit the queued commit"
9123                );
9124                drop(connection);
9125                let revision_before = recreated.local_revision();
9126                recreated
9127                    .recreate_with_schema(&v1)
9128                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9129                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9130                let fresh_rejection = recreated
9131                    .rejections()
9132                    .iter()
9133                    .find(|rejection| rejection.client_commit_id == fresh)
9134                    .expect("the in-place recovery rejection");
9135                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9136                assert_eq!(
9137                    fresh_rejection
9138                        .details
9139                        .as_ref()
9140                        .and_then(|details| details.reason.as_deref()),
9141                    Some(INVALID_STORED_VALUES_REASON),
9142                    "{label}"
9143                );
9144                assert!(
9145                    matches!(
9146                        recreated
9147                            .commit_outcome(&fresh)
9148                            .expect("outcome read")
9149                            .map(|outcome| outcome.status),
9150                        Some(CommitOutcomeStatus::Rejected)
9151                    ),
9152                    "{label}"
9153                );
9154                assert!(recreated.local_revision() > revision_before, "{label}");
9155                let revision = recreated.local_revision();
9156                let batches = recreated.drain_change_batches();
9157                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9158                assert!(
9159                    batches.iter().any(|batch| batch.rejections_changed
9160                        && batch.outcomes_changed
9161                        && batch.tables.iter().any(|change| change.table == "tasks")
9162                        && batch.revision == revision.to_string()),
9163                    "{label}: {batches:?}"
9164                );
9165                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9166                assert_eq!(
9167                    recreated
9168                        .query("SELECT id FROM tasks ORDER BY id", &[])
9169                        .unwrap(),
9170                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9171                    "{label}: the recovered commit leaves no projection"
9172                );
9173                drop(recreated);
9174            }
9175            let _ = std::fs::remove_file(&path);
9176        }
9177    }
9178
9179    #[test]
9180    fn legacy_values_recover_over_a_synced_authoritative_row() {
9181        let path = std::env::temp_dir().join(format!(
9182            "syncular-authoritative-row-{}.db",
9183            uuid::Uuid::new_v4()
9184        ));
9185        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9186            {"name":"id","type":"string","nullable":false},
9187            {"name":"project_id","type":"string","nullable":false},
9188            {"name":"title","type":"string","nullable":false},
9189            {"name":"payload","type":"bytes","nullable":true}],
9190            "scopes":[{"pattern":"project:{project_id}"}]}]});
9191        let open = |path: &std::path::Path| {
9192            SyncClient::open_path(
9193                "authoritative-row".into(),
9194                &schema,
9195                ClientLimits::default(),
9196                path.to_str().unwrap(),
9197            )
9198            .expect("open client")
9199        };
9200        let row = |title: &str| {
9201            Map::from_iter([
9202                ("id".to_owned(), json!("t1")),
9203                ("project_id".to_owned(), json!("p1")),
9204                ("title".to_owned(), json!(title)),
9205                ("payload".to_owned(), json!({"$bytes": "0a"})),
9206            ])
9207        };
9208        let upsert = |values| {
9209            vec![Mutation::Upsert {
9210                table: "tasks".into(),
9211                values,
9212                base_version: None,
9213            }]
9214        };
9215        let mut client = open(&path);
9216        client.create_synced_tables().expect("create synced tables");
9217        // A server-confirmed row: base and visible carry the authoritative
9218        // values at version 1.
9219        client
9220            .conn
9221            .execute_batch(
9222                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9223                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9224            )
9225            .expect("seed authoritative row");
9226        let older = client.mutate(upsert(row("older"))).expect("older");
9227        let newer = client.mutate(upsert(row("newer"))).expect("newer");
9228        let successor = client.mutate(upsert(row("successor"))).expect("successor");
9229        drop(client);
9230
9231        let connection = Connection::open(&path).expect("raw connection");
9232        for commit_id in [&older, &newer] {
9233            let planted = json!([{
9234                "op": "upsert",
9235                "table": "tasks",
9236                "rowId": "t1",
9237                "baseVersion": null,
9238                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9239                    "payload": {"$bytes": "zz"}}
9240            }]);
9241            connection
9242                .execute(
9243                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9244                    rusqlite::params![planted.to_string(), commit_id],
9245                )
9246                .expect("plant legacy intent");
9247        }
9248        drop(connection);
9249
9250        let reopened = open(&path);
9251        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9252        assert_eq!(
9253            reopened
9254                .rejections()
9255                .iter()
9256                .map(|rejection| rejection.client_commit_id.clone())
9257                .collect::<Vec<_>>(),
9258            vec![older.clone(), newer.clone()]
9259        );
9260        // The pending successor overlays the restored authoritative row, which
9261        // keeps its server version.
9262        assert_eq!(
9263            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9264            Ok(vec![Map::from_iter([
9265                ("id".to_owned(), json!("t1")),
9266                ("title".to_owned(), json!("successor")),
9267                ("_syncular_version".to_owned(), json!(1)),
9268            ])])
9269        );
9270
9271        // A later rejection of the surviving successor restores the
9272        // authoritative values and version exactly.
9273        drop(reopened);
9274        let connection = Connection::open(&path).expect("raw connection");
9275        connection
9276            .execute(
9277                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9278                rusqlite::params![
9279                    json!([{
9280                        "op": "upsert",
9281                        "table": "tasks",
9282                        "rowId": "t1",
9283                        "baseVersion": null,
9284                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9285                            "payload": {"$bytes": "zz"}}
9286                    }])
9287                    .to_string(),
9288                    successor
9289                ],
9290            )
9291            .expect("plant successor intent");
9292        drop(connection);
9293        let late = open(&path);
9294        assert!(late.pending_commit_ids().is_empty());
9295        assert_eq!(
9296            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9297            Ok(vec![Map::from_iter([
9298                ("id".to_owned(), json!("t1")),
9299                ("title".to_owned(), json!("authoritative")),
9300                ("_syncular_version".to_owned(), json!(1)),
9301            ])])
9302        );
9303        drop(late);
9304        let _ = std::fs::remove_file(&path);
9305    }
9306
9307    #[test]
9308    fn a_failed_value_recovery_commit_restores_every_side_effect() {
9309        let path = std::env::temp_dir().join(format!(
9310            "syncular-value-recovery-commit-{}.db",
9311            uuid::Uuid::new_v4()
9312        ));
9313        let path_str = path.to_str().expect("path");
9314        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9315            {"name":"id","type":"string","nullable":false},
9316            {"name":"project_id","type":"string","nullable":false},
9317            {"name":"payload","type":"bytes","nullable":true}],
9318            "scopes":[{"pattern":"project:{project_id}"}]}]});
9319        // A rollback-journal replica, so a second read connection can hold
9320        // SHARED across the recovery transaction's COMMIT.
9321        let conn = Connection::open(path_str).expect("replica connection");
9322        let mut client = SyncClient::with_connection(
9323            "value-recovery-commit".into(),
9324            &schema,
9325            ClientLimits::default(),
9326            conn,
9327        )
9328        .expect("replica");
9329        assert_eq!(
9330            client
9331                .conn
9332                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9333                .expect("journal mode"),
9334            "delete"
9335        );
9336        client.create_synced_tables().expect("create synced tables");
9337        let stale = client
9338            .mutate(vec![Mutation::Upsert {
9339                table: "tasks".into(),
9340                values: Map::from_iter([
9341                    ("id".to_owned(), json!("t1")),
9342                    ("project_id".to_owned(), json!("p1")),
9343                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9344                ]),
9345                base_version: None,
9346            }])
9347            .expect("queued write");
9348        client.drain_change_batches();
9349        let affected = client
9350            .conn
9351            .execute(
9352                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9353                rusqlite::params![
9354                    json!([{
9355                        "op": "upsert",
9356                        "table": "tasks",
9357                        "rowId": "t1",
9358                        "baseVersion": null,
9359                        "values": {"id": "t1", "project_id": "p1",
9360                            "payload": {"$bytes": "zz"}}
9361                    }])
9362                    .to_string(),
9363                    stale
9364                ],
9365            )
9366            .expect("plant legacy intent");
9367        assert_eq!(affected, 1, "the planted row must exist");
9368        let revision = client.local_revision();
9369        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9370        let prior_outbox = client.pending_commit_ids();
9371        let prior_rejections = client.rejections().len();
9372        let prior_changes = client.change_queue.len();
9373        let prior_intents = client.sync_intent_queue.len();
9374        let prior_failed = client.failed_commits.len();
9375        // The reader holds SHARED for the whole attempt: the recovery runs to
9376        // its publication and the COMMIT fails.
9377        let reader = Connection::open(path_str).expect("reader");
9378        reader
9379            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9380            .expect("reader snapshot");
9381        client
9382            .conn
9383            .busy_timeout(std::time::Duration::ZERO)
9384            .expect("busy timeout");
9385        let error = client
9386            .recreate_with_schema(&schema)
9387            .expect_err("a blocked COMMIT must refuse the recovery");
9388        assert!(error.starts_with("client.storage_busy:"), "{error}");
9389        assert!(client.conn.is_autocommit(), "the transaction is released");
9390        assert_eq!(client.local_revision(), revision);
9391        assert_eq!(client.pending_commit_ids(), prior_outbox);
9392        assert_eq!(client.rejections().len(), prior_rejections);
9393        assert_eq!(client.change_queue.len(), prior_changes);
9394        assert_eq!(client.sync_intent_queue.len(), prior_intents);
9395        assert_eq!(client.failed_commits.len(), prior_failed);
9396        assert_eq!(
9397            client.query("SELECT * FROM tasks", &[]).expect("rows"),
9398            rows
9399        );
9400        assert!(
9401            client
9402                .commit_outcome(&stale)
9403                .expect("outcome read")
9404                .is_none(),
9405            "the rolled-back recovery kept no outcome"
9406        );
9407        // The retry runs the same publication branch to completion.
9408        reader.execute_batch("ROLLBACK").expect("release reader");
9409        client
9410            .recreate_with_schema(&schema)
9411            .expect("retry after the blocked commit");
9412        assert!(client.pending_commit_ids().is_empty());
9413        assert_eq!(client.rejections().len(), prior_rejections + 1);
9414        assert!(client.local_revision() > revision);
9415        let batches = client.drain_change_batches();
9416        assert!(
9417            batches.iter().any(|batch| batch.rejections_changed
9418                && batch.outcomes_changed
9419                && batch.tables.iter().any(|change| change.table == "tasks")),
9420            "{batches:?}"
9421        );
9422        drop(client);
9423        let _ = std::fs::remove_file(&path);
9424    }
9425
9426    #[cfg(feature = "e2ee")]
9427    #[test]
9428    fn a_failed_recovery_restores_retained_failed_commits() {
9429        let path = std::env::temp_dir().join(format!(
9430            "syncular-value-recovery-retention-{}.db",
9431            uuid::Uuid::new_v4()
9432        ));
9433        let path_str = path.to_str().expect("path");
9434        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9435            {"name":"id","type":"string","nullable":false},
9436            {"name":"project_id","type":"string","nullable":false},
9437            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9438            "scopes":[{"pattern":"project:{project_id}"}]}]});
9439        let conn = Connection::open(path_str).expect("replica connection");
9440        let mut client = SyncClient::with_connection(
9441            "value-recovery-retention".into(),
9442            &schema,
9443            ClientLimits::default(),
9444            conn,
9445        )
9446        .expect("replica");
9447        client.create_synced_tables().expect("create synced tables");
9448        client.set_retain_failed_commits(true).expect("retention");
9449        // No keys configured: the §5.11 seal fails, so retention is exercised.
9450        client.set_encryption(crate::values::EncryptionConfig::default());
9451        client
9452            .mutate(vec![Mutation::Upsert {
9453                table: "secrets".into(),
9454                values: Map::from_iter([
9455                    ("id".to_owned(), json!("s1")),
9456                    ("project_id".to_owned(), json!("p1")),
9457                    ("note".to_owned(), json!("secret")),
9458                ]),
9459                base_version: None,
9460            }])
9461            .expect("queued write");
9462        client.drain_change_batches();
9463        let revision = client.local_revision();
9464        let prior_outbox = client.pending_commit_ids();
9465        let prior_rejections = client.rejections().len();
9466        let prior_failed = client.failed_commits.len();
9467        let reader = Connection::open(path_str).expect("reader");
9468        reader
9469            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9470            .expect("reader snapshot");
9471        client
9472            .conn
9473            .busy_timeout(std::time::Duration::ZERO)
9474            .expect("busy timeout");
9475        let error = client
9476            .drop_unencodable_outbox(true, None)
9477            .expect_err("a blocked COMMIT must refuse the recovery");
9478        assert!(error.starts_with("client.storage_busy:"), "{error}");
9479        assert_eq!(client.failed_commits.len(), prior_failed);
9480        assert_eq!(client.pending_commit_ids(), prior_outbox);
9481        assert_eq!(client.rejections().len(), prior_rejections);
9482        assert_eq!(client.local_revision(), revision);
9483        // The retry retains the failed commit and drops it from the outbox.
9484        reader.execute_batch("ROLLBACK").expect("release reader");
9485        client
9486            .drop_unencodable_outbox(true, None)
9487            .expect("retry after the blocked commit");
9488        assert_eq!(client.failed_commits.len(), prior_failed + 1);
9489        assert!(client.pending_commit_ids().is_empty());
9490        assert_eq!(client.rejections().len(), prior_rejections + 1);
9491        drop(client);
9492        let _ = std::fs::remove_file(&path);
9493    }
9494
9495    #[test]
9496    fn client_error_maps_legacy_codes_and_static_messages() {
9497        let code_only = ClientError::from("sync.unknown_table".to_owned());
9498        assert_eq!(code_only.code, "sync.unknown_table");
9499        assert_eq!(code_only.message, "the commit targets an unknown table");
9500        assert!(code_only.details.is_none());
9501
9502        let dynamic = ClientError::from(
9503            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9504        );
9505        assert_eq!(dynamic.code, "sync.invalid_request");
9506        assert_eq!(dynamic.message, "the authoring request is invalid");
9507        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9508            .as_str()
9509            .unwrap()
9510            .contains("patch cannot write scope column"));
9511
9512        let unclassified =
9513            ClientError::from("a commit must contain at least one operation".to_owned());
9514        assert_eq!(unclassified.code, "client.failed");
9515        assert_eq!(unclassified.message, "the local authoring operation failed");
9516        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9517            .as_str()
9518            .unwrap()
9519            .contains("at least one operation"));
9520
9521        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
9522        let metadata = ClientError::from(QueryReadFailure {
9523            code: None,
9524            sqlite_code: Some(1),
9525            sqlite_message: Some("generic SQLite prose".to_owned()),
9526            rollback_failure: Some(Box::new(QueryReadFailure::from(
9527                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9528            ))),
9529            message: "unclassified read".to_owned(),
9530        });
9531        assert_eq!(metadata.code, "client.failed");
9532        assert_eq!(metadata.message, "the local authoring operation failed");
9533        let details = metadata.details.as_ref().unwrap();
9534        assert_eq!(details["sqliteCode"], 1);
9535        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9536        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9537        assert_eq!(details["legacyCause"], "unclassified read");
9538    }
9539
9540    #[test]
9541    fn schema_marker_read_keeps_storage_classification() {
9542        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9543            rusqlite::ffi::Error::new(5),
9544            Some("database is locked".to_owned()),
9545        ));
9546        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9547        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9548            rusqlite::ffi::Error::new(1),
9549            Some("unreadable".to_owned()),
9550        ));
9551        assert_eq!(
9552            invalid,
9553            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9554        );
9555
9556        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
9557        // and the guard writes nothing.
9558        let path =
9559            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9560        let setup = Connection::open(&path).expect("setup connection");
9561        setup
9562            .execute_batch(
9563                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9564            )
9565            .unwrap();
9566        let locker = Connection::open(&path).expect("locker connection");
9567        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9568        let reader = Connection::open(&path).expect("reader connection");
9569        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9570        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9571        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9572        locker.execute_batch("ROLLBACK").unwrap();
9573        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9574        let rows = setup
9575            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9576                row.get::<_, i64>(0)
9577            })
9578            .unwrap();
9579        assert_eq!(rows, 1, "the guard performed no writes");
9580        drop(reader);
9581        drop(locker);
9582        drop(setup);
9583        let _ = std::fs::remove_file(&path);
9584    }
9585
9586    #[cfg(feature = "crdt-yjs")]
9587    #[test]
9588    fn crdt_authoring_pre_read_preserves_storage_classification() {
9589        let path =
9590            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9591        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9592            {"name":"id","type":"string","nullable":false},
9593            {"name":"project_id","type":"string","nullable":false},
9594            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9595            "scopes":[{"pattern":"project:{project_id}"}]}]});
9596        let mut client = SyncClient::open_path(
9597            "crdt-busy".into(),
9598            &schema,
9599            ClientLimits::default(),
9600            path.to_str().unwrap(),
9601        )
9602        .expect("open client");
9603        client.create_synced_tables().expect("create synced tables");
9604        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9605
9606        let locker = Connection::open(&path).expect("locker connection");
9607        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9608        let busy = client
9609            .crdt_apply_update("notes", "n1", "doc", &[])
9610            .expect_err("busy crdt pre-read");
9611        assert_eq!(busy.code, "client.storage_busy");
9612        assert!(busy.retryable);
9613        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9614        assert!(client.pending_commit_ids().is_empty());
9615        assert_eq!(client.local_revision(), 0);
9616
9617        locker.execute_batch("ROLLBACK").unwrap();
9618        drop(locker);
9619        drop(client);
9620        let _ = std::fs::remove_file(&path);
9621    }
9622
9623    #[test]
9624    fn large_unique_import_copies_no_unrelated_tables() {
9625        let (mut client, image, table) = unique_import_fixture(10240);
9626        let pending = client.pending_commit_ids();
9627        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9628            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9629        assert!(matches!(
9630            client.apply_sqlite_image(
9631                &image,
9632                &table,
9633                true,
9634                &[("project_id".into(), vec!["p1".into()])],
9635                10240,
9636                7,
9637                "digest"
9638            ),
9639            Ok(10240)
9640        ));
9641        assert_eq!(client.overlay_rebuild_count.get(), 0);
9642        assert_eq!(client.pending_commit_ids(), pending);
9643        assert_eq!(
9644            client
9645                .query("SELECT count(*) AS n FROM tasks", &[])
9646                .unwrap()[0]["n"],
9647            10241
9648        );
9649        assert_eq!(
9650            client
9651                .query("SELECT count(*) AS n FROM tasks_fts", &[])
9652                .unwrap()[0]["n"],
9653            10241
9654        );
9655        assert_eq!(
9656            client
9657                .query("SELECT count(*) AS n FROM notes_fts", &[])
9658                .unwrap()[0]["n"],
9659            8192
9660        );
9661    }
9662
9663    #[test]
9664    #[ignore = "manual scaling benchmark; see bench/README.md"]
9665    fn benchmark_unique_image_import() {
9666        let mut samples = Vec::new();
9667        for rows in [10240, 51200, 102400] {
9668            for trial in 0..4 {
9669                let (mut client, image, table) = unique_import_fixture(rows);
9670                let start = std::time::Instant::now();
9671                assert!(
9672                    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)
9673                );
9674                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9675                assert_eq!(
9676                    client
9677                        .query("SELECT count(*) AS n FROM tasks", &[])
9678                        .unwrap()[0]["n"],
9679                    rows + 1
9680                );
9681                assert_eq!(
9682                    client
9683                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
9684                        .unwrap()[0]["n"],
9685                    rows + 1
9686                );
9687                assert_eq!(
9688                    client
9689                        .query("SELECT count(*) AS n FROM notes_fts", &[])
9690                        .unwrap()[0]["n"],
9691                    8192
9692                );
9693                assert_eq!(client.pending_commit_ids().len(), 1);
9694                if trial > 0 {
9695                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9696                }
9697            }
9698        }
9699        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});
9700        let output = serde_json::to_string_pretty(&artifact).unwrap();
9701        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9702            use std::io::Write;
9703            let mut file = std::fs::OpenOptions::new()
9704                .write(true)
9705                .create_new(true)
9706                .open(path)
9707                .unwrap();
9708            file.write_all(output.as_bytes()).unwrap();
9709        } else {
9710            println!("{output}");
9711        }
9712    }
9713
9714    #[test]
9715    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9716        let schema = json!({
9717            "version": 1,
9718            "tables": [{
9719                "name": "catalogue_codes",
9720                "primaryKey": "id",
9721                "columns": [
9722                    { "name": "id", "type": "string", "nullable": false },
9723                    { "name": "release_id", "type": "string", "nullable": false },
9724                    { "name": "code", "type": "string", "nullable": false },
9725                    { "name": "title", "type": "string", "nullable": false }
9726                ],
9727                "scopes": [{ "pattern": "release:{release_id}" }],
9728                "ftsIndexes": [{
9729                    "name": "catalogue_codes_fts",
9730                    "columns": ["code", "title"],
9731                    "tokenize": "unicode61 remove_diacritics 2"
9732                }]
9733            }]
9734        });
9735        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9736            .expect("FTS5 client");
9737        let insert_trigger: String = client
9738            .conn
9739            .query_row(
9740                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9741                [],
9742                |row| row.get(0),
9743            )
9744            .expect("insert trigger");
9745        let replace_guard: String = client
9746            .conn
9747            .query_row(
9748                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9749                [],
9750                |row| row.get(0),
9751            )
9752            .expect("replace guard");
9753        assert!(!insert_trigger.contains("DELETE FROM"));
9754        assert!(replace_guard.contains("BEFORE INSERT"));
9755        assert!(replace_guard.contains("WHEN EXISTS"));
9756        let search = |client: &SyncClient, query: &str| {
9757            client
9758                .query(
9759                    "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",
9760                    &[Value::from(query)],
9761                )
9762                .expect("FTS query")
9763        };
9764
9765        client
9766            .mutate(vec![Mutation::Upsert {
9767                table: "catalogue_codes".to_owned(),
9768                values: Map::from_iter([
9769                    ("id".to_owned(), Value::from("c1")),
9770                    ("release_id".to_owned(), Value::from("r1")),
9771                    ("code".to_owned(), Value::from("A01")),
9772                    ("title".to_owned(), Value::from("Cholera")),
9773                ]),
9774                base_version: None,
9775            }])
9776            .expect("insert code");
9777        assert_eq!(search(&client, "cholera").len(), 1);
9778        let prior = client
9779            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9780            .unwrap();
9781        client
9782            .conn
9783            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9784            .unwrap();
9785        client.create_synced_tables().unwrap();
9786        assert_eq!(
9787            client
9788                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9789                .unwrap(),
9790            prior
9791        );
9792        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();
9793        let rendered = serde_json::to_string(&plan).unwrap();
9794        assert!(rendered.contains("COVERING INDEX"));
9795        assert!(rendered.contains("INDEX 0:="));
9796        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9797        assert_eq!(search(&client, "cholera").len(), 1);
9798
9799        client
9800            .mutate(vec![Mutation::Upsert {
9801                table: "catalogue_codes".to_owned(),
9802                values: Map::from_iter([
9803                    ("id".to_owned(), Value::from("c1")),
9804                    ("release_id".to_owned(), Value::from("r1")),
9805                    ("code".to_owned(), Value::from("A01")),
9806                    ("title".to_owned(), Value::from("Enteric infection")),
9807                ]),
9808                base_version: None,
9809            }])
9810            .expect("update code");
9811        assert!(search(&client, "cholera").is_empty());
9812        assert_eq!(search(&client, "enteric").len(), 1);
9813
9814        client
9815            .mutate(vec![Mutation::Delete {
9816                table: "catalogue_codes".to_owned(),
9817                row_id: "c1".to_owned(),
9818                base_version: None,
9819            }])
9820            .expect("delete code");
9821        assert!(search(&client, "enteric").is_empty());
9822    }
9823
9824    #[test]
9825    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9826        let schema = json!({
9827            "version": 1,
9828            "tables": [{
9829                "name": "patient_notes",
9830                "primaryKey": "id",
9831                "columns": [
9832                    { "name": "id", "type": "string", "nullable": false },
9833                    { "name": "practice_id", "type": "string", "nullable": false },
9834                    { "name": "encryption_key_id", "type": "string", "nullable": false },
9835                    { "name": "title", "type": "string", "nullable": false }
9836                ],
9837                "scopes": [{ "pattern": "practice:{practice_id}" }],
9838                "ftsIndexes": [{
9839                    "name": "patient_notes_fts",
9840                    "columns": ["title"],
9841                    "tokenize": "unicode61 remove_diacritics 2"
9842                }]
9843            }]
9844        });
9845        let mut client = SyncClient::new(
9846            "local-purge-test".to_owned(),
9847            &schema,
9848            ClientLimits::default(),
9849        )
9850        .expect("local purge client");
9851        client
9852            .conn
9853            .execute(
9854                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9855                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9856                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9857                [],
9858            )
9859            .expect("seed base rows");
9860        client.overlay_dirty.set(true);
9861        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9862
9863        let note = |id: &str, key_id: &str, title: &str| {
9864            Map::from_iter([
9865                ("id".to_owned(), Value::from(id)),
9866                ("practice_id".to_owned(), Value::from("practice-1")),
9867                ("encryption_key_id".to_owned(), Value::from(key_id)),
9868                ("title".to_owned(), Value::from(title)),
9869            ])
9870        };
9871        let doomed = client
9872            .mutate(vec![
9873                Mutation::Upsert {
9874                    table: "patient_notes".to_owned(),
9875                    values: note("target", "key-revoked", "Target changed"),
9876                    base_version: None,
9877                },
9878                Mutation::Upsert {
9879                    table: "patient_notes".to_owned(),
9880                    values: note("unrelated", "key-held", "Unrelated changed"),
9881                    base_version: None,
9882                },
9883            ])
9884            .expect("doomed commit");
9885        let kept = client
9886            .mutate(vec![Mutation::Upsert {
9887                table: "patient_notes".to_owned(),
9888                values: note("kept", "key-held", "Kept optimistic"),
9889                base_version: None,
9890            }])
9891            .expect("kept commit");
9892        client.drain_change_batches();
9893
9894        let input = LocalDataPurgeInput {
9895            purge_id: "purge-001".to_owned(),
9896            targets: vec![LocalDataPurgeTarget {
9897                table: "patient_notes".to_owned(),
9898                selectors: BTreeMap::from([(
9899                    "encryption_key_id".to_owned(),
9900                    vec!["key-revoked".to_owned()],
9901                )]),
9902            }],
9903        };
9904        assert_eq!(
9905            client.purge_local_data(&input).expect("apply purge"),
9906            LocalDataPurgeResult {
9907                already_applied: false,
9908                purged_rows: 1,
9909                dropped_commits: 1,
9910            }
9911        );
9912        let rows = client
9913            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9914            .expect("visible rows");
9915        assert_eq!(rows.len(), 2);
9916        assert_eq!(rows[0]["id"], "kept");
9917        assert_eq!(rows[1]["id"], "unrelated");
9918        assert_eq!(rows[1]["title"], "Unrelated original");
9919        let fts = client
9920            .query(
9921                "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'",
9922                &[],
9923            )
9924            .expect("fts query");
9925        assert!(fts.is_empty());
9926        assert_eq!(client.outbox.len(), 1);
9927        assert_eq!(client.outbox[0].client_commit_id, kept);
9928        let outcome = client
9929            .commit_outcome(&doomed)
9930            .expect("read doomed outcome")
9931            .expect("doomed outcome");
9932        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9933        match &outcome.results[0] {
9934            CommitOperationOutcome::Error { rejection } => {
9935                assert_eq!(rejection.code, "client.local_data_purged");
9936                assert_eq!(rejection.client_commit_id, doomed);
9937            }
9938            other => panic!("expected local purge rejection, got {other:?}"),
9939        }
9940        assert_eq!(client.drain_change_batches().len(), 1);
9941        assert_eq!(
9942            client.purge_local_data(&input).expect("retry purge"),
9943            LocalDataPurgeResult {
9944                already_applied: true,
9945                purged_rows: 0,
9946                dropped_commits: 0,
9947            }
9948        );
9949        let conflicting = LocalDataPurgeInput {
9950            purge_id: input.purge_id.clone(),
9951            targets: vec![LocalDataPurgeTarget {
9952                table: "patient_notes".to_owned(),
9953                selectors: BTreeMap::from([(
9954                    "encryption_key_id".to_owned(),
9955                    vec!["key-held".to_owned()],
9956                )]),
9957            }],
9958        };
9959        assert!(client
9960            .purge_local_data(&conflicting)
9961            .expect_err("id collision must fail")
9962            .contains("already used with a different plan"));
9963    }
9964
9965    /// Mirror `SyncClient::sync` through the split round API.
9966    fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
9967        let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
9968            Ok(prepared) => prepared,
9969            Err(outcome) => return *outcome,
9970        };
9971        loop {
9972            let completed = crate::round::test_support::drive_round(next, transport);
9973            match client.apply_sync_round(completed) {
9974                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
9975                crate::AppliedSyncRound::Complete {
9976                    outcome, controls, ..
9977                } => {
9978                    if client.transport_enabled() {
9979                        for text in controls {
9980                            let _ = transport.realtime_send(&text);
9981                        }
9982                    } else {
9983                        client.disconnect_realtime(transport);
9984                    }
9985                    return outcome;
9986                }
9987            }
9988        }
9989    }
9990
9991    #[derive(Clone)]
9992    enum RoundReplyScript {
9993        Response(Message),
9994        /// Answer every pushed commit with an applied result.
9995        PushAck,
9996        Error(TransportError),
9997    }
9998
9999    /// Serves one scripted reply and records every request, segment batch, and
10000    /// upload the round makes.
10001    struct SplitParityTransport {
10002        script: RoundReplyScript,
10003        segments: BTreeMap<String, Vec<u8>>,
10004        requests: Vec<Message>,
10005        batches: Vec<Vec<String>>,
10006        uploads: Vec<String>,
10007        sync_calls: usize,
10008        realtime_sync_calls: usize,
10009    }
10010
10011    impl SplitParityTransport {
10012        fn new(script: RoundReplyScript) -> Self {
10013            Self {
10014                script,
10015                segments: BTreeMap::new(),
10016                requests: Vec::new(),
10017                batches: Vec::new(),
10018                uploads: Vec::new(),
10019                sync_calls: 0,
10020                realtime_sync_calls: 0,
10021            }
10022        }
10023
10024        fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10025            Self {
10026                segments,
10027                ..Self::new(script)
10028            }
10029        }
10030
10031        fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10032            let message =
10033                decode_message(request).expect("the client encodes a legal round request");
10034            self.requests.push(message.clone());
10035            match &self.script {
10036                RoundReplyScript::Response(response) => Ok(encode_message(response)),
10037                RoundReplyScript::Error(error) => Err(error.clone()),
10038                RoundReplyScript::PushAck => {
10039                    let mut frames = vec![Frame::RespHeader {
10040                        required_schema_version: None,
10041                        latest_schema_version: None,
10042                        log_epoch: Some("epoch-1".into()),
10043                        reset_required: Some(false),
10044                    }];
10045                    let mut commit_seq = 0;
10046                    for frame in &message.frames {
10047                        if let Frame::PushCommit {
10048                            client_commit_id,
10049                            operations,
10050                        } = frame
10051                        {
10052                            commit_seq += 1;
10053                            frames.push(Frame::PushResult {
10054                                client_commit_id: client_commit_id.clone(),
10055                                status: PushStatus::Applied,
10056                                commit_seq: Some(commit_seq),
10057                                results: (0..operations.len())
10058                                    .map(|op_index| OpResult::Applied {
10059                                        op_index: op_index as i32,
10060                                    })
10061                                    .collect(),
10062                            });
10063                        }
10064                    }
10065                    frames.push(Frame::SubStart {
10066                        id: "tasks".into(),
10067                        status: SubStatus::Active,
10068                        reason_code: String::new(),
10069                        effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10070                        bootstrap: false,
10071                    });
10072                    frames.push(Frame::SubEnd {
10073                        next_cursor: 0,
10074                        bootstrap_state: None,
10075                    });
10076                    Ok(encode_message(&Message {
10077                        wire_version: WIRE_VERSION,
10078                        msg_kind: MsgKind::Response,
10079                        frames,
10080                    }))
10081                }
10082            }
10083        }
10084    }
10085
10086    impl Transport for SplitParityTransport {
10087        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10088            self.sync_calls += 1;
10089            self.respond(request)
10090        }
10091
10092        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10093            self.realtime_sync_calls += 1;
10094            self.respond(request)
10095        }
10096        fn download_segment(
10097            &mut self,
10098            _request: &SegmentRequest,
10099            _on_progress: &mut dyn FnMut(u64),
10100        ) -> Result<Vec<u8>, TransportError> {
10101            Err(TransportError::new(
10102                "sync.transport_failed",
10103                "per-descriptor fetch",
10104            ))
10105        }
10106
10107        fn download_segments(
10108            &mut self,
10109            fetches: &[crate::SegmentFetch],
10110            on_progress: &mut (dyn FnMut(usize, u64) + Send),
10111        ) -> Vec<Result<Vec<u8>, TransportError>> {
10112            self.batches.push(
10113                fetches
10114                    .iter()
10115                    .map(|fetch| match fetch {
10116                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10117                        crate::SegmentFetch::Url(url) => url.clone(),
10118                    })
10119                    .collect(),
10120            );
10121            fetches
10122                .iter()
10123                .enumerate()
10124                .map(|(index, fetch)| {
10125                    let crate::SegmentFetch::Direct(request) = fetch else {
10126                        panic!("the descriptors are direct-endpoint requests")
10127                    };
10128                    let bytes =
10129                        self.segments
10130                            .get(&request.segment_id)
10131                            .cloned()
10132                            .ok_or_else(|| {
10133                                TransportError::new("transport.not_found", "missing segment body")
10134                            })?;
10135                    on_progress(index, bytes.len() as u64);
10136                    Ok(bytes)
10137                })
10138                .collect()
10139        }
10140
10141        fn blob_upload_grant(
10142            &mut self,
10143            _blob_id: &str,
10144            _byte_length: u64,
10145            _media_type: Option<&str>,
10146        ) -> Result<BlobUploadGrant, TransportError> {
10147            Ok(BlobUploadGrant::None)
10148        }
10149
10150        fn blob_upload(
10151            &mut self,
10152            blob_id: &str,
10153            _bytes: &[u8],
10154            _media_type: Option<&str>,
10155        ) -> Result<(), TransportError> {
10156            self.uploads.push(blob_id.to_owned());
10157            Ok(())
10158        }
10159
10160        fn realtime_connect(&mut self) -> Result<(), TransportError> {
10161            Ok(())
10162        }
10163
10164        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10165            Ok(())
10166        }
10167
10168        fn realtime_close(&mut self) -> Result<(), TransportError> {
10169            Ok(())
10170        }
10171    }
10172
10173    fn parity_clients() -> (SyncClient, SyncClient) {
10174        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10175            {"name":"id","type":"string","nullable":false},
10176            {"name":"project_id","type":"string","nullable":false}],
10177            "scopes":[{"pattern":"project:{project_id}"}]}]});
10178        let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10179            let mut client =
10180                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10181            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10182            client
10183                .subscribe(
10184                    "tasks".into(),
10185                    "tasks".into(),
10186                    vec![("project_id".into(), vec!["p1".into()])],
10187                    None,
10188                )
10189                .unwrap();
10190            client.subs[0].cursor = 0;
10191            client.subs[0].synced_once = true;
10192            client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10193            client.persist_sub(&client.subs[0]).unwrap();
10194            client.drain_change_batches();
10195            client
10196        });
10197        (exchange, split)
10198    }
10199
10200    fn pull_response(client: &SyncClient) -> Message {
10201        let row = encode_row_json(
10202            client.schema.table("tasks").unwrap(),
10203            "kept",
10204            &Map::from_iter([
10205                ("id".into(), json!("kept")),
10206                ("project_id".into(), json!("p1")),
10207            ]),
10208            &client.encryption,
10209        )
10210        .unwrap();
10211        Message {
10212            wire_version: WIRE_VERSION,
10213            msg_kind: MsgKind::Response,
10214            frames: vec![
10215                Frame::RespHeader {
10216                    required_schema_version: None,
10217                    latest_schema_version: None,
10218                    log_epoch: Some("epoch-1".into()),
10219                    reset_required: Some(false),
10220                },
10221                Frame::SubStart {
10222                    id: "tasks".into(),
10223                    status: SubStatus::Active,
10224                    reason_code: String::new(),
10225                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10226                    bootstrap: false,
10227                },
10228                Frame::Commit {
10229                    commit_seq: 1,
10230                    created_at_ms: 1,
10231                    actor_id: "writer".into(),
10232                    tables: vec!["tasks".into()],
10233                    changes: vec![ssp2::model::Change {
10234                        table_index: 0,
10235                        row_id: "kept".into(),
10236                        op: Op::Upsert,
10237                        row_version: Some(1),
10238                        scopes: vec![("project_id".into(), "p1".into())],
10239                        row: Some(row),
10240                    }],
10241                },
10242                Frame::SubEnd {
10243                    next_cursor: 1,
10244                    bootstrap_state: None,
10245                },
10246            ],
10247        }
10248    }
10249
10250    #[test]
10251    fn split_round_matches_exchange_for_a_pull() {
10252        let (mut exchange, mut split) = parity_clients();
10253        let response = pull_response(&exchange);
10254        let mut exchange_transport =
10255            SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10256        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10257
10258        let exchange_outcome = exchange.sync(&mut exchange_transport);
10259        let split_outcome = sync_split(&mut split, &mut split_transport);
10260        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10261        assert_eq!(
10262            exchange
10263                .query("SELECT id, project_id FROM tasks", &[])
10264                .unwrap(),
10265            split
10266                .query("SELECT id, project_id FROM tasks", &[])
10267                .unwrap()
10268        );
10269        assert_eq!(exchange_transport.requests.len(), 1);
10270        assert_eq!(split_transport.requests.len(), 1);
10271    }
10272
10273    #[test]
10274    fn split_round_matches_exchange_for_a_push_with_an_upload() {
10275        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10276            {"name":"id","type":"string","nullable":false},
10277            {"name":"project_id","type":"string","nullable":false},
10278            {"name":"file","type":"blob_ref","nullable":true}],
10279            "scopes":[{"pattern":"project:{project_id}"}]}]});
10280        let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10281            let mut client =
10282                SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10283            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10284            client
10285                .subscribe(
10286                    "attachments".into(),
10287                    "attachments".into(),
10288                    vec![("project_id".into(), vec!["p1".into()])],
10289                    None,
10290                )
10291                .unwrap();
10292            client
10293        });
10294        let bytes = b"split upload";
10295        let mut mutations = Vec::new();
10296        for client in [&mut exchange, &mut split] {
10297            client
10298                .upload_blob(bytes, Some("text/plain".into()), None)
10299                .unwrap();
10300            mutations.push(
10301                client
10302                    .mutate(vec![Mutation::Upsert {
10303                        table: "attachments".into(),
10304                        values: Map::from_iter([
10305                            ("id".into(), Value::from("a1")),
10306                            ("project_id".into(), Value::from("p1")),
10307                            (
10308                                "file".into(),
10309                                Value::from(format!(
10310                                    "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10311                                    blob_id_for(bytes),
10312                                    bytes.len()
10313                                )),
10314                            ),
10315                        ]),
10316                        base_version: None,
10317                    }])
10318                    .unwrap(),
10319            );
10320        }
10321        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10322        let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10323        let exchange_outcome = exchange.sync(&mut exchange_transport);
10324        let split_outcome = sync_split(&mut split, &mut split_transport);
10325        let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10326            panic!("the exchange round must succeed: {exchange_outcome:?}")
10327        };
10328        let SyncOutcome::Ok(split_report) = split_outcome else {
10329            panic!("the split round must succeed: {split_outcome:?}")
10330        };
10331        assert_eq!(exchange_report.pushed, 1);
10332        assert_eq!(split_report.pushed, 1);
10333        assert_eq!(
10334            exchange_report.commits_applied,
10335            split_report.commits_applied
10336        );
10337        assert_eq!(
10338            exchange_transport.uploads,
10339            [blob_id_for(bytes)],
10340            "one upload before the metadata request"
10341        );
10342        assert_eq!(split_transport.uploads, exchange_transport.uploads);
10343        assert!(exchange.pending_commit_ids().is_empty());
10344        assert!(split.pending_commit_ids().is_empty());
10345        assert_eq!(
10346            exchange
10347                .query("SELECT id, project_id FROM attachments", &[])
10348                .unwrap(),
10349            split
10350                .query("SELECT id, project_id FROM attachments", &[])
10351                .unwrap()
10352        );
10353    }
10354
10355    #[test]
10356    fn split_round_matches_exchange_for_a_segment_download() {
10357        let (mut exchange, mut split) = parity_clients();
10358        let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10359        let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10360            table: "tasks".into(),
10361            schema_version: 1,
10362            columns,
10363            blocks: vec![vec![ssp2::segment::SegmentRow {
10364                server_version: 1,
10365                values: vec![
10366                    Some(ColumnValue::String("page".into())),
10367                    Some(ColumnValue::String("p1".into())),
10368                ],
10369            }]],
10370        });
10371        let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10372        let response = Message {
10373            wire_version: WIRE_VERSION,
10374            msg_kind: MsgKind::Response,
10375            frames: vec![
10376                Frame::RespHeader {
10377                    required_schema_version: None,
10378                    latest_schema_version: None,
10379                    log_epoch: Some("epoch-1".into()),
10380                    reset_required: Some(false),
10381                },
10382                Frame::SubStart {
10383                    id: "tasks".into(),
10384                    status: SubStatus::Active,
10385                    reason_code: String::new(),
10386                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10387                    bootstrap: true,
10388                },
10389                Frame::SegmentRef {
10390                    segment_id: segment_id.clone(),
10391                    media_type: MediaType::Rows,
10392                    table: "tasks".into(),
10393                    byte_length: page.len() as i64,
10394                    row_count: 1,
10395                    as_of_commit_seq: 1,
10396                    scope_digest: "digest".into(),
10397                    row_cursor: None,
10398                    next_row_cursor: None,
10399                    url: None,
10400                    url_expires_at_ms: None,
10401                },
10402                Frame::SubEnd {
10403                    next_cursor: 1,
10404                    bootstrap_state: None,
10405                },
10406            ],
10407        };
10408        let segments = BTreeMap::from([(segment_id.clone(), page)]);
10409        let mut exchange_transport = SplitParityTransport::with_segments(
10410            RoundReplyScript::Response(response.clone()),
10411            segments.clone(),
10412        );
10413        let mut split_transport =
10414            SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10415        let exchange_outcome = exchange.sync(&mut exchange_transport);
10416        let split_outcome = sync_split(&mut split, &mut split_transport);
10417        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10418        assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10419        assert_eq!(split_transport.batches, exchange_transport.batches);
10420        assert_eq!(
10421            exchange
10422                .query("SELECT id, project_id FROM tasks", &[])
10423                .unwrap(),
10424            split
10425                .query("SELECT id, project_id FROM tasks", &[])
10426                .unwrap()
10427        );
10428    }
10429
10430    #[test]
10431    fn split_round_matches_exchange_for_a_transport_failure() {
10432        let (mut exchange, mut split) = parity_clients();
10433        let error = TransportError::new("transport.timeout", "injected");
10434        let mut exchange_transport =
10435            SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10436        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10437        let exchange_outcome = exchange.sync(&mut exchange_transport);
10438        let split_outcome = sync_split(&mut split, &mut split_transport);
10439        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10440        assert!(matches!(
10441            split_outcome,
10442            SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10443        ));
10444        assert_eq!(
10445            exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10446            split.query("SELECT id FROM tasks", &[]).unwrap()
10447        );
10448    }
10449
10450    #[test]
10451    fn split_round_matches_exchange_for_a_failed_realtime_round() {
10452        let (mut exchange, mut split) = parity_clients();
10453        let error = TransportError::new("transport.timeout", "socket round failed");
10454        let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10455        let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10456            TransportError::new("transport.timeout", "socket round failed"),
10457        ));
10458        exchange.connect_realtime(&mut exchange_transport).unwrap();
10459        split.connect_realtime(&mut split_transport).unwrap();
10460        let exchange_outcome = exchange.sync(&mut exchange_transport);
10461        let split_outcome = sync_split(&mut split, &mut split_transport);
10462        assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10463        assert_eq!(exchange_transport.realtime_sync_calls, 1);
10464        assert_eq!(exchange_transport.sync_calls, 0);
10465        assert_eq!(split_transport.realtime_sync_calls, 1);
10466        assert_eq!(split_transport.sync_calls, 0);
10467        assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10468        assert_eq!(split.realtime_state(), RealtimeState::Lost);
10469    }
10470}
10471
10472impl SubState {
10473    fn name(self) -> &'static str {
10474        match self {
10475            SubState::Active => "active",
10476            SubState::Revoked => "revoked",
10477            SubState::Failed => "failed",
10478        }
10479    }
10480
10481    fn parse(value: &str) -> Self {
10482        match value {
10483            "revoked" => Self::Revoked,
10484            "failed" => Self::Failed,
10485            _ => Self::Active,
10486        }
10487    }
10488}
10489
10490#[derive(Debug, Clone)]
10491struct Subscription {
10492    id: String,
10493    table: String,
10494    requested: Vec<(String, Vec<String>)>,
10495    params: Option<String>,
10496    cursor: i64,
10497    /// §4.7 resume token, round-tripped opaquely.
10498    bootstrap_state: Option<String>,
10499    state: SubState,
10500    reason_code: Option<String>,
10501    /// Last effective scopes echoed while active (§3.3: persisted for the
10502    /// purge contract; each active echo replaces it).
10503    effective: Option<Vec<(String, Vec<String>)>>,
10504    synced_once: bool,
10505}
10506
10507#[cfg(test)]
10508mod subscription_rearm_tests {
10509    use super::*;
10510    use serde_json::json;
10511
10512    fn client() -> SyncClient {
10513        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10514            {"name":"id","type":"string","nullable":false},
10515            {"name":"project_id","type":"string","nullable":false}],
10516            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10517        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10518    }
10519
10520    fn scopes() -> Vec<(String, Vec<String>)> {
10521        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10522    }
10523
10524    /// §3.3: the stored registration carries progress the re-arm must drop.
10525    fn store_revoked(client: &mut SyncClient) {
10526        client
10527            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10528            .expect("subscribe");
10529        let sub = &mut client.subs[0];
10530        sub.cursor = 41;
10531        sub.bootstrap_state = Some("resume".to_owned());
10532        sub.effective = Some(scopes());
10533        sub.state = SubState::Revoked;
10534        sub.reason_code = Some("sync.scope_revoked".to_owned());
10535        client.persist_sub(&client.subs[0]).expect("persist");
10536        client.sync_needed = false;
10537        client.sync_intent_queue.clear();
10538    }
10539
10540    #[test]
10541    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10542        let mut client = client();
10543        store_revoked(&mut client);
10544
10545        client
10546            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10547            .expect("re-arm");
10548
10549        let view = client.subscription_state("sub").expect("state");
10550        assert_eq!(view.status, "active");
10551        assert_eq!(view.cursor, -1);
10552        assert!(!view.has_resume_token);
10553        assert_eq!(view.effective_scopes, None);
10554        assert_eq!(view.reason_code, None);
10555        assert!(client.sync_needed, "a re-arm asks for a pull");
10556        assert!(matches!(
10557            client.drain_sync_intents().as_slice(),
10558            [SyncIntent::Interactive]
10559        ));
10560        let stored: String = client
10561            .conn
10562            .query_row(
10563                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10564                [],
10565                |row| row.get(0),
10566            )
10567            .expect("persisted row");
10568        let stored: Value = serde_json::from_str(&stored).expect("stored json");
10569        assert_eq!(stored["status"], json!("active"));
10570        assert_eq!(stored["cursor"], json!(-1));
10571        assert_eq!(stored["bootstrapState"], Value::Null);
10572        assert_eq!(stored["effectiveScopes"], Value::Null);
10573        assert_eq!(stored["reasonCode"], Value::Null);
10574    }
10575
10576    #[test]
10577    fn a_failed_registration_is_not_rearmed() {
10578        let mut client = client();
10579        client
10580            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10581            .expect("subscribe");
10582        client.subs[0].state = SubState::Failed;
10583        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10584        client.persist_sub(&client.subs[0]).expect("persist");
10585        client.sync_needed = false;
10586        client.sync_intent_queue.clear();
10587
10588        client
10589            .subscribe("sub".into(), "tasks".into(), scopes(), None)
10590            .expect("subscribe");
10591
10592        let view = client.subscription_state("sub").expect("state");
10593        assert_eq!(view.status, "failed");
10594        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10595        assert!(!client.sync_needed);
10596        assert!(client.drain_sync_intents().is_empty());
10597    }
10598}
10599
10600#[derive(Debug, Clone)]
10601struct OutboxOp {
10602    upsert: bool,
10603    table: String,
10604    row_id: String,
10605    base_version: Option<i64>,
10606    /// Schema-agnostic local form (§0): driver JSON values, encoded with
10607    /// the current codec at send time. A key's presence IS the §6.1 sparse
10608    /// presence set — both the overlay and the wire payload derive from it.
10609    values: Option<Map<String, Value>>,
10610}
10611
10612impl From<&OutboxOp> for CommitOperation {
10613    fn from(operation: &OutboxOp) -> Self {
10614        Self {
10615            table: operation.table.clone(),
10616            row_id: operation.row_id.clone(),
10617            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10618            base_version: operation.base_version,
10619            values: operation.values.clone(),
10620        }
10621    }
10622}
10623
10624#[derive(Debug, Clone)]
10625struct OutboxCommit {
10626    client_commit_id: String,
10627    ops: Vec<OutboxOp>,
10628}
10629
10630/// One commit's `(table, value keys)` per upsert operation — the classification
10631/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
10632/// can never disagree about which commits cannot re-encode.
10633fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
10634    commit
10635        .ops
10636        .iter()
10637        .filter(|operation| operation.upsert)
10638        .map(|operation| {
10639            (
10640                operation.table.as_str(),
10641                operation
10642                    .values
10643                    .as_ref()
10644                    .map(|values| values.keys().map(String::as_str).collect())
10645                    .unwrap_or_default(),
10646            )
10647        })
10648        .collect()
10649}
10650
10651type FailedCommit = (OutboxCommit, Map<String, Value>);
10652
10653#[derive(Debug, Clone)]
10654struct CompiledLocalDataPurgeTarget {
10655    table: String,
10656    selectors: Vec<(String, Vec<String>)>,
10657}
10658
10659struct StoredCommitOutcomeRow {
10660    sequence: i64,
10661    client_commit_id: String,
10662    status: String,
10663    recorded_at_ms: i64,
10664    results_json: String,
10665    operations_json: Option<String>,
10666    resolution: String,
10667    resolved_at_ms: Option<i64>,
10668    replacement_client_commit_id: Option<String>,
10669}
10670
10671impl StoredCommitOutcomeRow {
10672    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10673        Ok(Self {
10674            sequence: row.get(0)?,
10675            client_commit_id: row.get(1)?,
10676            status: row.get(2)?,
10677            recorded_at_ms: row.get(3)?,
10678            results_json: row.get(4)?,
10679            operations_json: row.get(5)?,
10680            resolution: row.get(6)?,
10681            resolved_at_ms: row.get(7)?,
10682            replacement_client_commit_id: row.get(8)?,
10683        })
10684    }
10685}
10686
10687/// Section outcome distinguishing the §5.6 subscription-local fail-closed
10688/// path from a round-aborting failure (§1.4 rule 5).
10689enum SectionError {
10690    FailClosed,
10691    Abort(String, String),
10692}
10693
10694#[derive(Debug)]
10695pub(crate) struct RequestMeta {
10696    pushed_ids: Vec<String>,
10697    /// Subscription id → the request carried `cursor < 0` and no resume
10698    /// token (§5.6 first-page detection: a *fresh* bootstrap).
10699    fresh: Vec<(String, bool)>,
10700    pub(crate) accept: u8,
10701    /// §6.1 splitBatch: outbox commits held back from THIS request because
10702    /// the running operation count reached the push cap — the next round
10703    /// pushes them (`sync_needed` stays set while any remain).
10704    deferred_commits: usize,
10705}
10706
10707#[derive(Default)]
10708struct ChangeAccumulator {
10709    /// None means table-wide; Some is the exact scoped domain.
10710    tables: BTreeMap<String, Option<BTreeSet<String>>>,
10711    windows: BTreeMap<(String, String), BTreeSet<String>>,
10712    status: bool,
10713    conflicts: bool,
10714    rejections: bool,
10715    outcomes: bool,
10716}
10717
10718impl ChangeAccumulator {
10719    fn table(&mut self, table: &str) {
10720        self.tables.insert(table.to_owned(), None);
10721    }
10722
10723    fn scope(&mut self, table: &str, key: String) {
10724        match self.tables.get_mut(table) {
10725            Some(None) => {}
10726            Some(Some(keys)) => {
10727                keys.insert(key);
10728            }
10729            None => {
10730                self.tables
10731                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
10732            }
10733        }
10734    }
10735
10736    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10737        self.windows
10738            .entry((base_key.to_owned(), table.to_owned()))
10739            .or_default()
10740            .insert(unit.to_owned());
10741    }
10742
10743    fn touched(&self) -> bool {
10744        !self.tables.is_empty()
10745            || !self.windows.is_empty()
10746            || self.status
10747            || self.conflicts
10748            || self.rejections
10749            || self.outcomes
10750    }
10751}
10752
10753/// §6.1: the server caps total operations per request (reference default
10754/// 500) and rejects the whole batch with `sync.too_many_operations`; the
10755/// client "splits and retries". Splitting happens at build time: commits are
10756/// included IN ORDER until the operation budget is spent, the rest wait for
10757/// the next round.
10758const PUSH_OPS_PER_REQUEST: usize = 500;
10759const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10760const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10761const MAX_LOCAL_PURGE_VALUES: usize = 128;
10762const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10763pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10764
10765/// Dirty visible tables; `None` means a whole-replica recovery/reset.
10766struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10767
10768impl OverlayDirty {
10769    fn clean() -> Self {
10770        Self(RefCell::new(Some(BTreeSet::new())))
10771    }
10772    fn get(&self) -> bool {
10773        self.0
10774            .borrow()
10775            .as_ref()
10776            .is_none_or(|tables| !tables.is_empty())
10777    }
10778    fn set(&self, dirty: bool) {
10779        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10780    }
10781    fn table(&self, table: &str) {
10782        if let Some(tables) = self.0.borrow_mut().as_mut() {
10783            tables.insert(table.to_owned());
10784        }
10785    }
10786    fn snapshot(&self) -> Option<BTreeSet<String>> {
10787        self.0.borrow().clone()
10788    }
10789    fn restore(&self, tables: Option<BTreeSet<String>>) {
10790        *self.0.borrow_mut() = tables;
10791    }
10792}
10793
10794pub struct SyncClient {
10795    #[cfg(feature = "bench-internals")]
10796    benchmark_phases: Recorder,
10797    conn: Connection,
10798    schema: ClientSchema,
10799    client_id: String,
10800    limits: ClientLimits,
10801    subs: Vec<Subscription>,
10802    outbox: Vec<OutboxCommit>,
10803    failed_commits: Vec<FailedCommit>,
10804    retain_failed_commits: bool,
10805    conflicts: Vec<ConflictRecord>,
10806    rejections: Vec<RejectionRecord>,
10807    schema_floor: Option<SchemaFloor>,
10808    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
10809    lease_state: Option<LeaseState>,
10810    /// §1.6: the schema-floor response stops syncing until an upgrade.
10811    stopped: bool,
10812    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
10813    upgrading: bool,
10814    /// §8.4 coalesced sync-needed signal.
10815    sync_needed: bool,
10816    /// §8.8: how sync rounds treat the realtime binding, and its exact
10817    /// availability state with the evidence behind a lost or refused one.
10818    realtime_policy: RealtimePolicy,
10819    realtime_state: RealtimeState,
10820    realtime_reason_code: Option<String>,
10821    realtime_retry_delay_ms: Option<u64>,
10822    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
10823    presence: HashMap<String, HashMap<String, PresencePeer>>,
10824    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
10825    /// host may pin it (conformance runs on a virtual clock).
10826    now_ms: Option<i64>,
10827    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
10828    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
10829    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
10830    encryption: crate::values::EncryptionConfig,
10831    /// Fail-closed host bootstrap gate. While set, command hosts permit only
10832    /// status/lifecycle inspection and an exact authorized local purge.
10833    security_preflight: bool,
10834    transport_enabled: bool,
10835    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
10836    /// means the feature is off; the descriptor write and the container orphan
10837    /// sweep still happen.
10838    previous_version: Option<PreviousVersionContextConfig>,
10839    /// Per-table primary-key upsert SQL, built once per (full table name) —
10840    /// the row write path runs per row during bootstrap (§5.6), so the SQL
10841    /// string (and, via `prepare_cached`, its compiled statement) is reused
10842    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
10843    /// schema reset (the column lists may have changed).
10844    insert_sql: RefCell<HashMap<String, String>>,
10845    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
10846    /// reset and restart explicitly invalidate every table.
10847    overlay_dirty: OverlayDirty,
10848    // First SQLite failure of the current operation, before savepoint cleanup.
10849    storage_failure: RefCell<Option<QueryReadFailure>>,
10850    /// Test-only structural performance signal: response processing must not
10851    /// turn a batch of acknowledgements into one full overlay rebuild each.
10852    #[cfg(test)]
10853    overlay_rebuild_count: Cell<usize>,
10854    #[cfg(test)]
10855    acknowledged_replay_count: Cell<usize>,
10856    /// Test-only structural performance signal: outcome retention is enforced
10857    /// once per response rather than once per acknowledgement.
10858    #[cfg(test)]
10859    outcome_prune_count: Cell<usize>,
10860    /// Exact observer-transaction output drained by command/FFI hosts.
10861    progress: ProgressObserver,
10862    change_queue: VecDeque<ClientChangeBatch>,
10863    sync_intent_queue: VecDeque<SyncIntent>,
10864    /// Explicit exponential retry policy for transient transport failures.
10865    retry_delay_ms: u64,
10866    /// §7.6: the background retry the current round scheduled, if any.
10867    round_retry_delay_ms: Option<u64>,
10868    /// A round whose response never completed (§1.5): the client keeps its
10869    /// code and reports it on every following request until a round receives a
10870    /// complete, decodable response. The server already recorded the round, so
10871    /// nothing else can tell it the round failed on this side.
10872    round_failure: Option<String>,
10873    active_round: Option<uuid::Uuid>,
10874    last_round: Option<DiagnosticLastRound>,
10875    last_change: Option<DiagnosticLastChange>,
10876    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
10877    query_failures: Vec<DiagnosticQueryFailure>,
10878    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
10879    coalesced: Option<CoalescedApply>,
10880    /// A coalesced apply transaction of the current response failed; every
10881    /// later observation of that response fails until it ends.
10882    coalesced_failure: Option<String>,
10883}
10884
10885/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
10886/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
10887/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
10888struct CoalescedApply {
10889    rows: usize,
10890    subs: Vec<Subscription>,
10891    outbox: Vec<OutboxCommit>,
10892    failed_commits: Vec<FailedCommit>,
10893    conflicts: Vec<ConflictRecord>,
10894    rejections: Vec<RejectionRecord>,
10895    schema_floor: Option<SchemaFloor>,
10896    lease_state: Option<LeaseState>,
10897    stopped: bool,
10898    upgrading: bool,
10899    sync_needed: bool,
10900    overlay_dirty: Option<BTreeSet<String>>,
10901    change_queue: usize,
10902    sync_intent_queue: usize,
10903    last_change: Option<DiagnosticLastChange>,
10904}
10905
10906const COALESCED_APPLY_ROWS: usize = 1024;
10907
10908pub(crate) fn quote_ident(name: &str) -> String {
10909    format!("\"{}\"", name.replace('"', "\"\""))
10910}
10911/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
10912/// previous-version module reads and writes the SAME records as the client
10913/// instead of growing a parallel meta layer.
10914pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
10915    conn.query_row(
10916        "SELECT value FROM _syncular_meta WHERE key = ?1",
10917        rusqlite::params![key],
10918        |row| row.get::<_, String>(0),
10919    )
10920    .ok()
10921}
10922
10923pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
10924    let _ = conn.execute(
10925        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
10926        rusqlite::params![key, value],
10927    );
10928}
10929
10930pub(crate) fn meta_delete(conn: &Connection, key: &str) {
10931    let _ = conn.execute(
10932        "DELETE FROM _syncular_meta WHERE key = ?1",
10933        rusqlite::params![key],
10934    );
10935}
10936
10937/// Seek by the existing key while retaining the exact legacy text match.
10938fn row_id_predicate(table: &TableSchema) -> String {
10939    let key = quote_ident(&table.primary_key);
10940    let text_match = format!("CAST({key} AS TEXT) = ?1");
10941    match table.columns[table.pk_index].ty {
10942        ColumnType::String | ColumnType::Json => {
10943            format!("{key} = ?1 AND {text_match}")
10944        }
10945        // Native columns have no declared SQLite affinity. Match the stored
10946        // integer bind type explicitly. Unary + removes expression affinity so
10947        // SQLite can seek the typeless key; the text check preserves exact IDs.
10948        ColumnType::Integer | ColumnType::Boolean => {
10949            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
10950        }
10951        // REAL-to-text conversion can round distinct values to the same text.
10952        // Narrowing those matches by numeric equality would change behavior.
10953        _ => text_match,
10954    }
10955}
10956
10957fn is_local_operation_code_like(value: &str) -> bool {
10958    let bytes = value.as_bytes();
10959    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
10960        && bytes
10961            .iter()
10962            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
10963}
10964
10965fn base_table(name: &str) -> String {
10966    quote_ident(&format!("_syncular_base_{name}"))
10967}
10968
10969/// §4.8: a stable, server-opaque key for a window base — table + variable +
10970/// canonical fixed scopes. Two `set_window` calls with the same base
10971/// address the same registry rows.
10972/// §4.8 deferred eviction record: (sub id, table, effective scope map).
10973type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
10974
10975fn window_base_key(base: &WindowBase) -> String {
10976    format!(
10977        "{}\0{}\0{}",
10978        base.table,
10979        base.variable,
10980        canonical_scope_json(&base.fixed_scopes)
10981    )
10982}
10983
10984/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
10985fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
10986    let mut scopes = base.fixed_scopes.clone();
10987    scopes.retain(|(k, _)| k != &base.variable);
10988    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
10989    scopes
10990}
10991
10992/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
10993/// echoed not interpreted by the server, so the exact hash is client
10994/// convention; SHA-256 matches the SPEC's worked example.
10995fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
10996    let canonical = canonical_scope_json(&unit_scopes(base, unit));
10997    let digest = Sha256::digest(canonical.as_bytes());
10998    let hex = bytes_to_hex(&digest);
10999    format!("w:{}:{}", base.table, &hex[..16])
11000}
11001
11002fn visible_table(name: &str) -> String {
11003    quote_ident(name)
11004}
11005
11006const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11007
11008/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
11009/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
11010fn is_synced_table_name(name: &str) -> bool {
11011    if name.starts_with("sqlite_") {
11012        return false;
11013    }
11014    if name.starts_with("_syncular_base_") {
11015        return true; // the base half of a synced table pair
11016    }
11017    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
11018    !name.starts_with("_syncular_")
11019}
11020
11021/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
11022fn blob_id_for(bytes: &[u8]) -> String {
11023    let digest = Sha256::digest(bytes);
11024    format!("sha256:{}", bytes_to_hex(&digest))
11025}
11026
11027/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
11028/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
11029/// per row on the §5.6 bootstrap path), scalars bind owned.
11030enum RowParam<'a> {
11031    Cell(&'a Option<ColumnValue>),
11032    Version(i64),
11033}
11034
11035impl rusqlite::ToSql for RowParam<'_> {
11036    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11037        Ok(match self {
11038            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11039            RowParam::Cell(cell) => match cell {
11040                None => ToSqlOutput::Owned(SqlValue::Null),
11041                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11042                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11043                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11044                Some(ColumnValue::Boolean(b)) => {
11045                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11046                }
11047                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11048                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11049                }
11050                // §5.10: crdt bytes store as BLOB, like bytes.
11051                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11052                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
11053                }
11054            },
11055        })
11056    }
11057}
11058
11059// Retain a typed primary key across the current import chunk without copying row payloads.
11060fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11061    match param.to_sql().map_err(|error| error.to_string())? {
11062        ToSqlOutput::Owned(value) => Ok(value),
11063        ToSqlOutput::Borrowed(value) => {
11064            SqlValue::try_from(value).map_err(|error| error.to_string())
11065        }
11066        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11067    }
11068}
11069
11070/// §5.3 image cell → bind parameter, strict per the declared column type
11071/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
11072/// nullable). Mismatches are image-producer violations. Returns the cell
11073/// borrowed when the stored representation already matches what the row
11074/// codec would write, or the normalized scalar (boolean → 0/1, float from
11075/// INTEGER → REAL) otherwise — the local write is byte-identical to the
11076/// old convert-then-insert path without allocating per cell.
11077fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11078    use ssp2::segment::ColumnType;
11079    let mismatch = || {
11080        Err(format!(
11081            "image column {:?} holds a value of the wrong type",
11082            column.name
11083        ))
11084    };
11085    match value {
11086        ValueRef::Null => {
11087            if !column.nullable {
11088                return Err(format!(
11089                    "image column {:?} is NULL but not nullable",
11090                    column.name
11091                ));
11092            }
11093            Ok(ToSqlOutput::Owned(SqlValue::Null))
11094        }
11095        ValueRef::Integer(i) => match column.ty {
11096            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11097            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11098            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11099            _ => mismatch(),
11100        },
11101        ValueRef::Real(_) => match column.ty {
11102            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11103            _ => mismatch(),
11104        },
11105        ValueRef::Text(t) => {
11106            std::str::from_utf8(t)
11107                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11108            match column.ty {
11109                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11110                    Ok(ToSqlOutput::Borrowed(value))
11111                }
11112                _ => mismatch(),
11113            }
11114        }
11115        ValueRef::Blob(_) => match column.ty {
11116            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
11117            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11118            _ => mismatch(),
11119        },
11120    }
11121}
11122
11123pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11124    use rusqlite::types::ValueRef;
11125    match value {
11126        ValueRef::Null => Value::Null,
11127        ValueRef::Integer(i) => match column.ty {
11128            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11129            ssp2::segment::ColumnType::Float => {
11130                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11131            }
11132            _ => Value::from(i),
11133        },
11134        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11135        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11136        ValueRef::Blob(b) => {
11137            let mut map = Map::new();
11138            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11139            Value::Object(map)
11140        }
11141    }
11142}
11143
11144/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
11145/// Objects are accepted in lossless `{"$bytes": hex}` and
11146/// `{"$bigint": decimal}` envelope forms.
11147fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11148    Ok(match value {
11149        Value::Null => SqlValue::Null,
11150        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11151        Value::Number(n) => {
11152            if let Some(i) = n.as_i64() {
11153                SqlValue::Integer(i)
11154            } else if let Some(f) = n.as_f64() {
11155                SqlValue::Real(f)
11156            } else {
11157                return Err(format!("query param number {n} is out of range"));
11158            }
11159        }
11160        Value::String(s) => SqlValue::Text(s.clone()),
11161        Value::Object(_) => {
11162            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11163                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11164            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11165                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11166                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11167                })?)
11168            } else {
11169                return Err(
11170                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11171                        .to_owned(),
11172                );
11173            }
11174        }
11175        Value::Array(_) => return Err("query array params are not supported".to_owned()),
11176    })
11177}
11178
11179/// Map a rusqlite value with no schema column to consult (arbitrary query
11180/// output): integers/reals/text pass through by stored affinity, blobs ride
11181/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
11182/// schema column type to recover booleans/floats/json.
11183fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11184    use rusqlite::types::ValueRef;
11185    match value {
11186        ValueRef::Null => Value::Null,
11187        ValueRef::Integer(i) => {
11188            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
11189            // ordinary UI-sized integers ergonomic and envelope only values
11190            // beyond JavaScript's safe range.
11191            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11192            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11193                Value::from(i)
11194            } else {
11195                let mut map = Map::new();
11196                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11197                Value::Object(map)
11198            }
11199        }
11200        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11201        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11202        ValueRef::Blob(b) => {
11203            let mut map = Map::new();
11204            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11205            Value::Object(map)
11206        }
11207    }
11208}
11209
11210/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
11211fn query_connection(
11212    conn: &Connection,
11213    sql: &str,
11214    params: &[QueryValue],
11215) -> Result<Vec<QueryRow>, QueryReadFailure> {
11216    crate::query_guard::assert_read_only_query(sql)?;
11217    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11218    let bound: Vec<SqlValue> = params
11219        .iter()
11220        .map(json_param_to_sql)
11221        .collect::<Result<_, _>>()?;
11222    let mut stmt = conn.prepare(&lowered_sql)?;
11223    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11224    let bound_refs: Vec<&dyn rusqlite::ToSql> =
11225        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11226    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11227    let mut out = Vec::new();
11228    while let Some(row) = sql_rows.next()? {
11229        let mut record = Map::new();
11230        for (i, name) in column_names.iter().enumerate() {
11231            let value = row.get_ref(i)?;
11232            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11233        }
11234        out.push(record);
11235    }
11236    Ok(out)
11237}
11238
11239fn persisted_window_state(
11240    conn: &Connection,
11241    base: &WindowBase,
11242) -> Result<WindowState, QueryReadFailure> {
11243    let mut stmt = conn.prepare(
11244        "SELECT windows.unit, subscriptions.state_json
11245           FROM _syncular_windows AS windows
11246           JOIN _syncular_subscriptions AS subscriptions
11247             ON subscriptions.id = windows.sub_id
11248          WHERE windows.base = ?1
11249          ORDER BY windows.unit ASC",
11250    )?;
11251    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11252        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11253    })?;
11254    let mut units = Vec::new();
11255    let mut pending = Vec::new();
11256    for row in rows {
11257        let (unit, raw) = row?;
11258        let state: Value = serde_json::from_str(&raw)
11259            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11260        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11261            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11262            || state
11263                .get("bootstrapState")
11264                .is_some_and(|value| !value.is_null());
11265        if is_pending {
11266            pending.push(unit.clone());
11267        }
11268        units.push(unit);
11269    }
11270    Ok(WindowState { units, pending })
11271}
11272
11273/// One read-only statement in a §7.5 snapshot read.
11274#[derive(Debug, Clone, Copy)]
11275pub struct SnapshotStatement<'a> {
11276    pub sql: &'a str,
11277    pub params: &'a [Value],
11278}
11279
11280/// A §7.5 atomic read request: read-only statements, window coverage,
11281/// subscription catch-up states, and commit deliveries, all resolved from one
11282/// SQLite read transaction and one revision.
11283#[derive(Debug, Clone, Default)]
11284pub struct SnapshotReadRequest<'a> {
11285    pub statements: Vec<SnapshotStatement<'a>>,
11286    pub coverage: &'a [WindowCoverage],
11287    pub subscriptions: Vec<String>,
11288    pub commit_ids: Vec<String>,
11289}
11290
11291#[cfg(test)]
11292thread_local! {
11293    /// Test-only rendezvous that runs between the statements of one snapshot
11294    /// read. A test uses it to commit on another connection mid-read and prove
11295    /// the read transaction's snapshot and revision stay stable.
11296    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11297        std::cell::RefCell::new(None);
11298}
11299
11300#[cfg(test)]
11301fn run_snapshot_read_hook() {
11302    SNAPSHOT_READ_HOOK.with(|slot| {
11303        if let Some(hook) = slot.borrow_mut().take() {
11304            hook();
11305        }
11306    });
11307}
11308
11309/// §7.5: resolve a request over one SQLite read transaction. The revision,
11310/// every statement's rows, coverage, catch-up states, and deliveries are
11311/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
11312/// read back and releases the connection.
11313fn snapshot_read_connection(
11314    conn: &Connection,
11315    request: &SnapshotReadRequest<'_>,
11316) -> Result<SnapshotRead, QueryReadFailure> {
11317    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11318    let corrupt = |message: &str| QueryReadFailure {
11319        code: Some("sync.local_corrupt"),
11320        sqlite_code: None,
11321        sqlite_message: None,
11322        rollback_failure: None,
11323        message: message.to_owned(),
11324    };
11325    let stored_failure = |error: rusqlite::Error, message: &str| match error {
11326        rusqlite::Error::Utf8Error(..)
11327        | rusqlite::Error::InvalidColumnType(..)
11328        | rusqlite::Error::FromSqlConversionFailure(..)
11329        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11330        _ => QueryReadFailure::from(error),
11331    };
11332    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11333        // The durable revision as a canonical decimal `u64`; a missing marker
11334        // is the legacy zero, and any other non-canonical value is corrupt.
11335        let revision: Option<String> = conn
11336            .query_row(
11337                "SELECT value FROM _syncular_meta WHERE key = ?1",
11338                rusqlite::params![LOCAL_REVISION_KEY],
11339                |row| row.get(0),
11340            )
11341            .optional()
11342            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11343        let revision = match revision {
11344            None => "0".to_owned(),
11345            Some(value)
11346                if value == "0"
11347                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11348                        && value.bytes().all(|b| b.is_ascii_digit())
11349                        && value.parse::<u64>().is_ok()) =>
11350            {
11351                value
11352            }
11353            Some(_) => return Err(corrupt("persisted local revision is invalid")),
11354        };
11355        let mut queries = Vec::with_capacity(request.statements.len());
11356        for statement in &request.statements {
11357            queries.push(query_connection(conn, statement.sql, statement.params)?);
11358            #[cfg(test)]
11359            run_snapshot_read_hook();
11360        }
11361        let mut pending = Vec::new();
11362        let mut missing = Vec::new();
11363        for requested in request.coverage {
11364            let base_key = window_base_key(&requested.base);
11365            let state = persisted_window_state(conn, &requested.base)?;
11366            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11367                let reference = WindowUnitRef {
11368                    base_key: base_key.clone(),
11369                    unit: unit.clone(),
11370                };
11371                if !state.units.iter().any(|held| held == &unit) {
11372                    missing.push(reference);
11373                } else if state.pending.iter().any(|held| held == &unit) {
11374                    pending.push(reference);
11375                }
11376            }
11377        }
11378        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11379        for id in &request.subscriptions {
11380            let row: Option<(String, String)> = conn
11381                .query_row(
11382                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11383                    rusqlite::params![id],
11384                    |row| Ok((row.get(0)?, row.get(1)?)),
11385                )
11386                .optional()
11387                .map_err(|error| {
11388                    stored_failure(error, "persisted subscription state is invalid")
11389                })?;
11390            let Some((table, raw)) = row else {
11391                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11392                continue;
11393            };
11394            // Every persisted field is required to be well typed; a corrupt
11395            // record fails typed instead of inventing a state.
11396            let state: Value = serde_json::from_str(&raw)
11397                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11398            let status = match state.get("status").and_then(Value::as_str) {
11399                Some(value @ ("active" | "revoked" | "failed")) => value,
11400                _ => return Err(corrupt("persisted subscription state is invalid")),
11401            };
11402            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11403                return Err(corrupt("persisted subscription state is invalid"));
11404            };
11405            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11406                return Err(corrupt("persisted subscription state is invalid"));
11407            }
11408            let effective_scopes = state
11409                .get("effectiveScopes")
11410                .filter(|value| !value.is_null());
11411            if let Some(scopes) = effective_scopes {
11412                json_to_scope_map(scopes)
11413                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11414            }
11415            let has_resume_token = match state.get("bootstrapState") {
11416                None | Some(Value::Null) => false,
11417                Some(Value::String(_)) => true,
11418                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11419            };
11420            // A present reasonCode must be a string; a non-string value is a
11421            // corrupt record, never silently dropped.
11422            let reason_code = match state.get("reasonCode") {
11423                None | Some(Value::Null) => None,
11424                Some(Value::String(value)) => Some(value.clone()),
11425                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11426            };
11427            let active = status == "active";
11428            subscriptions.push(SubscriptionCatchup::Known {
11429                id: id.clone(),
11430                table,
11431                status: status.to_owned(),
11432                cursor,
11433                has_resume_token,
11434                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11435                known_pending_pages: active && (has_resume_token || cursor < 0),
11436                effective_scopes: effective_scopes.cloned(),
11437                reason_code,
11438            });
11439        }
11440        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11441        for id in &request.commit_ids {
11442            let pending: bool = conn.query_row(
11443                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11444                rusqlite::params![id],
11445                |row| row.get(0),
11446            )?;
11447            if pending {
11448                deliveries.push(CommitDelivery::Pending {
11449                    client_commit_id: id.clone(),
11450                });
11451                continue;
11452            }
11453            let row = conn
11454                .query_row(
11455                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11456                            resolution, resolved_at_ms, replacement_client_commit_id
11457                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11458                    rusqlite::params![id],
11459                    StoredCommitOutcomeRow::from_row,
11460                )
11461                .optional()
11462                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11463            deliveries.push(match row {
11464                Some(row) => CommitDelivery::Known {
11465                    client_commit_id: id.clone(),
11466                    outcome: SyncClient::outcome_from_row(row)
11467                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11468                },
11469                None => CommitDelivery::Unknown {
11470                    client_commit_id: id.clone(),
11471                },
11472            });
11473        }
11474        Ok(SnapshotRead {
11475            revision,
11476            queries,
11477            coverage: CoverageSnapshot {
11478                complete: pending.is_empty() && missing.is_empty(),
11479                pending,
11480                missing,
11481            },
11482            subscriptions,
11483            deliveries,
11484        })
11485    })();
11486    match result {
11487        Ok(read) => {
11488            conn.execute_batch("RELEASE syncular_snapshot_read")?;
11489            Ok(read)
11490        }
11491        Err(mut error) => {
11492            if let Err(rollback) = conn
11493                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11494            {
11495                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11496            }
11497            Err(error)
11498        }
11499    }
11500}
11501
11502fn snapshot_connection(
11503    conn: &Connection,
11504    sql: &str,
11505    params: &[Value],
11506    coverage: &[WindowCoverage],
11507) -> Result<QuerySnapshot, QueryReadFailure> {
11508    let read = snapshot_read_connection(
11509        conn,
11510        &SnapshotReadRequest {
11511            statements: vec![SnapshotStatement { sql, params }],
11512            coverage,
11513            subscriptions: Vec::new(),
11514            commit_ids: Vec::new(),
11515        },
11516    )?;
11517    Ok(QuerySnapshot {
11518        revision: read.revision,
11519        rows: read
11520            .queries
11521            .into_iter()
11522            .next()
11523            .expect("single-query snapshot returns one result"),
11524        coverage: read.coverage,
11525    })
11526}
11527
11528/// A long-lived read-only SQLite sidecar for latency-critical native views.
11529/// Network rounds stay serialized on the mutable core owner, while atomic
11530/// query snapshots use this independent connection and therefore never queue
11531/// behind HTTP/WebSocket latency.
11532pub struct FileQuerySnapshotReader {
11533    path: String,
11534    conn: Option<Connection>,
11535}
11536
11537impl FileQuerySnapshotReader {
11538    #[must_use]
11539    pub fn new(path: impl Into<String>) -> Self {
11540        Self {
11541            path: path.into(),
11542            conn: None,
11543        }
11544    }
11545
11546    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11547        if self.conn.is_none() {
11548            let conn = Connection::open_with_flags(
11549                &self.path,
11550                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11551            )
11552            .map_err(QueryReadFailure::from)?;
11553            conn.busy_timeout(std::time::Duration::from_millis(250))
11554                .map_err(QueryReadFailure::from)?;
11555            self.conn = Some(conn);
11556        }
11557        self.conn
11558            .as_ref()
11559            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11560    }
11561
11562    /// One atomic read over the shared primitive: every statement, coverage,
11563    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
11564    pub fn snapshot_read(
11565        &mut self,
11566        request: &SnapshotReadRequest<'_>,
11567    ) -> Result<SnapshotRead, QueryReadFailure> {
11568        snapshot_read_connection(self.connection()?, request)
11569    }
11570
11571    /// The current durable local revision. A host can skip a read when it is
11572    /// unchanged; a corrupt or unreadable marker fails typed.
11573    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11574        Ok(self
11575            .snapshot_read(&SnapshotReadRequest::default())?
11576            .revision)
11577    }
11578
11579    /// One subscription's persisted catch-up state (see the §7.5 contract).
11580    pub fn subscription_catchup(
11581        &mut self,
11582        id: &str,
11583    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11584        let read = self.snapshot_read(&SnapshotReadRequest {
11585            subscriptions: vec![id.to_owned()],
11586            ..Default::default()
11587        })?;
11588        Ok(read
11589            .subscriptions
11590            .into_iter()
11591            .next()
11592            .expect("one requested catch-up"))
11593    }
11594
11595    /// One client commit's delivery status: pending, its retained outcome, or
11596    /// an explicit unknown (never held, or pruned by retention).
11597    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11598        let read = self.snapshot_read(&SnapshotReadRequest {
11599            commit_ids: vec![id.to_owned()],
11600            ..Default::default()
11601        })?;
11602        Ok(read
11603            .deliveries
11604            .into_iter()
11605            .next()
11606            .expect("one requested delivery"))
11607    }
11608
11609    /// Hosts report an owned failure, and the first success after it, to the
11610    /// owning core through [`SyncClient::record_query_read`] (§7.6).
11611    pub fn query_snapshot(
11612        &mut self,
11613        sql: &str,
11614        params: &[Value],
11615        coverage: &[WindowCoverage],
11616    ) -> Result<QuerySnapshot, QueryReadFailure> {
11617        snapshot_connection(self.connection()?, sql, params, coverage)
11618    }
11619}
11620
11621fn validate_authority_reads(
11622    schema: &ClientSchema,
11623    declarations: &[crate::api::AuthorityReadDeclaration],
11624) -> Result<(), String> {
11625    let mut seen = BTreeSet::new();
11626    for read in declarations {
11627        let table = schema
11628            .table(&read.table)
11629            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11630        let plain = |name: &str| {
11631            table.columns.iter().enumerate().any(|(i, c)| {
11632                c.name == name
11633                    && name != "authority_version"
11634                    && !name.starts_with("_sync")
11635                    && !table.encrypted_columns.iter().any(|e| e.index == i)
11636                    && matches!(
11637                        c.ty,
11638                        ssp2::segment::ColumnType::String
11639                            | ssp2::segment::ColumnType::Integer
11640                            | ssp2::segment::ColumnType::Float
11641                            | ssp2::segment::ColumnType::Boolean
11642                            | ssp2::segment::ColumnType::Json
11643                    )
11644            })
11645        };
11646        if !seen.insert(&read.table)
11647            || read.columns.is_empty()
11648            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11649            || !read.columns.contains(&table.primary_key)
11650            || !read.columns.iter().all(|c| plain(c))
11651            || read.scopes.is_empty()
11652            || !read.scopes.iter().all(|(v, values)| {
11653                table
11654                    .scope_column(v)
11655                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11656                    && !values.is_empty()
11657                    && values.iter().all(|v| !v.is_empty() && v != "*")
11658            })
11659        {
11660            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11661        }
11662    }
11663    Ok(())
11664}
11665
11666// §7.4.2: every open and recreation checks before any persistent writes.
11667/// A marker read that hit a recognized SQLite storage failure keeps that
11668/// classification (BUSY/LOCKED stay retryable); only an unclassified read
11669/// failure is the invalid-marker `sync.local_corrupt`.
11670fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11671    let failure = QueryReadFailure::from(error);
11672    if failure.code.is_some() {
11673        failure.to_string()
11674    } else {
11675        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11676    }
11677}
11678
11679/// §7.1: reject an invalid configured client limit before any storage is
11680/// opened or written. Zero would wedge the push queue and an out-of-range
11681/// value would differ per target; the TS core enforces the same bounds.
11682fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11683    if limits.outcome_retention_max_entries == Some(0) {
11684        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11685    }
11686    if [
11687        limits.max_push_commits_per_request,
11688        limits.max_push_operations_per_request,
11689        limits.max_push_request_bytes,
11690    ]
11691    .into_iter()
11692    .flatten()
11693    .any(|value| value == 0 || value > u32::MAX as usize)
11694    {
11695        return Err(
11696            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11697        );
11698    }
11699    Ok(())
11700}
11701
11702fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11703    let corrupt =
11704        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11705    let table_type: Option<String> = conn
11706        .query_row(
11707            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11708            [],
11709            |row| row.get(0),
11710        )
11711        .optional()
11712        .map_err(schema_marker_read_failure)?;
11713    let Some(table_type) = table_type else {
11714        return Ok(None);
11715    };
11716    if table_type != "table" {
11717        return Err(corrupt());
11718    }
11719    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
11720    // whichever row SQLite returns first, and an older one would drive a
11721    // destructive reset over a newer replica.
11722    let mut statement = conn
11723        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11724        .map_err(schema_marker_read_failure)?;
11725    let mut rows = statement
11726        .query([LOCAL_SCHEMA_VERSION_KEY])
11727        .map_err(schema_marker_read_failure)?;
11728    let value: Option<String> = rows
11729        .next()
11730        .map_err(schema_marker_read_failure)?
11731        .map(|row| row.get(0))
11732        .transpose()
11733        .map_err(schema_marker_read_failure)?;
11734    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11735        return Err(corrupt());
11736    }
11737    let Some(value) = value else {
11738        let has_descriptor = conn
11739            .query_row(
11740                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11741                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11742                |_| Ok(()),
11743            )
11744            .optional()
11745            .map_err(schema_marker_read_failure)?
11746            .is_some();
11747        if has_descriptor {
11748            return Err(corrupt());
11749        }
11750        return Ok(None);
11751    };
11752    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11753        || !value.bytes().all(|b| b.is_ascii_digit())
11754    {
11755        return Err(corrupt());
11756    }
11757    let version = value.parse::<i32>().map_err(|_| corrupt())?;
11758    if version > requested {
11759        return Err(
11760            "client.schema_downgrade: persisted local schema is newer than the requested schema"
11761                .to_owned(),
11762        );
11763    }
11764    Ok(Some(version))
11765}
11766
11767/// A file replica on a browser worker must recover a hot journal before any
11768/// statement reads it. `wasm_vfs` replaces the installed OPFS VFS's reserved
11769/// lock callback, so install it before the connection opens its file.
11770#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11771fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11772    if path.is_empty() || path == ":memory:" {
11773        return Ok(());
11774    }
11775    crate::wasm_vfs::disable_foreign_lock_check()
11776}
11777
11778#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11779fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11780    Ok(())
11781}
11782
11783impl SyncClient {
11784    pub fn new_with_identity(
11785        client_id: Option<String>,
11786        schema_json: &Value,
11787        limits: ClientLimits,
11788    ) -> Result<Self, String> {
11789        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11790        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11791        Self::with_connection(resolved, schema_json, limits, conn)
11792    }
11793
11794    pub fn new(
11795        client_id: String,
11796        schema_json: &Value,
11797        limits: ClientLimits,
11798    ) -> Result<Self, String> {
11799        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11800        Self::with_connection(client_id, schema_json, limits, conn)
11801    }
11802
11803    /// Build a client backed by an on-disk SQLite database at `path` — the
11804    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
11805    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
11806    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
11807    /// of the command router's dependency set (the router only holds a path).
11808    pub fn open_path(
11809        client_id: String,
11810        schema_json: &Value,
11811        limits: ClientLimits,
11812        path: &str,
11813    ) -> Result<Self, String> {
11814        validate_client_limits(&limits)?;
11815        parse_schema_json(schema_json)?;
11816        prepare_wasm_replica(path)?;
11817        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11818        Self::with_connection(client_id, schema_json, limits, conn)
11819    }
11820
11821    /// Open a file replica and resolve its client id. Native targets require
11822    /// WAL for the independent snapshot reader; `wasm32-unknown-unknown`
11823    /// accepts the sqlite-wasm worker journal instead.
11824    pub fn open_path_with_identity(
11825        client_id: Option<String>,
11826        schema_json: &Value,
11827        limits: ClientLimits,
11828        path: &str,
11829    ) -> Result<Self, String> {
11830        Self::open_path_with_identity_journal(
11831            client_id,
11832            schema_json,
11833            limits,
11834            path,
11835            !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11836        )
11837    }
11838
11839    fn open_path_with_identity_journal(
11840        client_id: Option<String>,
11841        schema_json: &Value,
11842        limits: ClientLimits,
11843        path: &str,
11844        require_wal: bool,
11845    ) -> Result<Self, String> {
11846        validate_client_limits(&limits)?;
11847        // §7.4.2: an invalid requested version is refused before the replica is
11848        // even created, so a rejected constructor leaves no file behind.
11849        parse_schema_json(schema_json)?;
11850        prepare_wasm_replica(path)?;
11851        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11852        // A file-backed native client uses an independent read connection for
11853        // latency-critical snapshots. WAL is SQLite's intended reader/writer
11854        // concurrency mode: a view read never holds a rollback-journal lock
11855        // that delays the client's next commit. The busy timeout must precede
11856        // this replica's own startup transaction; WAL is applied only after
11857        // startup succeeded, because a refused open must leave the replica's
11858        // journal mode and contents untouched (§7.4.2).
11859        conn.busy_timeout(std::time::Duration::from_millis(250))
11860            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11861        // Durability is the caller's contract here, not the VFS default: a
11862        // rollback-journal commit needs FULL before it writes the database
11863        // pages, or a killed worker can leave a half-written image behind.
11864        // WAL below inherits the same setting.
11865        conn.pragma_update(None, "synchronous", "FULL")
11866            .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11867        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11868        // A browser worker's sqlite-wasm VFS has no WAL mode, and the wasm
11869        // host has no independent snapshot reader. The WAL requirement exists
11870        // for native file replicas, where it is the reader/writer boundary.
11871        if require_wal {
11872            let journal_mode: String = client
11873                .conn
11874                .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11875                .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11876            if journal_mode != "wal" {
11877                return Err(
11878                    "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11879                );
11880            }
11881        }
11882        Ok(client)
11883    }
11884
11885    /// Delete a file replica at `path`, including its `-journal`, `-wal`, and
11886    /// `-shm` sidecars, through the VFS that owns it. Use this after
11887    /// `client.storage_corrupt`: an OPFS SAH pool stores those files under
11888    /// opaque names, so deleting the pool directory behind SQLite bypasses the
11889    /// pool's slot accounting.
11890    ///
11891    /// Close the client and every [`FileQuerySnapshotReader`] on `path` first;
11892    /// a VFS cannot delete a file another handle still owns. The next open
11893    /// creates a fresh replica (new client id) and re-bootstraps from the
11894    /// server. A memory replica has nothing to delete and fails with
11895    /// `sync.invalid_request`.
11896    pub fn reset_replica(path: &str) -> Result<(), String> {
11897        if path.is_empty() || path == ":memory:" {
11898            return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
11899        }
11900        // SAFETY: a null VFS name asks SQLite for its process-wide default VFS,
11901        // the same one `Connection::open(path)` above selects. `xDelete` is
11902        // that VFS's own function pointer, and `c_name` outlives the call.
11903        let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
11904        if vfs.is_null() {
11905            return Err("client.storage_io: no SQLite VFS is registered".to_owned());
11906        }
11907        let delete = unsafe { (*vfs).xDelete }
11908            .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
11909        for suffix in ["-wal", "-shm", "-journal", ""] {
11910            let file_name = format!("{path}{suffix}");
11911            let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
11912                format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
11913            })?;
11914            // SAFETY: `vfs` and its `xDelete` pointer come from the live
11915            // SQLite library; `c_name` is a valid NUL-terminated path.
11916            let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
11917            if code != rusqlite::ffi::SQLITE_OK {
11918                return Err(format!(
11919                    "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
11920                ));
11921            }
11922        }
11923        Ok(())
11924    }
11925
11926    #[must_use]
11927    pub fn client_id(&self) -> &str {
11928        &self.client_id
11929    }
11930
11931    /// Rotate the caller's client id (§1.5) and return the new one. Use it
11932    /// after a `sync.invalid_client_id` refusal (`recommendedAction:
11933    /// resetClientId`): the next round carries the new id. A live realtime
11934    /// socket is closed so the next connection registers with the new id.
11935    /// The replica and its outbox stay in place; wipe them yourself when the
11936    /// stored data belongs to the account that signed out.
11937    pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
11938        self.disconnect_realtime(transport);
11939        let client_id = uuid::Uuid::new_v4().to_string();
11940        self.client_id.clone_from(&client_id);
11941        self.set_meta(CLIENT_ID_KEY, &client_id);
11942        self.round_failure = None;
11943        client_id
11944    }
11945
11946    /// Build a client over a caller-supplied rusqlite connection — the seam a
11947    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
11948    /// with an on-disk database (`Connection::open(path)`) rather than the
11949    /// default `:memory:`. The connection MUST be fresh (no pre-existing
11950    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
11951    /// the same file across process restarts reuses the persisted rows.
11952    pub fn with_connection(
11953        client_id: String,
11954        schema_json: &Value,
11955        limits: ClientLimits,
11956        conn: Connection,
11957    ) -> Result<Self, String> {
11958        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11959    }
11960
11961    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
11962    /// means a durable replica's persisted `clientId` wins over a fresh one.
11963    fn with_connection_identity(
11964        client_id: Option<String>,
11965        schema_json: &Value,
11966        limits: ClientLimits,
11967        conn: Connection,
11968    ) -> Result<Self, String> {
11969        validate_client_limits(&limits)?;
11970        let schema = parse_schema_json(schema_json)?;
11971        validate_authority_reads(&schema, &limits.authority_reads)?;
11972        // RFC 0005 D8: resolve the previous-version config before the opening
11973        // reset runs — the capture happens inside it. Bad bounds fail loud.
11974        let previous_version = match limits.previous_version_context {
11975            Some(config) => {
11976                config.validate()?;
11977                Some(config)
11978            }
11979            None => None,
11980        };
11981        let mut client = SyncClient {
11982            #[cfg(feature = "bench-internals")]
11983            benchmark_phases: Recorder::default(),
11984            conn,
11985            schema,
11986            client_id: client_id
11987                .clone()
11988                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11989            limits,
11990            subs: Vec::new(),
11991            outbox: Vec::new(),
11992            failed_commits: Vec::new(),
11993            retain_failed_commits: false,
11994            conflicts: Vec::new(),
11995            rejections: Vec::new(),
11996            schema_floor: None,
11997            lease_state: None,
11998            stopped: false,
11999            upgrading: false,
12000            sync_needed: false,
12001            realtime_policy: RealtimePolicy::Optional,
12002            realtime_state: RealtimeState::Disconnected,
12003            realtime_reason_code: None,
12004            realtime_retry_delay_ms: None,
12005            presence: HashMap::new(),
12006            now_ms: None,
12007            encryption: crate::values::EncryptionConfig::default(),
12008            security_preflight: false,
12009            transport_enabled: true,
12010            previous_version,
12011            insert_sql: RefCell::new(HashMap::new()),
12012            overlay_dirty: OverlayDirty::clean(),
12013            storage_failure: RefCell::new(None),
12014            #[cfg(test)]
12015            overlay_rebuild_count: Cell::new(0),
12016            #[cfg(test)]
12017            acknowledged_replay_count: Cell::new(0),
12018            #[cfg(test)]
12019            outcome_prune_count: Cell::new(0),
12020            progress: ProgressObserver::default(),
12021            change_queue: VecDeque::new(),
12022            sync_intent_queue: VecDeque::new(),
12023            retry_delay_ms: 250,
12024            round_retry_delay_ms: None,
12025            round_failure: None,
12026            active_round: None,
12027            last_round: None,
12028            last_change: None,
12029            query_failures: Vec::new(),
12030            coalesced: None,
12031            coalesced_failure: None,
12032        };
12033        // The row write path leans on the prepared-statement cache (two
12034        // insert statements per synced table, plus the bookkeeping
12035        // statements); size it so a multi-table schema never thrashes.
12036        client
12037            .conn
12038            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12039        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
12040        // writer) covers the marker read, the identity check, and every
12041        // bookkeeping/schema write, so a concurrent open that upgraded the
12042        // replica cannot interleave between the guard and the writes, and a
12043        // refusal leaves the replica unchanged.
12044        client
12045            .conn
12046            .execute_batch("BEGIN IMMEDIATE")
12047            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12048        let startup = (|| -> Result<(), String> {
12049            // The marker guard runs inside the write transaction, so the version
12050            // it validates is the version every write below commits under.
12051            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12052            // New app indexes may reference columns that only exist after a
12053            // version-bump reset.
12054            client.create_bookkeeping_tables()?;
12055            match client.get_meta(CLIENT_ID_KEY) {
12056                Some(existing) => match client_id {
12057                    Some(requested) if existing != requested => {
12058                        return Err(format!(
12059                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12060                        ))
12061                    }
12062                    _ => client.client_id = existing,
12063                },
12064                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12065            }
12066            client.restore_persisted_state()?;
12067            client.failed_commits = client.load_failed_commits()?;
12068            match marker {
12069                None => {
12070                    client.create_synced_tables()?;
12071                    client.persist_schema_version()?;
12072                    client.save_subscription_scope_schema();
12073                }
12074                Some(version) if version == client.schema.version => {
12075                    client.create_synced_tables()?;
12076                    // RFC 0005 D1: the same-version open backfills the descriptor
12077                    // for a database first opened by an unaware binary, so the
12078                    // NEXT bump can capture without needing a schema bump first.
12079                    set_local_schema_descriptor(&client.conn, &client.schema)
12080                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12081                    client.save_subscription_scope_schema();
12082                }
12083                Some(_) => client.run_schema_reset()?,
12084            }
12085            // RFC 0005 D9: the reset sweep cannot see a container whose capture
12086            // committed in its OWN file while the replica's savepoint never
12087            // released — a crash at that seam leaves a container beside a matching
12088            // OLD marker, and the next open at that version runs no reset. Discard
12089            // it before anything can read it. No-op when no container exists.
12090            client.reconcile_previous_version_at_boot()?;
12091            client.clear_satisfied_persisted_schema_floor();
12092            client.prune_unknown_subscriptions(false)?;
12093            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
12094            // the full startup/reset reconciliation boundary, inside this open
12095            // transaction and after the schema and identity guards, so a commit
12096            // an earlier version accepted is dropped with durable rejection
12097            // evidence before any replay. A table the current schema removed
12098            // keeps the §7.4.4 send-time classification, and an unresolved
12099            // encryption key stays with the send seam because hosts configure
12100            // keys after open.
12101            client.drop_unencodable_outbox(false, None)?;
12102            if marker == Some(client.schema.version)
12103                && (!client.outbox.is_empty()
12104                    || !client.failed_commits.is_empty()
12105                    || client.has_acknowledged_rows())
12106            {
12107                // Reconstruct the visible optimistic overlay from the durable
12108                // base plus outbox instead of trusting a process-interrupted
12109                // mirror.
12110                client.overlay_dirty.set(true);
12111                client.rebuild_overlay()?;
12112            }
12113            // Every persisted active subscription needs one catch-up pull on
12114            // open: realtime only covers changes after connection, while an
12115            // idempotent setWindow correctly creates no fresh command effect.
12116            // Pending outbox work has the same restart requirement. The core
12117            // owns this intent so native hosts never poll or require an
12118            // application-issued sync().
12119            client.enqueue_startup_sync_if_needed()?;
12120            Ok(())
12121        })();
12122        match startup {
12123            Ok(()) => match client.conn.execute_batch("COMMIT") {
12124                Ok(()) => Ok(client),
12125                Err(error) => {
12126                    let failure = Self::sqlite_failure(&client.storage_failure, error);
12127                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12128                        // Keep a failed rollback's typed storage classification.
12129                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12130                    }
12131                    Err(failure)
12132                }
12133            },
12134            Err(error) => {
12135                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12136                    // Keep a failed rollback's typed storage classification.
12137                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12138                }
12139                Err(error)
12140            }
12141        }
12142    }
12143
12144    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
12145    /// against this instead of system time (conformance virtual clock).
12146    pub fn set_now_ms(&mut self, now_ms: i64) {
12147        self.now_ms = Some(now_ms);
12148    }
12149
12150    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
12151    /// The command router parses these from the `create` command's
12152    /// `encryption` config (keys as `{$bytes: hex}`).
12153    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12154        self.encryption = encryption;
12155    }
12156
12157    #[must_use]
12158    pub fn security_lifecycle(&self) -> &'static str {
12159        if self.security_preflight {
12160            "preflight"
12161        } else {
12162            "active"
12163        }
12164    }
12165
12166    #[must_use]
12167    pub fn security_preflight(&self) -> bool {
12168        self.security_preflight
12169    }
12170
12171    /// Host-owned, non-persisted network gate. Local operations remain available.
12172    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12173        if self.transport_enabled != enabled {
12174            self.transport_enabled = enabled;
12175            self.sync_intent_queue.clear();
12176            self.sync_intent_queue
12177                .push_back(if enabled && !self.security_preflight {
12178                    SyncIntent::Interactive
12179                } else {
12180                    SyncIntent::None
12181                });
12182        }
12183        // A captured reply keeps its normal apply/revocation semantics.
12184        if !enabled && self.active_round.is_none() {
12185            self.disconnect_realtime(transport);
12186        }
12187    }
12188
12189    pub fn transport_enabled(&self) -> bool {
12190        self.transport_enabled
12191    }
12192
12193    /// Quarantine this replica: enter the fail-closed gate, release all
12194    /// core-owned key material, and record the gate in the database so
12195    /// reopening re-enters preflight until `activate_security` clears it.
12196    ///
12197    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
12198    /// which must NOT leave that durable mark.
12199    pub fn begin_security_preflight(&mut self) {
12200        self.seal_security_on_teardown();
12201        // Persist the quarantine so it survives handle teardown and restart:
12202        // reopening this replica re-enters preflight until activation clears it.
12203        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12204    }
12205
12206    /// Release core-owned key material as a host tears the client down,
12207    /// WITHOUT recording a quarantine.
12208    ///
12209    /// Shutting an activated client down is not a quarantine event. Persisting
12210    /// the gate here marks every cleanly closed replica as pending, and the
12211    /// next plain `create` is then refused permanently: the reopen path
12212    /// restores the flag from the marker and the create guard rejects it. The
12213    /// in-memory flag still closes the gate for anything still holding this
12214    /// instance, which is all a teardown barrier needs.
12215    pub fn seal_security_on_teardown(&mut self) {
12216        self.cancel_sync_round();
12217        self.security_preflight = true;
12218        self.encryption = crate::values::EncryptionConfig::default();
12219        self.sync_intent_queue.clear();
12220    }
12221
12222    /// Install the post-authentication keyring and release the host loop.
12223    pub fn activate_security(
12224        &mut self,
12225        encryption: crate::values::EncryptionConfig,
12226    ) -> Result<(), String> {
12227        if !self.security_preflight {
12228            return Err(
12229                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12230            );
12231        }
12232        self.enqueue_startup_sync_if_needed()?;
12233        self.encryption = encryption;
12234        self.security_preflight = false;
12235        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12236        Ok(())
12237    }
12238
12239    fn clock_now_ms(&self) -> i64 {
12240        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12241    }
12242
12243    fn create_bookkeeping_tables(&self) -> Result<(), String> {
12244        // Durable client bookkeeping (outbox + subscription + meta).
12245        self.conn
12246            .execute_batch(
12247                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12248                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12249                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12250                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12251                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12252                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12253                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12254                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12255                   PRIMARY KEY(commit_id, idx));
12256                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12257                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12258                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12259                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12260                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
12261                   client_commit_id TEXT NOT NULL UNIQUE,
12262                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12263                   recorded_at_ms INTEGER NOT NULL,
12264                   results_json TEXT NOT NULL,
12265                   operations_json TEXT,
12266                   resolution TEXT NOT NULL DEFAULT 'active'
12267                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12268                   resolved_at_ms INTEGER,
12269                   replacement_client_commit_id TEXT);
12270                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12271                   ON _syncular_commit_outcomes(resolution, seq);
12272                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12273                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12274                 CREATE TABLE IF NOT EXISTS _syncular_meta (
12275                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
12276                 CREATE TABLE IF NOT EXISTS _syncular_windows (
12277                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12278                   PRIMARY KEY (base, unit));
12279                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12280                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12281                   effective_scopes TEXT NOT NULL);
12282                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12283                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12284                   PRIMARY KEY(commit_id, blob_id));
12285                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12286                   ON _syncular_blob_commit_refs(blob_id);",
12287            )
12288            .map_err(|e| e.to_string())?;
12289        // Migrate an outcome journal created before failed aggregate
12290        // envelopes were retained. Historical rows intentionally stay NULL.
12291        let _ = self
12292            .conn
12293            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12294        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12295            self.set_meta(LOCAL_REVISION_KEY, "0");
12296        }
12297        // §5.9.7 blob cache. Mutable upload state stays off the body row so
12298        // completing a large upload does not rewrite its BLOB record.
12299        if self.schema_has_blobs() {
12300            let exists = self
12301                .conn
12302                .query_row(
12303                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12304                    [],
12305                    |row| row.get::<_, bool>(0),
12306                )
12307                .map_err(|e| e.to_string())?;
12308            if exists {
12309                let mut statement = self
12310                    .conn
12311                    .prepare("PRAGMA table_info(_syncular_blobs)")
12312                    .map_err(|e| e.to_string())?;
12313                let mut columns = statement
12314                    .query_map([], |row| row.get::<_, String>(1))
12315                    .map_err(|e| e.to_string())?
12316                    .collect::<Result<Vec<_>, _>>()
12317                    .map_err(|e| e.to_string())?;
12318                columns.sort();
12319                let mut expected = vec![
12320                    "blob_id",
12321                    "byte_length",
12322                    "bytes",
12323                    "created_at_ms",
12324                    "media_type",
12325                ];
12326                expected.sort_unstable();
12327                if columns != expected {
12328                    return Err(
12329                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12330                    );
12331                }
12332            } else {
12333                self.conn
12334                    .execute_batch(
12335                        "CREATE TABLE _syncular_blobs(
12336                           blob_id TEXT PRIMARY KEY,
12337                           bytes BLOB NOT NULL,
12338                           byte_length INTEGER NOT NULL,
12339                           media_type TEXT,
12340                           created_at_ms INTEGER NOT NULL);",
12341                    )
12342                    .map_err(|e| e.to_string())?;
12343            }
12344            self.conn
12345                .execute_batch(
12346                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12347                       blob_id TEXT PRIMARY KEY,
12348                       media_type TEXT,
12349                       created_at_ms INTEGER NOT NULL);",
12350                )
12351                .map_err(|e| e.to_string())?;
12352        }
12353        Ok(())
12354    }
12355
12356    /// True iff any synced table declares a `blob_ref` column (§5.9).
12357    fn schema_has_blobs(&self) -> bool {
12358        self.schema
12359            .tables
12360            .iter()
12361            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12362    }
12363
12364    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
12365
12366    fn get_meta(&self, key: &str) -> Option<String> {
12367        meta_get(&self.conn, key)
12368    }
12369
12370    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12371        self.conn
12372            .query_row(
12373                "SELECT value FROM _syncular_meta WHERE key = ?1",
12374                rusqlite::params![key],
12375                |row| row.get::<_, String>(0),
12376            )
12377            .optional()
12378            .map_err(|_| {
12379                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12380            })
12381    }
12382
12383    // The caller holds the startup/reset transaction. Either both schema
12384    // records persist or the enclosing operation fails and rolls back.
12385    fn persist_schema_version(&self) -> Result<(), String> {
12386        self.conn
12387            .execute(
12388                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12389                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12390            )
12391            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12392        set_local_schema_descriptor(&self.conn, &self.schema)
12393            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12394    }
12395
12396    pub(crate) fn set_meta(&self, key: &str, value: &str) {
12397        meta_set(&self.conn, key, value);
12398    }
12399
12400    fn delete_meta(&self, key: &str) {
12401        meta_delete(&self.conn, key);
12402    }
12403
12404    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
12405    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12406        let mut stmt = self
12407            .conn
12408            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12409            .map_err(|error| error.to_string())?;
12410        let rows = stmt
12411            .query_map([], |row| {
12412                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12413            })
12414            .map_err(|error| error.to_string())?;
12415        let mut commits = Vec::new();
12416        for row in rows {
12417            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12418            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12419                format!("invalid persisted outbox {client_commit_id:?}: {error}")
12420            })?;
12421            let mut ops = Vec::with_capacity(entries.len());
12422            for entry in entries {
12423                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12424                ops.push(OutboxOp {
12425                    upsert: op == "upsert",
12426                    table: entry
12427                        .get("table")
12428                        .and_then(Value::as_str)
12429                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12430                        .to_owned(),
12431                    row_id: entry
12432                        .get("rowId")
12433                        .and_then(Value::as_str)
12434                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12435                        .to_owned(),
12436                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
12437                    // A legacy entry's `changedFields` key is ignored:
12438                    // the values map is the presence set (§6.1).
12439                    values: entry.get("values").and_then(Value::as_object).cloned(),
12440                });
12441            }
12442            commits.push(OutboxCommit {
12443                client_commit_id,
12444                ops,
12445            });
12446        }
12447        Ok(commits)
12448    }
12449
12450    fn restore_persisted_state(&mut self) -> Result<(), String> {
12451        self.subs = {
12452            let mut stmt = self
12453                .conn
12454                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12455                .map_err(|error| error.to_string())?;
12456            let rows = stmt
12457                .query_map([], |row| {
12458                    Ok((
12459                        row.get::<_, String>(0)?,
12460                        row.get::<_, String>(1)?,
12461                        row.get::<_, String>(2)?,
12462                    ))
12463                })
12464                .map_err(|error| error.to_string())?;
12465            let mut subscriptions = Vec::new();
12466            for row in rows {
12467                let (id, table, raw) = row.map_err(|error| error.to_string())?;
12468                let state: Value = serde_json::from_str(&raw)
12469                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12470                let requested = json_to_scope_map(
12471                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
12472                )?;
12473                let effective = state
12474                    .get("effectiveScopes")
12475                    .filter(|value| !value.is_null())
12476                    .map(json_to_scope_map)
12477                    .transpose()?;
12478                subscriptions.push(Subscription {
12479                    id,
12480                    table,
12481                    requested,
12482                    params: state
12483                        .get("params")
12484                        .and_then(Value::as_str)
12485                        .map(str::to_owned),
12486                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12487                    bootstrap_state: state
12488                        .get("bootstrapState")
12489                        .and_then(Value::as_str)
12490                        .map(str::to_owned),
12491                    state: SubState::parse(
12492                        state
12493                            .get("status")
12494                            .and_then(Value::as_str)
12495                            .unwrap_or("active"),
12496                    ),
12497                    reason_code: state
12498                        .get("reasonCode")
12499                        .and_then(Value::as_str)
12500                        .map(str::to_owned),
12501                    effective,
12502                    synced_once: state
12503                        .get("syncedOnce")
12504                        .and_then(Value::as_bool)
12505                        .unwrap_or(false),
12506                });
12507            }
12508            subscriptions
12509        };
12510
12511        self.outbox = self.load_outbox()?;
12512
12513        self.prune_commit_outcomes()?;
12514        let active = self.commit_outcomes(CommitOutcomeQuery {
12515            active_only: true,
12516            ..CommitOutcomeQuery::default()
12517        })?;
12518        self.conflicts = active
12519            .iter()
12520            .flat_map(|outcome| outcome.results.iter())
12521            .filter_map(|result| match result {
12522                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12523                _ => None,
12524            })
12525            .collect();
12526        self.rejections = active
12527            .iter()
12528            .flat_map(|outcome| outcome.results.iter())
12529            .filter_map(|result| match result {
12530                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12531                _ => None,
12532            })
12533            .collect();
12534
12535        self.lease_state = self
12536            .get_meta(LEASE_STATE_KEY)
12537            .map(|raw| serde_json::from_str(&raw))
12538            .transpose()
12539            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12540        self.schema_floor = self
12541            .get_meta(SCHEMA_FLOOR_KEY)
12542            .map(|raw| serde_json::from_str(&raw))
12543            .transpose()
12544            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12545        self.stopped = self.schema_floor.is_some();
12546        // A replica left in unactivated preflight reopens gated: the quarantine
12547        // decision persists with the data across handle teardown and restart.
12548        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12549            self.security_preflight = true;
12550        }
12551        Ok(())
12552    }
12553
12554    fn save_subscription_scope_schema(&self) {
12555        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12556            .schema
12557            .tables
12558            .iter()
12559            .map(|table| {
12560                (
12561                    table.name.clone(),
12562                    table
12563                        .scope_variables
12564                        .iter()
12565                        .map(|scope| {
12566                            (
12567                                scope.variable.clone(),
12568                                [scope.prefix.clone(), scope.column.clone()],
12569                            )
12570                        })
12571                        .collect(),
12572                )
12573            })
12574            .collect();
12575        self.set_meta(
12576            "subscriptionScopeSchema",
12577            &serde_json::to_string(&declarations).expect("scope schema serialization"),
12578        );
12579    }
12580
12581    /// Remove registrations with incompatible table/key/prefix/column meaning.
12582    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12583        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12584            self.get_meta("subscriptionScopeSchema")
12585                .map(|raw| serde_json::from_str(&raw))
12586                .transpose()
12587                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12588        } else {
12589            None
12590        };
12591        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12592            let Some(table) = self.schema.table(table_name) else {
12593                return false;
12594            };
12595            if requested
12596                .iter()
12597                .any(|(variable, _)| table.scope_column(variable).is_none())
12598            {
12599                return false;
12600            }
12601            if !bump {
12602                return true;
12603            }
12604            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12605                return false;
12606            };
12607            let variables: BTreeSet<_> = if requested.is_empty() {
12608                old.keys()
12609                    .cloned()
12610                    .chain(
12611                        table
12612                            .scope_variables
12613                            .iter()
12614                            .map(|scope| scope.variable.clone()),
12615                    )
12616                    .collect()
12617            } else {
12618                requested
12619                    .iter()
12620                    .map(|(variable, _)| variable.clone())
12621                    .collect()
12622            };
12623            variables.iter().all(|variable| {
12624                let current = table
12625                    .scope_variables
12626                    .iter()
12627                    .find(|scope| &scope.variable == variable);
12628                match (old.get(variable), current) {
12629                    (Some(old), Some(current)) => {
12630                        old == &[current.prefix.clone(), current.column.clone()]
12631                    }
12632                    _ => false,
12633                }
12634            })
12635        };
12636        let mut stale_ids: BTreeSet<_> = self
12637            .subs
12638            .iter()
12639            .filter(|sub| !compatible(&sub.table, &sub.requested))
12640            .map(|sub| sub.id.clone())
12641            .collect();
12642        for (id, table, effective) in self.load_pending_evictions()? {
12643            if !compatible(&table, &effective) {
12644                stale_ids.insert(id);
12645            }
12646        }
12647        for id in &stale_ids {
12648            for sql in [
12649                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12650                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12651                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12652            ] {
12653                self.conn
12654                    .execute(sql, rusqlite::params![id])
12655                    .map_err(|error| error.to_string())?;
12656            }
12657        }
12658        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12659        Ok(())
12660    }
12661
12662    #[must_use]
12663    pub fn local_revision(&self) -> u64 {
12664        self.get_meta(LOCAL_REVISION_KEY)
12665            .and_then(|value| value.parse().ok())
12666            .unwrap_or(0)
12667    }
12668
12669    /// §7.5: resolve a snapshot read on the mutable owner connection through
12670    /// the same primitive the read-only sidecar uses, so the command surface
12671    /// and the file sidecar return one revision, rows, coverage, catch-up, and
12672    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
12673    /// `owner` records its failure (and its next success) in diagnostics.
12674    pub fn snapshot_read(
12675        &mut self,
12676        request: &SnapshotReadRequest<'_>,
12677        owner: Option<QueryOwner<'_>>,
12678    ) -> Result<SnapshotRead, QueryReadFailure> {
12679        let result = snapshot_read_connection(&self.conn, request);
12680        if let Some(owner) = owner {
12681            self.record_query_read(owner, result.as_ref().err());
12682        }
12683        result
12684    }
12685
12686    #[must_use]
12687    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12688        SyncStatusSnapshot {
12689            current_schema_version: self.schema.version,
12690            outbox: self.outbox.len(),
12691            upgrading: self.upgrading,
12692            lease_state: self.lease_state.clone(),
12693            schema_floor: self.schema_floor.clone(),
12694            sync_needed: self.sync_needed,
12695            previous_version_context: self.previous_version_status(),
12696        }
12697    }
12698
12699    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
12700    /// rollback precondition), read without creating the file.
12701    fn previous_version_status(&self) -> PreviousVersionStatus {
12702        let Some(replica_path) = self.previous_version_replica_path() else {
12703            return PreviousVersionStatus {
12704                present: false,
12705                created_at_ms: None,
12706            };
12707        };
12708        match crate::previous_version::read_previous_version_record(&replica_path) {
12709            Ok(Some(record)) => PreviousVersionStatus {
12710                present: true,
12711                created_at_ms: Some(record.created_at_ms),
12712            },
12713            _ => PreviousVersionStatus {
12714                present: std::path::Path::new(
12715                    &crate::previous_version::previous_version_container_path(&replica_path),
12716                )
12717                .exists(),
12718                created_at_ms: None,
12719            },
12720        }
12721    }
12722
12723    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
12724    /// is always `previousVersion`; the container never makes
12725    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
12726    /// revocation, coverage completion and the TTL DISCARD the container and
12727    /// close the read with their own reason.
12728    pub fn previous_version_snapshot(
12729        &self,
12730        spec: &PreviousVersionReadSpec,
12731    ) -> Result<PreviousVersionSnapshot, String> {
12732        let now_ms = self.clock_now_ms();
12733        crate::previous_version::previous_version_snapshot(
12734            &self.conn,
12735            self.previous_version_replica_path().as_deref(),
12736            self.previous_version.as_ref(),
12737            PreviousVersionLifecycle {
12738                lease_inactive: self.previous_version_lease_inactive(now_ms),
12739                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12740                coverage_complete: self.previous_version_coverage_complete(),
12741            },
12742            now_ms,
12743            self.schema.version,
12744            spec,
12745        )
12746    }
12747
12748    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
12749    #[must_use]
12750    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12751        crate::previous_version::stored_previous_version_audit(&self.conn)
12752    }
12753
12754    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
12755    /// and both metadata records, and report whether anything was present.
12756    /// Idempotent: absence is a successful no-op.
12757    pub fn previous_version_discard(
12758        &self,
12759    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12760        self.drop_previous_version()
12761    }
12762
12763    /// The module's discard has no failure path (it removes the file even when
12764    /// the container cannot be opened), so the only error a caller could ever
12765    /// see is a bug; report absence rather than claiming a discard happened.
12766    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12767        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12768            present: false,
12769            discarded: false,
12770        };
12771        let Some(replica_path) = self.previous_version_replica_path() else {
12772            return absent;
12773        };
12774        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12775            .unwrap_or(absent)
12776    }
12777
12778    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
12779    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12780        let Some(lease) = &self.lease_state else {
12781            return false;
12782        };
12783        if lease.error_code.is_some() {
12784            return true;
12785        }
12786        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12787    }
12788
12789    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
12790    /// has `cursor >= 0` and no resume token. An empty active set is not
12791    /// completion (mirrors the TS predicate exactly).
12792    fn previous_version_coverage_complete(&self) -> bool {
12793        let active = self
12794            .subs
12795            .iter()
12796            .filter(|sub| sub.state == SubState::Active)
12797            .collect::<Vec<_>>();
12798        if active.is_empty() {
12799            return false;
12800        }
12801        active
12802            .iter()
12803            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12804    }
12805
12806    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
12807    /// apply. A container that no longer has a reason to exist is discarded so
12808    /// the rows do not outlive the window they were kept for.
12809    fn previous_version_lifetime_check(&self) {
12810        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12811            return;
12812        };
12813        let now_ms = self.clock_now_ms();
12814        if self.previous_version_lease_inactive(now_ms) {
12815            let _ = self.drop_previous_version();
12816            return;
12817        }
12818        let Some(replica_path) = self.previous_version_replica_path() else {
12819            return;
12820        };
12821        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12822        else {
12823            return;
12824        };
12825        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12826            let _ = self.drop_previous_version();
12827            return;
12828        }
12829        if now_ms - record.created_at_ms > config.max_age_ms {
12830            let _ = self.drop_previous_version();
12831            return;
12832        }
12833        if self.previous_version_coverage_complete() {
12834            let _ = self.drop_previous_version();
12835        }
12836    }
12837
12838    pub fn diagnostics_snapshot(
12839        &self,
12840        request: &ClientDiagnosticsRequest,
12841    ) -> Result<ClientDiagnosticsSnapshot, String> {
12842        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12843            return Err(format!(
12844                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12845            ));
12846        }
12847        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12848        for sub in &self.subs {
12849            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12850            let complete =
12851                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12852            let state = match sub.state {
12853                SubState::Revoked => "revoked",
12854                SubState::Failed => "failed",
12855                SubState::Active if reset => "reset",
12856                SubState::Active if complete => "complete",
12857                SubState::Active => "bootstrapping",
12858            };
12859            subscriptions.insert(
12860                sub.id.clone(),
12861                DiagnosticSubscription {
12862                    id: sub.id.clone(),
12863                    table: sub.table.clone(),
12864                    state: state.to_owned(),
12865                    complete,
12866                    cursor: Some(sub.cursor),
12867                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12868                },
12869            );
12870        }
12871        for expected in &request.expected_subscriptions {
12872            if expected.id.is_empty() || expected.table.is_empty() {
12873                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12874            }
12875            if subscriptions
12876                .get(&expected.id)
12877                .is_some_and(|registered| registered.table != expected.table)
12878            {
12879                subscriptions.insert(
12880                    expected.id.clone(),
12881                    DiagnosticSubscription {
12882                        id: expected.id.clone(),
12883                        table: expected.table.clone(),
12884                        state: "failed".to_owned(),
12885                        complete: false,
12886                        cursor: None,
12887                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12888                    },
12889                );
12890            } else {
12891                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12892                    DiagnosticSubscription {
12893                        id: expected.id.clone(),
12894                        table: expected.table.clone(),
12895                        state: "unregistered".to_owned(),
12896                        complete: false,
12897                        cursor: None,
12898                        reason_code: None,
12899                    }
12900                });
12901            }
12902        }
12903        let mut ordered_subscriptions = Vec::new();
12904        let mut included = BTreeSet::new();
12905        for expected in &request.expected_subscriptions {
12906            if included.insert(expected.id.clone()) {
12907                if let Some(subscription) = subscriptions.get(&expected.id) {
12908                    ordered_subscriptions.push(subscription.clone());
12909                }
12910            }
12911        }
12912        for (id, subscription) in &subscriptions {
12913            if included.insert(id.clone()) {
12914                ordered_subscriptions.push(subscription.clone());
12915            }
12916        }
12917        let subscriptions_truncated =
12918            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12919        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12920        let captured_at_ms = self.clock_now_ms();
12921        let lease = if let Some(error_code) = self
12922            .lease_state
12923            .as_ref()
12924            .and_then(|state| state.error_code.clone())
12925        {
12926            ClientDiagnosticsLease {
12927                state: "stopped".to_owned(),
12928                expires_at_ms: self
12929                    .lease_state
12930                    .as_ref()
12931                    .and_then(|state| state.expires_at_ms),
12932                error_code: Some(Self::diagnostic_code(&error_code)),
12933            }
12934        } else if let Some(expires_at_ms) = self
12935            .lease_state
12936            .as_ref()
12937            .and_then(|state| state.expires_at_ms)
12938        {
12939            ClientDiagnosticsLease {
12940                state: if expires_at_ms <= captured_at_ms {
12941                    "expired".to_owned()
12942                } else {
12943                    "active".to_owned()
12944                },
12945                expires_at_ms: Some(expires_at_ms),
12946                error_code: None,
12947            }
12948        } else {
12949            ClientDiagnosticsLease {
12950                state: "none".to_owned(),
12951                expires_at_ms: None,
12952                error_code: None,
12953            }
12954        };
12955        let connectivity = match self.last_round.as_ref() {
12956            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12957            Some(round) if round.status == "succeeded" => "online",
12958            Some(round)
12959                if round.status == "failed"
12960                    && round
12961                        .error_code
12962                        .as_deref()
12963                        .is_some_and(Self::retryable_transport_code) =>
12964            {
12965                "offline"
12966            }
12967            _ => "unknown",
12968        };
12969        Ok(ClientDiagnosticsSnapshot {
12970            version: CLIENT_DIAGNOSTICS_VERSION,
12971            captured_at_ms,
12972            host: ClientDiagnosticsHost {
12973                kind: "direct".to_owned(),
12974                role: "single".to_owned(),
12975                connectivity: connectivity.to_owned(),
12976                realtime: self.realtime_state,
12977                realtime_policy: self.realtime_policy,
12978                realtime_reason_code: self.realtime_reason_code.clone(),
12979                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12980            },
12981            security_lifecycle: self.security_lifecycle().to_owned(),
12982            schema: ClientDiagnosticsSchema {
12983                current_version: self.schema.version,
12984                upgrading: self.upgrading,
12985                required_version: self
12986                    .schema_floor
12987                    .as_ref()
12988                    .and_then(|floor| floor.required_schema_version),
12989                latest_version: self
12990                    .schema_floor
12991                    .as_ref()
12992                    .and_then(|floor| floor.latest_schema_version),
12993            },
12994            replica: ClientDiagnosticsReplica {
12995                local_revision: self.local_revision().to_string(),
12996                sync_needed: self.sync_needed,
12997                pending_outbox: self.outbox.len(),
12998            },
12999            lease,
13000            subscriptions: ordered_subscriptions,
13001            subscriptions_truncated,
13002            last_round: self.last_round.clone(),
13003            last_change: self.last_change.clone(),
13004            storage: self.diagnostics_storage(),
13005            query_failures: self.query_failures.clone(),
13006        })
13007    }
13008
13009    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13010        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13011            let page_count: i64 = self
13012                .conn
13013                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13014            let page_size: i64 = self
13015                .conn
13016                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13017            // Reuse compiled aggregates; every invocation still reads current storage.
13018            let outbox_bytes: i64 = self
13019                .conn
13020                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13021                .query_row([], |row| row.get(0))?;
13022            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13023                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13024            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13025            let blob_bytes = if self.schema_has_blobs() {
13026                self.conn
13027                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13028                    .query_row([], |row| row.get(0))?
13029            } else {
13030                0
13031            };
13032            let pressure = self
13033                .limits
13034                .blob_cache_max_bytes
13035                .is_some_and(|limit| blob_bytes > limit);
13036            Ok(ClientDiagnosticsStorage {
13037                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13038                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13039                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13040                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13041                retained_outcome_entries: Some(outcome_entries.max(0)),
13042                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13043                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13044            })
13045        };
13046        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13047            status: "unreadable".to_owned(),
13048            database_bytes_approx: None,
13049            pending_outbox_bytes_approx: None,
13050            retained_outcome_bytes_approx: None,
13051            retained_outcome_entries: None,
13052            blob_cache_bytes_approx: None,
13053            pressure_reason_code: None,
13054        })
13055    }
13056
13057    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13058        self.change_queue.drain(..).collect()
13059    }
13060
13061    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13062        let intents = self.sync_intent_queue.drain(..).collect();
13063        if self.transport_enabled {
13064            intents
13065        } else {
13066            vec![SyncIntent::None]
13067        }
13068    }
13069
13070    fn schedule_background_retry(&mut self) -> u64 {
13071        let delay_ms = self.retry_delay_ms;
13072        self.sync_intent_queue
13073            .push_back(SyncIntent::Background { delay_ms });
13074        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13075        self.round_retry_delay_ms = Some(delay_ms);
13076        delay_ms
13077    }
13078
13079    fn reset_background_retry(&mut self) {
13080        self.retry_delay_ms = 250;
13081    }
13082
13083    fn retryable_transport_code(code: &str) -> bool {
13084        code == "transport.failed"
13085            || code == "transport.timeout"
13086            || code == "transport.unavailable"
13087            || code == "sync.transport_failed"
13088    }
13089
13090    /// §1.5: a segment fetch that fails inside an otherwise complete round is
13091    /// invisible to the server (the round response arrived whole), so its code
13092    /// rides the following requests like a socket-round failure. A code the
13093    /// frame grammar rejects is not reportable and the previous code stays.
13094    fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13095        if ssp2::model::valid_round_failure_code(&error.code) {
13096            self.round_failure = Some(error.code.clone());
13097        }
13098        SectionError::Abort(error.code, error.message)
13099    }
13100
13101    /// A failed round worth a background retry: a transport failure, or a
13102    /// §10.2 code the catalog marks retryable (clients may hardcode the
13103    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
13104    /// on the server's `retryable` flag.
13105    fn retryable_failure_code(code: &str) -> bool {
13106        Self::retryable_transport_code(code)
13107            || matches!(
13108                code,
13109                "sync.auth_required"
13110                    | "sync.auth_lease_required"
13111                    | "sync.auth_lease_revoked"
13112                    | "sync.internal_error"
13113                    | "sync.idempotency_cache_miss"
13114                    | "sync.schema_not_ready"
13115                    | "sync.segment_expired"
13116                    | "sync.rate_limited"
13117                    | "sync.websocket_connection_limit"
13118            )
13119    }
13120
13121    fn diagnostic_code(code: &str) -> String {
13122        let valid = !code.is_empty()
13123            && code.len() <= 96
13124            && code.contains('.')
13125            && code
13126                .bytes()
13127                .next()
13128                .is_some_and(|byte| byte.is_ascii_lowercase())
13129            && code.bytes().all(|byte| {
13130                byte.is_ascii_lowercase()
13131                    || byte.is_ascii_digit()
13132                    || matches!(byte, b'.' | b'_' | b'-')
13133            });
13134        if valid {
13135            code.to_owned()
13136        } else {
13137            "client.unknown_failure".to_owned()
13138        }
13139    }
13140
13141    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13142        if self.sync_needed != value {
13143            if self.begin_observation("syncular_status").is_ok() {
13144                self.sync_needed = value;
13145                let batch = ChangeAccumulator {
13146                    status: true,
13147                    ..ChangeAccumulator::default()
13148                };
13149                if self.finish_observation("syncular_status", batch).is_err() {
13150                    self.rollback_observation("syncular_status");
13151                }
13152            } else {
13153                self.sync_needed = value;
13154            }
13155        }
13156        if value && interactive {
13157            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13158        }
13159    }
13160
13161    fn sqlite_failure(
13162        storage_failure: &RefCell<Option<QueryReadFailure>>,
13163        error: rusqlite::Error,
13164    ) -> String {
13165        let message = error.to_string();
13166        let failure = QueryReadFailure::from(error);
13167        let code = failure.code;
13168        if code.is_some() && storage_failure.borrow().is_none() {
13169            *storage_failure.borrow_mut() = Some(failure);
13170        }
13171        match code {
13172            Some(code) => format!("{code}: {message}"),
13173            None => message,
13174        }
13175    }
13176
13177    fn begin_observation(&self, name: &str) -> Result<(), String> {
13178        if let Some(failure) = &self.coalesced_failure {
13179            return Err(failure.clone());
13180        }
13181        // SQLite rolls a whole transaction back on some I/O and capacity
13182        // errors; later frames must not commit on their own after that.
13183        if self.coalesced.is_some() && self.conn.is_autocommit() {
13184            return Err("the coalesced apply transaction was rolled back".to_owned());
13185        }
13186        self.conn
13187            .execute_batch(&format!("SAVEPOINT {name}"))
13188            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13189    }
13190
13191    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
13192    /// already owns one.
13193    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13194        if self.coalesced.is_some() || !self.conn.is_autocommit() {
13195            return Ok(());
13196        }
13197        self.conn
13198            .execute_batch("BEGIN")
13199            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13200        self.coalesced = Some(CoalescedApply {
13201            rows: 0,
13202            subs: self.subs.clone(),
13203            outbox: self.outbox.clone(),
13204            failed_commits: self.failed_commits.clone(),
13205            conflicts: self.conflicts.clone(),
13206            rejections: self.rejections.clone(),
13207            schema_floor: self.schema_floor.clone(),
13208            lease_state: self.lease_state.clone(),
13209            stopped: self.stopped,
13210            upgrading: self.upgrading,
13211            sync_needed: self.sync_needed,
13212            overlay_dirty: self.overlay_dirty.snapshot(),
13213            change_queue: self.change_queue.len(),
13214            sync_intent_queue: self.sync_intent_queue.len(),
13215            last_change: self.last_change.clone(),
13216        });
13217        Ok(())
13218    }
13219
13220    /// Account the rows of the next frame or block. A unit that would take the
13221    /// coalesced transaction past the bound commits the earlier units first.
13222    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13223        let Some(open) = &self.coalesced else {
13224            return Ok(());
13225        };
13226        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13227            self.commit_coalesced_apply()?;
13228            self.begin_coalesced_apply()?;
13229        }
13230        if let Some(open) = &mut self.coalesced {
13231            open.rows += rows;
13232        }
13233        Ok(())
13234    }
13235
13236    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
13237    /// frame back, restores the in-memory state of its `BEGIN`, drops the
13238    /// change batches and sync intents queued since, and fails the rest of the
13239    /// response.
13240    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13241        let Some(open) = self.coalesced.take() else {
13242            return Ok(());
13243        };
13244        let committed = if self.conn.is_autocommit() {
13245            Err("the coalesced apply transaction was rolled back".to_owned())
13246        } else {
13247            self.conn
13248                .execute_batch("COMMIT")
13249                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13250        };
13251        let Err(error) = committed else {
13252            return Ok(());
13253        };
13254        if !self.conn.is_autocommit() {
13255            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13256                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13257            }
13258        }
13259        self.subs = open.subs;
13260        self.outbox = open.outbox;
13261        self.failed_commits = open.failed_commits;
13262        self.conflicts = open.conflicts;
13263        self.rejections = open.rejections;
13264        self.schema_floor = open.schema_floor;
13265        self.lease_state = open.lease_state;
13266        self.stopped = open.stopped;
13267        self.upgrading = open.upgrading;
13268        self.sync_needed = open.sync_needed;
13269        self.overlay_dirty.restore(open.overlay_dirty);
13270        self.change_queue.truncate(open.change_queue);
13271        self.sync_intent_queue.truncate(open.sync_intent_queue);
13272        self.last_change = open.last_change;
13273        self.coalesced_failure = Some(error.clone());
13274        Err(error)
13275    }
13276
13277    /// End the response's coalescing: COMMIT what is open and clear a failure.
13278    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13279        let committed = self.commit_coalesced_apply();
13280        match self.coalesced_failure.take() {
13281            Some(failure) => Err(failure),
13282            None => committed,
13283        }
13284    }
13285
13286    fn rollback_observation(&self, name: &str) {
13287        if let Err(error) = self
13288            .conn
13289            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13290        {
13291            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13292                if failure.rollback_failure.is_none() {
13293                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13294                }
13295            }
13296        }
13297    }
13298
13299    fn finish_observation(
13300        &mut self,
13301        name: &str,
13302        mut batch: ChangeAccumulator,
13303    ) -> Result<(), String> {
13304        #[cfg(feature = "bench-internals")]
13305        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13306        if self.failed_commits.iter().any(|(commit, _)| {
13307            commit
13308                .ops
13309                .iter()
13310                .any(|operation| batch.tables.contains_key(&operation.table))
13311        }) {
13312            batch.outcomes = true;
13313        }
13314        if !batch.touched() {
13315            self.conn
13316                .execute_batch(&format!("RELEASE {name}"))
13317                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13318            return Ok(());
13319        }
13320        let revision = self
13321            .local_revision()
13322            .checked_add(1)
13323            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13324        self.conn
13325            .execute(
13326                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13327                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13328            )
13329            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13330        let status = batch.status.then(|| self.status_snapshot());
13331        let event = ClientChangeBatch {
13332            revision: revision.to_string(),
13333            tables: batch
13334                .tables
13335                .into_iter()
13336                .map(|(table, scope_keys)| TableChange {
13337                    table,
13338                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13339                })
13340                .collect(),
13341            windows: batch
13342                .windows
13343                .into_iter()
13344                .map(|((base_key, table), units)| WindowChange {
13345                    base_key,
13346                    table,
13347                    units: units.into_iter().collect(),
13348                })
13349                .collect(),
13350            status,
13351            conflicts_changed: batch.conflicts,
13352            rejections_changed: batch.rejections,
13353            outcomes_changed: batch.outcomes,
13354        };
13355        self.conn
13356            .execute_batch(&format!("RELEASE {name}"))
13357            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13358        let mut diagnostic_tables = event
13359            .tables
13360            .iter()
13361            .map(|entry| entry.table.clone())
13362            .collect::<BTreeSet<_>>()
13363            .into_iter()
13364            .collect::<Vec<_>>();
13365        let mut diagnostic_windows = event
13366            .windows
13367            .iter()
13368            .map(|entry| entry.table.clone())
13369            .collect::<BTreeSet<_>>()
13370            .into_iter()
13371            .collect::<Vec<_>>();
13372        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13373            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13374        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13375        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13376        self.last_change = Some(DiagnosticLastChange {
13377            revision: event.revision.clone(),
13378            recorded_at_ms: self.clock_now_ms(),
13379            tables: diagnostic_tables,
13380            windows: diagnostic_windows,
13381            domains_truncated,
13382            status_changed: event.status.is_some(),
13383            conflicts_changed: event.conflicts_changed,
13384            rejections_changed: event.rejections_changed,
13385            outcomes_changed: event.outcomes_changed,
13386        });
13387        self.change_queue.push_back(event);
13388        Ok(())
13389    }
13390
13391    fn record_scope_map(
13392        &self,
13393        batch: &mut ChangeAccumulator,
13394        table_name: &str,
13395        scopes: &[(String, Vec<String>)],
13396    ) {
13397        let Some(table) = self.schema.table(table_name) else {
13398            return;
13399        };
13400        for (variable, values) in scopes {
13401            let Some(scope) = table
13402                .scope_variables
13403                .iter()
13404                .find(|scope| &scope.variable == variable)
13405            else {
13406                continue;
13407            };
13408            for value in values {
13409                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13410            }
13411        }
13412    }
13413
13414    /// Record a row's current scope keys from the base or visible table.
13415    fn record_row_scopes(
13416        &self,
13417        batch: &mut ChangeAccumulator,
13418        table_name: &str,
13419        row_id: &str,
13420        base: bool,
13421    ) -> bool {
13422        let Some(table) = self.schema.table(table_name) else {
13423            return false;
13424        };
13425        if table.scope_variables.is_empty() {
13426            return false;
13427        }
13428        let columns = table
13429            .scope_variables
13430            .iter()
13431            .map(|scope| quote_ident(&scope.column))
13432            .collect::<Vec<_>>()
13433            .join(", ");
13434        let full_table = if base {
13435            base_table(table_name)
13436        } else {
13437            visible_table(table_name)
13438        };
13439        let sql = format!(
13440            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13441            row_id_predicate(table)
13442        );
13443        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13444            return false;
13445        };
13446        let values = stmt.query_row(rusqlite::params![row_id], |row| {
13447            let mut values = Vec::with_capacity(table.scope_variables.len());
13448            for index in 0..table.scope_variables.len() {
13449                values.push(row.get::<_, Option<String>>(index)?);
13450            }
13451            Ok(values)
13452        });
13453        let Ok(values) = values else {
13454            return false;
13455        };
13456        let mut recorded = false;
13457        for (scope, value) in table.scope_variables.iter().zip(values) {
13458            if let Some(value) = value {
13459                batch.scope(table_name, format!("{}:{value}", scope.prefix));
13460                recorded = true;
13461            }
13462        }
13463        recorded
13464    }
13465
13466    fn record_commit_changes(
13467        &self,
13468        batch: &mut ChangeAccumulator,
13469        tables: &[String],
13470        changes: &[ssp2::model::Change],
13471    ) {
13472        #[cfg(feature = "bench-internals")]
13473        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13474        for change in changes {
13475            let Some(table_name) = tables.get(change.table_index as usize) else {
13476                continue;
13477            };
13478            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13479            if let Some(table) = self.schema.table(table_name) {
13480                for (variable, value) in &change.scopes {
13481                    if let Some(scope) = table
13482                        .scope_variables
13483                        .iter()
13484                        .find(|scope| &scope.variable == variable)
13485                    {
13486                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
13487                        precise = true;
13488                    }
13489                }
13490            }
13491            if !precise {
13492                batch.table(table_name);
13493            }
13494        }
13495    }
13496
13497    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13498        if effective.is_empty() {
13499            return false;
13500        }
13501        let Some(table) = self.schema.table(table_name) else {
13502            return false;
13503        };
13504        let mut clauses = Vec::new();
13505        let mut params = Vec::new();
13506        for (variable, values) in effective {
13507            let Some(column) = table.scope_column(variable) else {
13508                return false;
13509            };
13510            if values.is_empty() {
13511                return false;
13512            }
13513            let placeholders = values
13514                .iter()
13515                .map(|value| {
13516                    params.push(SqlValue::Text(value.clone()));
13517                    "?"
13518                })
13519                .collect::<Vec<_>>()
13520                .join(", ");
13521            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13522        }
13523        let sql = format!(
13524            "SELECT 1 FROM {} WHERE {} LIMIT 1",
13525            base_table(table_name),
13526            clauses.join(" AND ")
13527        );
13528        self.conn
13529            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13530            .is_ok()
13531    }
13532
13533    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
13534    pub fn upgrading(&self) -> bool {
13535        self.upgrading
13536    }
13537
13538    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
13539    /// keeping this client's local database (identity, outbox, tables). The
13540    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
13541    /// version increases. Recreation runs the same marker guard as opening
13542    /// a durable replica, holds the guard and every write it authorizes under
13543    /// one write transaction, and preserves the current client (compiled
13544    /// schema, outbox, tables) on refusal.
13545    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13546        let new_schema = parse_schema_json(schema_json)?;
13547        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13548        self.conn
13549            .execute_batch("BEGIN IMMEDIATE")
13550            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13551        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13552        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
13553        // state (dropped outbox commits, rejections, upgrade and floor flags,
13554        // overlay dirtiness) and appends change/intent batches. A COMMIT that
13555        // fails after the reset succeeded rolls the database back, so every
13556        // one of those fields is captured here and restored with it.
13557        let prior_subs = self.subs.clone();
13558        let prior_outbox = self.outbox.clone();
13559        let prior_rejections = self.rejections.clone();
13560        let prior_upgrading = self.upgrading;
13561        let prior_stopped = self.stopped;
13562        let prior_schema_floor = self.schema_floor.clone();
13563        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13564        let prior_sync_needed = self.sync_needed;
13565        let prior_active_round = self.active_round;
13566        let prior_changes = self.change_queue.len();
13567        let prior_intents = self.sync_intent_queue.len();
13568        let prior_last_change = self.last_change.clone();
13569        let guarded = (|| -> Result<(), String> {
13570            // §7.4.2: the marker is re-read under the write lock the caller
13571            // holds, so a replica another process upgraded meanwhile refuses
13572            // this recreation instead of having its marker and tables
13573            // downgraded.
13574            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13575            self.create_bookkeeping_tables()?;
13576            match marker {
13577                Some(version) if version < self.schema.version => self.run_schema_reset()?,
13578                None => {
13579                    self.create_synced_tables()?;
13580                    self.persist_schema_version()?;
13581                }
13582                _ => {}
13583            }
13584            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
13585            // durable outbox the same-version path is about to replay. Only the
13586            // outbox is reloaded: the recovery below reads it, and the caller's
13587            // rollback snapshot already covers this field.
13588            self.outbox = self.load_outbox()?;
13589            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
13590            // this guarded boundary once the schema and marker agree. A bump went
13591            // through `run_schema_reset`, which performs the same recovery after
13592            // it creates the new physical tables and before its final replay.
13593            self.drop_unencodable_outbox(false, None)?;
13594            if marker == Some(self.schema.version)
13595                && (!self.outbox.is_empty()
13596                    || !self.failed_commits.is_empty()
13597                    || self.has_acknowledged_rows())
13598            {
13599                self.overlay_dirty.set(true);
13600                self.rebuild_overlay()?;
13601            }
13602            self.prune_unknown_subscriptions(false)?;
13603            self.save_subscription_scope_schema();
13604            // The conformance recreate is the in-memory equivalent of reopening
13605            // a durable client. Apply the same startup catch-up contract even
13606            // when the schema itself did not change.
13607            self.enqueue_startup_sync_if_needed()?;
13608            Ok(())
13609        })();
13610        let outcome = match guarded {
13611            Ok(()) => self
13612                .conn
13613                .execute_batch("COMMIT")
13614                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13615            Err(error) => Err(error),
13616        };
13617        if let Err(error) = outcome {
13618            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13619                // Keep a failed rollback's typed storage classification.
13620                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13621            }
13622            self.schema = prior_schema;
13623            self.subs = prior_subs;
13624            self.outbox = prior_outbox;
13625            self.rejections = prior_rejections;
13626            self.upgrading = prior_upgrading;
13627            self.stopped = prior_stopped;
13628            self.schema_floor = prior_schema_floor;
13629            self.overlay_dirty.restore(prior_overlay_dirty);
13630            self.sync_needed = prior_sync_needed;
13631            self.active_round = prior_active_round;
13632            self.change_queue.truncate(prior_changes);
13633            self.sync_intent_queue.truncate(prior_intents);
13634            self.last_change = prior_last_change;
13635            // Prepared insert statements were derived from the reverted schema.
13636            self.insert_sql.borrow_mut().clear();
13637            return Err(error);
13638        }
13639        Ok(())
13640    }
13641
13642    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13643        let startup_work = !self.stopped
13644            && (!self.outbox.is_empty()
13645                || self.has_acknowledged_rows()
13646                || !self.load_pending_evictions()?.is_empty()
13647                || self.subs.iter().any(|sub| sub.state == SubState::Active));
13648        if startup_work {
13649            self.sync_needed = true;
13650            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13651        }
13652        Ok(())
13653    }
13654
13655    /// A native client persists schema-floor stops across process restarts.
13656    /// Once the running app already satisfies that floor, the persisted stop
13657    /// is only stale evidence from an older server round (for example, the
13658    /// server was restarted after catching up to an app that was ahead).
13659    /// Clear it and let the normal startup pull re-negotiate. If the server is
13660    /// still incompatible it will return the floor again in that first round.
13661    fn clear_satisfied_persisted_schema_floor(&mut self) {
13662        let satisfied = self
13663            .schema_floor
13664            .as_ref()
13665            .and_then(|floor| floor.required_schema_version)
13666            .is_some_and(|required| self.schema.version >= required);
13667        if !satisfied {
13668            return;
13669        }
13670        self.schema_floor = None;
13671        self.stopped = false;
13672        self.delete_meta(SCHEMA_FLOOR_KEY);
13673    }
13674
13675    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
13676    /// path already swept it; this covers a same-version or fresh-install open,
13677    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
13678    /// an aware binary can apply.
13679    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13680        let Some(replica_path) = self.previous_version_replica_path() else {
13681            return Ok(());
13682        };
13683        let max_age_ms = self
13684            .previous_version
13685            .filter(|config| config.enabled)
13686            .map(|config| config.max_age_ms);
13687        reconcile_previous_version_at_boot(
13688            &self.conn,
13689            &replica_path,
13690            self.schema.version,
13691            max_age_ms,
13692            self.clock_now_ms(),
13693        )
13694    }
13695
13696    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
13697    /// no file — an in-memory replica returns an empty path, and a `file:` URI
13698    /// has no plain sibling path — so the feature resolves as off there.
13699    fn previous_version_replica_path(&self) -> Option<String> {
13700        let path = self.conn.path()?;
13701        if path.is_empty() || path.starts_with("file:") {
13702            return None;
13703        }
13704        Some(path.to_owned())
13705    }
13706
13707    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
13708    /// and leaseState. Drops/recreates every synced table from the new
13709    /// schema, resets subscription sync-state (keeping registrations), clears
13710    /// the schema-floor stop state, rewrites the marker, drops outbox commits
13711    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
13712    fn run_schema_reset(&mut self) -> Result<(), String> {
13713        self.begin_observation("syncular_schema_reset")?;
13714        let prior_subs = self.subs.clone();
13715        let prior_outbox = self.outbox.clone();
13716        let prior_rejections = self.rejections.clone();
13717        let prior_upgrading = self.upgrading;
13718        let prior_stopped = self.stopped;
13719        let prior_schema_floor = self.schema_floor.clone();
13720        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13721        let prior_sync_needed = self.sync_needed;
13722        let prior_active_round = self.active_round;
13723        let mut batch = ChangeAccumulator::default();
13724        let result = self
13725            .run_schema_reset_observed(&mut batch, true)
13726            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13727        if let Err(error) = result {
13728            self.rollback_observation("syncular_schema_reset");
13729            self.subs = prior_subs;
13730            self.outbox = prior_outbox;
13731            self.rejections = prior_rejections;
13732            self.upgrading = prior_upgrading;
13733            self.stopped = prior_stopped;
13734            self.schema_floor = prior_schema_floor;
13735            self.overlay_dirty.restore(prior_overlay_dirty);
13736            self.sync_needed = prior_sync_needed;
13737            self.active_round = prior_active_round;
13738            return Err(error);
13739        }
13740        Ok(())
13741    }
13742
13743    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13744        if self.get_meta(LOG_EPOCH_KEY).is_none() {
13745            self.begin_observation("syncular_epoch_acquisition")?;
13746            let previous_sync_needed = self.sync_needed;
13747            if let Err(error) = self.conn.execute(
13748                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13749                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13750            ) {
13751                self.rollback_observation("syncular_epoch_acquisition");
13752                return Err(Self::sqlite_failure(&self.storage_failure, error));
13753            }
13754            self.sync_needed = true;
13755            let batch = ChangeAccumulator {
13756                status: true,
13757                ..ChangeAccumulator::default()
13758            };
13759            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13760                self.rollback_observation("syncular_epoch_acquisition");
13761                self.sync_needed = previous_sync_needed;
13762                return Err(error);
13763            }
13764            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13765            return Ok(Vec::new());
13766        }
13767        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13768        self.begin_observation("syncular_log_epoch_reset")?;
13769        let prior_subs = self.subs.clone();
13770        let prior_outbox = self.outbox.clone();
13771        let prior_rejections = self.rejections.clone();
13772        let prior_upgrading = self.upgrading;
13773        let prior_stopped = self.stopped;
13774        let prior_schema_floor = self.schema_floor.clone();
13775        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13776        let prior_sync_needed = self.sync_needed;
13777        let prior_active_round = self.active_round;
13778        let mut batch = ChangeAccumulator::default();
13779        let result = self
13780            .run_schema_reset_observed(&mut batch, false)
13781            .and_then(|()| {
13782                self.conn
13783                    .execute(
13784                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13785                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13786                    )
13787                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13788                self.sync_needed = true;
13789                batch.status = true;
13790                self.finish_observation("syncular_log_epoch_reset", batch)
13791            });
13792        if let Err(error) = result {
13793            self.rollback_observation("syncular_log_epoch_reset");
13794            self.subs = prior_subs;
13795            self.outbox = prior_outbox;
13796            self.rejections = prior_rejections;
13797            self.upgrading = prior_upgrading;
13798            self.stopped = prior_stopped;
13799            self.schema_floor = prior_schema_floor;
13800            self.overlay_dirty.restore(prior_overlay_dirty);
13801            self.sync_needed = prior_sync_needed;
13802            self.active_round = prior_active_round;
13803            return Err(error);
13804        }
13805        self.sync_intent_queue.push_back(SyncIntent::Interactive);
13806        Ok(resets)
13807    }
13808
13809    fn run_schema_reset_observed(
13810        &mut self,
13811        batch: &mut ChangeAccumulator,
13812        capture: bool,
13813    ) -> Result<(), String> {
13814        // §7.4.2: the reset is the only path that wipes local tables for a
13815        // version bump, and it rewrites the marker last. Re-read the marker
13816        // under this reset's transaction: a replica another process upgraded
13817        // meanwhile refuses here instead of being reset to this client's
13818        // older schema.
13819        read_local_schema_version(&self.conn, self.schema.version)?;
13820        self.conn
13821            .execute_batch(
13822                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13823            )
13824            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13825        self.cancel_sync_round();
13826        self.upgrading = true;
13827        batch.status = true;
13828        // RFC 0005 D6: classify the pending outbox against the NEW compiled
13829        // schema BEFORE the wipe, and record the advisory audit. This pass
13830        // drops nothing, so the audit sees the original pending intent; the
13831        // §7.4.4 send-time drop and the intrinsic value recovery later in this
13832        // reset are the paths that remove a commit. The classification is
13833        // shared with the send-time drop so the two cannot disagree about what
13834        // is incompatible.
13835        if self.previous_version.is_some_and(|config| config.enabled) {
13836            let previous_version = self
13837                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13838                .and_then(|value| value.parse::<i32>().ok())
13839                .unwrap_or(self.schema.version);
13840            let pending = self
13841                .outbox
13842                .iter()
13843                .map(|commit| PendingCommitAudit {
13844                    commit_id: &commit.client_commit_id,
13845                    operations: commit_audit_operations(commit),
13846                })
13847                .collect::<Vec<_>>();
13848            crate::previous_version::write_previous_version_audit(
13849                &self.conn,
13850                &crate::previous_version::build_previous_version_audit(
13851                    &self.schema,
13852                    &pending,
13853                    previous_version,
13854                    self.schema.version,
13855                    self.clock_now_ms(),
13856                ),
13857            );
13858        }
13859        // RFC 0005 D5: the container lives in its own FILE, so the reset can
13860        // never reach it. Sweep first (unconditional — also on the log-epoch
13861        // reset, which captures nothing), then capture BEFORE the wipe drops
13862        // any row. A refused capture records its reason and leaves no file.
13863        if let Some(replica_path) = self.previous_version_replica_path() {
13864            sweep_previous_version_container(&self.conn, &replica_path)?;
13865            if capture {
13866                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13867                    let previous_version = self
13868                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13869                        .and_then(|value| value.parse::<i32>().ok())
13870                        .unwrap_or(self.schema.version);
13871                    capture_previous_version_from_replica(
13872                        &self.conn,
13873                        &replica_path,
13874                        previous_version,
13875                        self.schema.version,
13876                        &config,
13877                        self.clock_now_ms(),
13878                    )?;
13879                }
13880            }
13881        }
13882        for table in &self.schema.tables {
13883            batch.table(&table.name);
13884        }
13885        for (base_key, unit, table) in self.load_registered_window_units() {
13886            batch.window(&base_key, &table, &unit);
13887        }
13888        if capture {
13889            self.prune_unknown_subscriptions(true)?;
13890        }
13891        // The per-table insert SQL is derived from the OLD column lists.
13892        self.insert_sql.borrow_mut().clear();
13893        self.overlay_dirty.set(true);
13894        // Drop every synced table (base + visible) that currently exists —
13895        // discovered from sqlite_master so a bump that adds/removes tables is
13896        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
13897        // and the blob cache) are preserved; base tables are `_syncular_base_*`
13898        // so they are matched explicitly, not by the bookkeeping filter.
13899        // Drop virtual tables first so SQLite removes every FTS shadow table
13900        // atomically instead of the generic discovery tearing it apart.
13901        let virtual_tables: Vec<String> = {
13902            let mut stmt = self
13903                .conn
13904                .prepare(
13905                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13906                )
13907                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13908            let rows = stmt
13909                .query_map([], |row| row.get::<_, String>(0))
13910                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13911            rows.filter_map(Result::ok)
13912                .filter(|name| is_synced_table_name(name))
13913                .collect()
13914        };
13915        for name in virtual_tables {
13916            self.conn
13917                .execute_batch(&format!(
13918                    "DROP TABLE IF EXISTS {}",
13919                    quote_ident(&format!("_syncular_fts_{name}"))
13920                ))
13921                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13922            self.conn
13923                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13924                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13925        }
13926        let existing: Vec<String> = {
13927            let mut stmt = self
13928                .conn
13929                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13930                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13931            let rows = stmt
13932                .query_map([], |row| row.get::<_, String>(0))
13933                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13934            rows.filter_map(Result::ok)
13935                .filter(|name| is_synced_table_name(name))
13936                .collect()
13937        };
13938        for name in &existing {
13939            if let Some(table) = name.strip_prefix("_syncular_base_") {
13940                batch.table(table);
13941            } else if !name.starts_with("_syncular_") {
13942                batch.table(name);
13943            }
13944        }
13945        for name in existing {
13946            self.conn
13947                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13948                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13949        }
13950        // Recreate the synced tables from the NEW schema.
13951        self.create_synced_tables()?;
13952        // Reset every subscription's sync-state, keeping the registration.
13953        for sub in &mut self.subs {
13954            sub.cursor = -1;
13955            sub.bootstrap_state = None;
13956            sub.effective = None;
13957            sub.state = SubState::Active;
13958            sub.reason_code = None;
13959            sub.synced_once = false;
13960        }
13961        let subs = self.subs.clone();
13962        for sub in &subs {
13963            self.persist_sub(sub)?;
13964        }
13965        // The stop state is over: this client now ships a servable schema.
13966        self.stopped = false;
13967        self.schema_floor = None;
13968        self.delete_meta(SCHEMA_FLOOR_KEY);
13969        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
13970        self.persist_schema_version()?;
13971        self.save_subscription_scope_schema();
13972        // §7.1/§7.4.4: the previous-version capture and audit above saw the
13973        // original pending intent; the synced tables now carry the new schema, so
13974        // recover intrinsically codec-invalid persisted values here, before the
13975        // final replay. A pending upsert that names a column the new schema
13976        // removed stays pending for the send-time `drop_incompatible_outbox`
13977        // prepass. The reset wrapper snapshots and restores the outbox,
13978        // rejections, and change queues, so a failed commit rolls this recovery
13979        // back with the rest of the reset.
13980        // §7.4.4: the enclosing reset observation owns the publication, so the
13981        // recovery contributes its rejection, outcome, and table domains to it.
13982        self.drop_unencodable_outbox(false, Some(batch))?;
13983        // Re-apply the surviving outbox optimistically over the empty tables.
13984        self.rebuild_overlay()?;
13985        Ok(())
13986    }
13987
13988    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
13989    /// column the current schema lacks (or a removed table) cannot be encoded
13990    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
13991    /// encodable and is validated by the server). Drop the commit, undo its
13992    /// optimistic projection, and raise a client-local
13993    /// `sync.outbox_incompatible` rejection.
13994    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13995        let incompatible = self
13996            .outbox
13997            .iter()
13998            .filter(|commit| {
13999                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14000            })
14001            .cloned()
14002            .collect::<Vec<_>>();
14003        if incompatible.is_empty() {
14004            return Ok(false);
14005        }
14006        // §7.4.4: one observation covers the durable outcome writes, the outbox
14007        // removal, the in-memory drop, and the overlay rebuild, so a storage
14008        // fault leaves the replica and the client unchanged and publishes no
14009        // change; a successful drop publishes exactly one batch carrying the
14010        // rejections, outcomes, status, and the tables whose projection changed.
14011        let prior_outbox = self.outbox.clone();
14012        let prior_rejections = self.rejections.clone();
14013        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14014        let owns_transaction = self.conn.is_autocommit();
14015        self.begin_observation("syncular_outbox_drop")?;
14016        let mut batch = ChangeAccumulator::default();
14017        let dropped = (|| -> Result<(), String> {
14018            let mut rejections = Vec::new();
14019            for commit in &incompatible {
14020                let results = commit
14021                    .ops
14022                    .iter()
14023                    .enumerate()
14024                    .map(|(op_index, operation)| {
14025                        let rejection = RejectionRecord {
14026                            client_commit_id: commit.client_commit_id.clone(),
14027                            op_index: op_index as i32,
14028                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14029                            message: "the persisted commit cannot encode under the current schema"
14030                                .to_owned(),
14031                            retryable: false,
14032                            details: None,
14033                            operation: Some(CommitOperation::from(operation)),
14034                        };
14035                        rejections.push(rejection.clone());
14036                        CommitOperationOutcome::Error { rejection }
14037                    })
14038                    .collect::<Vec<_>>();
14039                self.persist_commit_outcome(
14040                    &commit.client_commit_id,
14041                    CommitOutcomeStatus::Rejected,
14042                    &results,
14043                    Some(&commit.ops),
14044                )?;
14045                self.delete_outbox_persisted(&commit.client_commit_id)?;
14046                // A removed table has no local projection to invalidate; every
14047                // surviving table the dropped commit touched does.
14048                for operation in &commit.ops {
14049                    if self.schema.table(&operation.table).is_some() {
14050                        batch.table(&operation.table);
14051                    }
14052                }
14053            }
14054            self.prune_commit_outcomes()?;
14055            let incompatible_ids = incompatible
14056                .iter()
14057                .map(|commit| commit.client_commit_id.as_str())
14058                .collect::<BTreeSet<_>>();
14059            self.outbox
14060                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14061            self.rejections.extend(rejections);
14062            // The dropped commits' purely-optimistic rows are undone: the
14063            // visible projection is re-derived from the surviving base + outbox.
14064            self.overlay_dirty.set(true);
14065            self.rebuild_overlay()?;
14066            batch.status = true;
14067            batch.rejections = true;
14068            batch.outcomes = true;
14069            Ok(())
14070        })();
14071        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14072        if let Err(error) = result {
14073            // An outer RELEASE is the COMMIT. Roll back that transaction
14074            // directly: retrying RELEASE after ROLLBACK TO still needs the
14075            // reader's lock. A nested observation only rolls back its savepoint.
14076            if owns_transaction {
14077                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14078                    let mut failure = self.storage_failure.borrow_mut();
14079                    let failure =
14080                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14081                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14082                }
14083            } else {
14084                self.rollback_observation("syncular_outbox_drop");
14085            }
14086            self.outbox = prior_outbox;
14087            self.rejections = prior_rejections;
14088            self.overlay_dirty.restore(prior_overlay_dirty);
14089            return Err(error);
14090        }
14091        Ok(true)
14092    }
14093
14094    /// §7.4.3: (re)create the base + visible table pair for every synced
14095    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
14096    fn create_synced_tables(&self) -> Result<(), String> {
14097        for table in &self.schema.tables {
14098            // The base half + the visible half form the synced-table pair. An
14099            // index name is global in SQLite, so the base half's indexes are
14100            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
14101            for (full, index_prefix) in [
14102                (base_table(&table.name), "_syncular_base_"),
14103                (visible_table(&table.name), ""),
14104            ] {
14105                let mut cols: Vec<String> =
14106                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14107                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14108                let sql = format!(
14109                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14110                    cols.join(", "),
14111                    quote_ident(&table.primary_key)
14112                );
14113                self.conn
14114                    .execute(&sql, [])
14115                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14116                // Local secondary indexes (CREATE INDEX subset). Created on
14117                // both halves so mirror reads hit an index on either. Runs on
14118                // both the initial create and the §7.4.3 reset recreate path.
14119                for index in &table.indexes {
14120                    let unique = if index.unique { "UNIQUE " } else { "" };
14121                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14122                    let cols_sql = index
14123                        .columns
14124                        .iter()
14125                        .map(|c| quote_ident(c))
14126                        .collect::<Vec<_>>()
14127                        .join(", ");
14128                    let index_sql = format!(
14129                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14130                    );
14131                    self.conn
14132                        .execute(&index_sql, [])
14133                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14134                }
14135            }
14136        }
14137        // FTS exists only on the visible half. It is a local search
14138        // projection, never a synced/base table or a wire-schema member.
14139        for table in &self.schema.tables {
14140            for index in &table.fts_indexes {
14141                self.create_fts_projection(table, index)?;
14142            }
14143        }
14144        Ok(())
14145    }
14146
14147    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14148        let count: i64 = self
14149            .conn
14150            .query_row(
14151                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14152                rusqlite::params![index.name],
14153                |row| row.get(0),
14154            )
14155            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14156        Ok(count > 0)
14157    }
14158
14159    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14160        for suffix in ["ai", "ad", "au"] {
14161            self.conn
14162                .execute(
14163                    &format!(
14164                        "DROP TRIGGER IF EXISTS {}",
14165                        quote_ident(&format!("{}_{suffix}", index.name))
14166                    ),
14167                    [],
14168                )
14169                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14170        }
14171        Ok(())
14172    }
14173
14174    fn create_fts_triggers(
14175        &self,
14176        table: &TableSchema,
14177        index: &FtsIndexSchema,
14178    ) -> Result<(), String> {
14179        let fts = quote_ident(&index.name);
14180        let source = visible_table(&table.name);
14181        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14182        let pk = quote_ident(&table.primary_key);
14183        let projection_columns = std::iter::once(source_id.clone())
14184            .chain(index.columns.iter().map(|column| quote_ident(column)))
14185            .collect::<Vec<_>>()
14186            .join(", ");
14187        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14188            .chain(
14189                index
14190                    .columns
14191                    .iter()
14192                    .map(|column| format!("new.{}", quote_ident(column))),
14193            )
14194            .collect::<Vec<_>>()
14195            .join(", ");
14196        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14197        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)");
14198        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)");
14199        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})");
14200        let bi = quote_ident(&format!("{}_bi", index.name));
14201        let ai = quote_ident(&format!("{}_ai", index.name));
14202        let ad = quote_ident(&format!("{}_ad", index.name));
14203        let au = quote_ident(&format!("{}_au", index.name));
14204        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14205        let sql = format!(
14206            "DROP TRIGGER IF EXISTS {bi};
14207             DROP TRIGGER IF EXISTS {ai};
14208             DROP TRIGGER IF EXISTS {ad};
14209             DROP TRIGGER IF EXISTS {au};
14210             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14211             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14212             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14213             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14214        );
14215        self.conn
14216            .execute_batch(&sql)
14217            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14218    }
14219
14220    fn rebuild_fts_projection(
14221        &self,
14222        table: &TableSchema,
14223        index: &FtsIndexSchema,
14224    ) -> Result<(), String> {
14225        let fts = quote_ident(&index.name);
14226        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14227        let pk = quote_ident(&table.primary_key);
14228        let indexed_columns = index
14229            .columns
14230            .iter()
14231            .map(|column| quote_ident(column))
14232            .collect::<Vec<_>>();
14233        let projection_columns = std::iter::once(source_id.clone())
14234            .chain(indexed_columns.iter().cloned())
14235            .collect::<Vec<_>>()
14236            .join(", ");
14237        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14238        let sql = format!(
14239            "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};",
14240            columns = indexed_columns.join(", "),
14241            source = visible_table(&table.name),
14242        );
14243        self.conn
14244            .execute_batch(&sql)
14245            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14246    }
14247
14248    fn create_fts_projection(
14249        &self,
14250        table: &TableSchema,
14251        index: &FtsIndexSchema,
14252    ) -> Result<(), String> {
14253        self.begin_observation("syncular_fts_schema")?;
14254        let result = (|| {
14255            let existed = self.fts_projection_exists(index)?;
14256            let tokenizer = index.tokenize.replace('\'', "''");
14257            let columns = index
14258                .columns
14259                .iter()
14260                .map(|column| quote_ident(column))
14261                .collect::<Vec<_>>()
14262                .join(", ");
14263            let sql = format!(
14264            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14265            fts = quote_ident(&index.name),
14266            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14267        );
14268            self.conn.execute(&sql, []).map_err(|error| {
14269                format!(
14270                    "cannot create local FTS5 projection {:?}: {error}",
14271                    index.name
14272                )
14273            })?;
14274            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14275            let mapped: bool = self
14276                .conn
14277                .query_row(
14278                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14279                    [format!("_syncular_fts_{}", index.name)],
14280                    |row| row.get(0),
14281                )
14282                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14283            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))?;
14284            if !mapped && existed {
14285                self.conn
14286                    .execute_batch(&format!(
14287                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14288                        quote_ident(FTS_SOURCE_ID_COLUMN),
14289                        quote_ident(&index.name)
14290                    ))
14291                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14292            }
14293            self.create_fts_triggers(table, index)?;
14294            if !existed {
14295                self.rebuild_fts_projection(table, index)?;
14296            }
14297            Ok(())
14298        })();
14299        if let Err(error) = result {
14300            self.rollback_observation("syncular_fts_schema");
14301            return Err(error);
14302        }
14303        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14304            self.rollback_observation("syncular_fts_schema");
14305            return Err(error.to_string());
14306        }
14307        Ok(())
14308    }
14309
14310    // -- persistence write-through --------------------------------------------
14311
14312    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14313        let state = serde_json::json!({
14314            "requested": scope_map_to_json(&sub.requested),
14315            "params": sub.params,
14316            "cursor": sub.cursor,
14317            "bootstrapState": sub.bootstrap_state,
14318            "status": sub.state.name(),
14319            "reasonCode": sub.reason_code,
14320            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14321            "syncedOnce": sub.synced_once,
14322        });
14323        self.conn.execute(
14324            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14325            rusqlite::params![sub.id, sub.table, state.to_string()],
14326        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14327    }
14328
14329    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14330        let ops: Vec<Value> = commit
14331            .ops
14332            .iter()
14333            .map(|op| {
14334                serde_json::json!({
14335                    "op": if op.upsert { "upsert" } else { "delete" },
14336                    "table": op.table,
14337                    "rowId": op.row_id,
14338                    "baseVersion": op.base_version,
14339                    "values": op.values.clone().map(Value::Object),
14340                })
14341            })
14342            .collect();
14343        self.conn
14344            .execute(
14345                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14346                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14347            )
14348            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14349        for operation in &commit.ops {
14350            let Some(values) = &operation.values else {
14351                continue;
14352            };
14353            let Some(table) = self.schema.table(&operation.table) else {
14354                continue;
14355            };
14356            for column in table
14357                .columns
14358                .iter()
14359                .filter(|column| column.ty == ColumnType::BlobRef)
14360            {
14361                let Some(Value::String(raw)) = values.get(&column.name) else {
14362                    continue;
14363                };
14364                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14365                    continue;
14366                };
14367                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14368                    continue;
14369                };
14370                self.conn
14371                    .execute(
14372                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14373                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14374                        rusqlite::params![commit.client_commit_id, blob_id],
14375                    )
14376                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14377            }
14378        }
14379        Ok(())
14380    }
14381
14382    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14383        if let Some(commit) = self
14384            .outbox
14385            .iter()
14386            .find(|commit| commit.client_commit_id == client_commit_id)
14387        {
14388            for operation in &commit.ops {
14389                self.overlay_dirty.table(&operation.table);
14390            }
14391        }
14392
14393        self.conn
14394            .execute(
14395                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14396                rusqlite::params![client_commit_id],
14397            )
14398            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14399        self.conn
14400            .execute(
14401                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14402                rusqlite::params![client_commit_id],
14403            )
14404            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14405        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)", [])
14406            .map(|_| ())
14407            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14408    }
14409
14410    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14411        match status {
14412            CommitOutcomeStatus::Applied => "applied",
14413            CommitOutcomeStatus::Cached => "cached",
14414            CommitOutcomeStatus::Conflict => "conflict",
14415            CommitOutcomeStatus::Rejected => "rejected",
14416        }
14417    }
14418
14419    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14420        match resolution {
14421            CommitOutcomeResolution::Active => "active",
14422            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14423            CommitOutcomeResolution::Superseded => "superseded",
14424            CommitOutcomeResolution::Dismissed => "dismissed",
14425        }
14426    }
14427
14428    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14429        match value {
14430            "applied" => Ok(CommitOutcomeStatus::Applied),
14431            "cached" => Ok(CommitOutcomeStatus::Cached),
14432            "conflict" => Ok(CommitOutcomeStatus::Conflict),
14433            "rejected" => Ok(CommitOutcomeStatus::Rejected),
14434            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14435        }
14436    }
14437
14438    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14439        match value {
14440            "active" => Ok(CommitOutcomeResolution::Active),
14441            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14442            "superseded" => Ok(CommitOutcomeResolution::Superseded),
14443            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14444            _ => Err(format!(
14445                "invalid persisted commit outcome resolution {value:?}"
14446            )),
14447        }
14448    }
14449
14450    fn persist_commit_outcome(
14451        &self,
14452        client_commit_id: &str,
14453        status: CommitOutcomeStatus,
14454        results: &[CommitOperationOutcome],
14455        operations: Option<&[OutboxOp]>,
14456    ) -> Result<(), String> {
14457        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14458        let operations_json = operations
14459            .map(|items| {
14460                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14461            })
14462            .transpose()
14463            .map_err(|error| error.to_string())?;
14464        self.conn
14465            .execute(
14466                "INSERT INTO _syncular_commit_outcomes (
14467                   client_commit_id, status, recorded_at_ms, results_json,
14468                   operations_json, resolution
14469                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14470                rusqlite::params![
14471                    client_commit_id,
14472                    Self::outcome_status_name(status),
14473                    self.clock_now_ms(),
14474                    results_json,
14475                    operations_json
14476                ],
14477            )
14478            .map(|_| ())
14479            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14480    }
14481
14482    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
14483    /// the byte-envelope rule refuses cannot be journaled verbatim.
14484    fn stored_cell_is_representable(cell: &Value) -> bool {
14485        cell.is_null()
14486            || cell.is_string()
14487            || cell.is_boolean()
14488            || cell.is_number()
14489            || is_canonical_bytes_envelope(cell)
14490    }
14491
14492    fn stored_row_is_representable(value: &Value) -> bool {
14493        value
14494            .as_object()
14495            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14496    }
14497
14498    fn stored_operation_is_representable(value: &Value) -> bool {
14499        value.get("table").is_some_and(Value::is_string)
14500            && value.get("rowId").is_some_and(Value::is_string)
14501            && value.get("baseVersion").is_none_or(|v| {
14502                v.as_i64()
14503                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14504            })
14505            && match value.get("op").and_then(Value::as_str) {
14506                Some("upsert") => value
14507                    .get("values")
14508                    .is_some_and(Self::stored_row_is_representable),
14509                Some("delete") => value.get("values").is_none(),
14510                _ => false,
14511            }
14512    }
14513
14514    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
14515    /// only when the journal reader accepts the serialized operation, which is the
14516    /// rule `outcome_from_row` applies to this same row later.
14517    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14518        serde_json::to_value(CommitOperation::from(operation))
14519            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14520    }
14521
14522    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14523        let StoredCommitOutcomeRow {
14524            sequence,
14525            client_commit_id,
14526            status,
14527            recorded_at_ms,
14528            results_json,
14529            operations_json,
14530            resolution,
14531            resolved_at_ms,
14532            replacement_client_commit_id,
14533        } = row;
14534        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14535            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14536            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14537        {
14538            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14539        }
14540        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14541        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14542        for result in &stored {
14543            let nested = match result.get("status").and_then(Value::as_str) {
14544                Some("conflict") => result.get("conflict"),
14545                Some("error") => result.get("rejection"),
14546                _ => None,
14547            };
14548            if nested
14549                .and_then(|record| record.get("operation"))
14550                .is_some_and(|op| !Self::stored_operation_is_representable(op))
14551                || (result.get("status").and_then(Value::as_str) == Some("conflict")
14552                    && nested
14553                        .and_then(|record| record.get("serverRow"))
14554                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
14555            {
14556                return Err(invalid());
14557            }
14558        }
14559        let results: Vec<CommitOperationOutcome> =
14560            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14561        let has_conflict = results
14562            .iter()
14563            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14564        let has_error = results
14565            .iter()
14566            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14567        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14568        let redacted = results.is_empty() && operations_json.is_none();
14569        if match status {
14570            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14571            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14572            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14573        } {
14574            return Err(invalid());
14575        }
14576        let operations: Option<Vec<CommitOperation>> = operations_json
14577            .map(|raw| {
14578                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14579                if !stored.iter().all(Self::stored_operation_is_representable) {
14580                    return Err(invalid());
14581                }
14582                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14583            })
14584            .transpose()?;
14585        for result in &results {
14586            match result {
14587                CommitOperationOutcome::Conflict { conflict }
14588                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14589                {
14590                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14591                }
14592                CommitOperationOutcome::Error { rejection } => {
14593                    if let Some(details) = &rejection.details {
14594                        let encoded = serde_json::to_string(details).map_err(|_| {
14595                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14596                        })?;
14597                        RejectionDetails::parse(&encoded).map_err(|_| {
14598                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14599                        })?;
14600                    }
14601                }
14602                _ => {}
14603            }
14604        }
14605        Ok(CommitOutcome {
14606            sequence,
14607            client_commit_id,
14608            status,
14609            recorded_at_ms,
14610            results,
14611            operations,
14612            retained_rows: None,
14613            resolution: Self::parse_outcome_resolution(&resolution)?,
14614            resolved_at_ms,
14615            replacement_client_commit_id,
14616        })
14617    }
14618
14619    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
14620    fn unique_conflicts(
14621        &self,
14622        table: &TableSchema,
14623        values: &Map<String, Value>,
14624        base: bool,
14625    ) -> Result<Vec<RetainedUniqueConflict>, String> {
14626        let source = if base {
14627            base_table(&table.name)
14628        } else {
14629            visible_table(&table.name)
14630        };
14631        let mut conflicts = Vec::new();
14632        for index in table.indexes.iter().filter(|index| index.unique) {
14633            let mut params = vec![json_param_to_sql(
14634                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14635            )?];
14636            for name in &index.columns {
14637                let column = table
14638                    .columns
14639                    .iter()
14640                    .find(|column| column.name == *name)
14641                    .ok_or("sync.local_corrupt")?;
14642                let cell = json_to_column_value(column, values.get(name))?;
14643                params.push(owned_sql_value(RowParam::Cell(&cell))?);
14644            }
14645            let sql = format!(
14646                "SELECT * FROM {source} WHERE {} != ? AND {}",
14647                quote_ident(&table.primary_key),
14648                index
14649                    .columns
14650                    .iter()
14651                    .map(|column| format!("{} = ?", quote_ident(column)))
14652                    .collect::<Vec<_>>()
14653                    .join(" AND ")
14654            );
14655            let mut statement = self
14656                .conn
14657                .prepare_cached(&sql)
14658                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14659            let records = statement
14660                .query_map(rusqlite::params_from_iter(params), |record| {
14661                    let values = table
14662                        .columns
14663                        .iter()
14664                        .enumerate()
14665                        .map(|(index, column)| {
14666                            let value = sql_ref_to_json(column, record.get_ref(index)?);
14667                            json_to_column_value(column, Some(&value)).map_err(|message| {
14668                                rusqlite::Error::ToSqlConversionFailure(message.into())
14669                            })
14670                        })
14671                        .collect::<Result<Row, _>>()?;
14672                    Ok((values, record.get::<_, i64>(table.columns.len())?))
14673                })
14674                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14675            for record in records {
14676                let (row, version) =
14677                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14678                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14679                conflicts.push(RetainedUniqueConflict {
14680                    index: index.name.clone(),
14681                    columns: index.columns.clone(),
14682                    row_id,
14683                    server_row: table
14684                        .columns
14685                        .iter()
14686                        .zip(&row)
14687                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14688                        .collect(),
14689                    server_version: version,
14690                });
14691            }
14692        }
14693        Ok(conflicts)
14694    }
14695
14696    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14697        if let Some((commit, initial)) = self
14698            .failed_commits
14699            .iter()
14700            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14701        {
14702            let mut rows = Vec::new();
14703            for operation in &commit.ops {
14704                let table = self
14705                    .schema
14706                    .table(&operation.table)
14707                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
14708                let key =
14709                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14710                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14711                let base = self.stored_row(table, &operation.row_id, true)?;
14712                rows.push(RetainedCommitRow {
14713                    table: operation.table.clone(),
14714                    row_id: operation.row_id.clone(),
14715                    unique_conflicts: if operation.upsert {
14716                        self.unique_conflicts(
14717                            table,
14718                            initial
14719                                .get(&key)
14720                                .and_then(Value::as_object)
14721                                .or(operation.values.as_ref())
14722                                .ok_or("sync.local_corrupt")?,
14723                            true,
14724                        )?
14725                    } else {
14726                        Vec::new()
14727                    },
14728                    local_row: if operation.upsert {
14729                        initial
14730                            .get(&key)
14731                            .and_then(Value::as_object)
14732                            .cloned()
14733                            .or_else(|| operation.values.clone())
14734                    } else {
14735                        None
14736                    },
14737                    server_row: base.as_ref().map(|(values, _)| {
14738                        table
14739                            .columns
14740                            .iter()
14741                            .zip(values)
14742                            .map(|(column, value)| {
14743                                (column.name.clone(), column_value_to_json(value))
14744                            })
14745                            .collect()
14746                    }),
14747                    server_version: base.map(|(_, version)| version),
14748                });
14749            }
14750            outcome.retained_rows = Some(rows);
14751        }
14752        Ok(outcome)
14753    }
14754
14755    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14756        let row = self
14757            .conn
14758            .query_row(
14759                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14760                        resolution, resolved_at_ms, replacement_client_commit_id
14761                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14762                rusqlite::params![client_commit_id],
14763                |row| {
14764                    Ok(StoredCommitOutcomeRow {
14765                        sequence: row.get(0)?,
14766                        client_commit_id: row.get(1)?,
14767                        status: row.get(2)?,
14768                        recorded_at_ms: row.get(3)?,
14769                        results_json: row.get(4)?,
14770                        operations_json: row.get(5)?,
14771                        resolution: row.get(6)?,
14772                        resolved_at_ms: row.get(7)?,
14773                        replacement_client_commit_id: row.get(8)?,
14774                    })
14775                },
14776            )
14777            .optional()
14778            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14779        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14780            .transpose()
14781    }
14782
14783    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14784        if query.limit == Some(0) {
14785            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14786        }
14787        let mut sql = String::from(
14788            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14789                    resolution, resolved_at_ms, replacement_client_commit_id
14790               FROM _syncular_commit_outcomes",
14791        );
14792        if query.active_only {
14793            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14794        }
14795        sql.push_str(" ORDER BY seq DESC");
14796        if let Some(limit) = query.limit {
14797            sql.push_str(&format!(" LIMIT {limit}"));
14798        }
14799        let mut stmt = self
14800            .conn
14801            .prepare(&sql)
14802            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14803        let rows = stmt
14804            .query_map([], |row| {
14805                Ok(StoredCommitOutcomeRow {
14806                    sequence: row.get(0)?,
14807                    client_commit_id: row.get(1)?,
14808                    status: row.get(2)?,
14809                    recorded_at_ms: row.get(3)?,
14810                    results_json: row.get(4)?,
14811                    operations_json: row.get(5)?,
14812                    resolution: row.get(6)?,
14813                    resolved_at_ms: row.get(7)?,
14814                    replacement_client_commit_id: row.get(8)?,
14815                })
14816            })
14817            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14818        let mut outcomes = Vec::new();
14819        for row in rows {
14820            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14821                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14822            )?)?);
14823        }
14824        Ok(outcomes)
14825    }
14826
14827    fn prune_commit_outcomes(&self) -> Result<(), String> {
14828        #[cfg(test)]
14829        self.outcome_prune_count
14830            .set(self.outcome_prune_count.get() + 1);
14831        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14832        let count = self
14833            .conn
14834            .query_row(
14835                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14836                [],
14837                |row| row.get::<_, i64>(0),
14838            )
14839            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14840            as usize;
14841        let excess = count.saturating_sub(max_entries);
14842        if excess == 0 {
14843            return Ok(());
14844        }
14845        let mut stmt = self
14846            .conn
14847            .prepare(
14848                "SELECT seq FROM _syncular_commit_outcomes
14849                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
14850                  ORDER BY seq ASC LIMIT ?1",
14851            )
14852            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14853        let rows = stmt
14854            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14855            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14856        let sequences = rows
14857            .collect::<Result<Vec<_>, _>>()
14858            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14859        drop(stmt);
14860        for sequence in sequences {
14861            self.conn
14862                .execute(
14863                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14864                    rusqlite::params![sequence],
14865                )
14866                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14867        }
14868        Ok(())
14869    }
14870
14871    // -- driver surface ---------------------------------------------------------
14872
14873    pub fn subscribe(
14874        &mut self,
14875        id: String,
14876        table: String,
14877        scopes: Vec<(String, Vec<String>)>,
14878        params: Option<String>,
14879    ) -> Result<(), String> {
14880        if self.schema.table(&table).is_none() {
14881            return Err(format!("unknown table {table:?}"));
14882        }
14883        let registered_at = self
14884            .subs
14885            .iter()
14886            .position(|subscription| subscription.id == id);
14887        if let Some(index) = registered_at {
14888            let existing = &self.subs[index];
14889            let same_intent = existing.table == table
14890                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14891                && existing.params == params;
14892            if !same_intent {
14893                return Err(
14894                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14895                        .to_owned(),
14896                );
14897            }
14898            // §3.3: a revoked registration keeps its id and intent but no
14899            // progress. Subscribing again with the same intent re-arms it and
14900            // asks for a pull, which resolves its scopes from scratch. A
14901            // `failed` registration is a configuration error and stays put.
14902            if existing.state != SubState::Revoked {
14903                return Ok(());
14904            }
14905        }
14906        let sub = Subscription {
14907            id: id.clone(),
14908            table,
14909            requested: scopes,
14910            params,
14911            cursor: -1,
14912            bootstrap_state: None,
14913            state: SubState::Active,
14914            reason_code: None,
14915            effective: None,
14916            synced_once: false,
14917        };
14918        self.persist_sub(&sub)?;
14919        match registered_at {
14920            Some(index) => self.subs[index] = sub,
14921            None => self.subs.push(sub),
14922        }
14923        // Only a re-arm asks for a sync here: a fresh registration reaches the
14924        // next round through the host's schedule, and `set_window` (§4.8)
14925        // batches its own intent for the units it registers.
14926        if registered_at.is_some() {
14927            self.set_sync_needed(true, true);
14928        }
14929        Ok(())
14930    }
14931
14932    /// Register several subscriptions in one local transaction: the same
14933    /// rules as `subscribe` for each request, and one commit for all of them
14934    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
14935    /// registrations pays it once). An error rolls back every registration
14936    /// of the call.
14937    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14938        let prior_subs = self.subs.clone();
14939        self.begin_observation("syncular_subscribe")?;
14940        let registered = (|| {
14941            for request in requests {
14942                self.subscribe(request.id, request.table, request.scopes, request.params)?;
14943            }
14944            self.conn
14945                .execute_batch("RELEASE syncular_subscribe")
14946                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14947        })();
14948        if let Err(error) = registered {
14949            self.rollback_observation("syncular_subscribe");
14950            self.subs = prior_subs;
14951            return Err(error);
14952        }
14953        Ok(())
14954    }
14955
14956    pub fn unsubscribe(&mut self, id: &str) {
14957        if self.subs.iter().any(|s| s.id == id) {
14958            self.cancel_sync_round();
14959        }
14960        self.subs.retain(|s| s.id != id);
14961        let _ = self.conn.execute(
14962            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14963            rusqlite::params![id],
14964        );
14965    }
14966
14967    // -- windowed subscriptions (§4.8) ------------------------------------------
14968
14969    /// §4.8: set the live window units for a base — a value-sharded family
14970    /// of subscriptions, one per unit. Added units get fresh subscriptions
14971    /// (image-lane bootstrap on the next sync); removed units are
14972    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
14973    /// Idempotent; re-entry cancels any deferred eviction.
14974    pub fn set_window(
14975        &mut self,
14976        base: &WindowBase,
14977        units: &[String],
14978    ) -> Result<CommandEffects, String> {
14979        let table = self
14980            .schema
14981            .table(&base.table)
14982            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14983        if table.scope_column(&base.variable).is_none() {
14984            return Err(format!(
14985                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14986                base.table, base.variable
14987            ));
14988        }
14989        let base_key = window_base_key(base);
14990        let wanted: std::collections::HashSet<&String> = units.iter().collect();
14991        let live = self.load_window_units(&base_key);
14992        self.begin_observation("syncular_window")?;
14993        let mut batch = ChangeAccumulator::default();
14994        let mut changed = false;
14995
14996        let prior_subs = self.subs.clone();
14997        let widened = (|| {
14998            // Widen: units wanted but not live → fresh subscription + registry row.
14999            for unit in units {
15000                if live.iter().any(|(u, _)| u == unit) {
15001                    continue;
15002                }
15003                let sub_id = derive_sub_id(base, unit);
15004                self.delete_pending_evict(&sub_id)?;
15005                self.insert_window_unit(&base_key, unit, &sub_id)?;
15006                self.subscribe(
15007                    sub_id,
15008                    base.table.clone(),
15009                    unit_scopes(base, unit),
15010                    base.params.clone(),
15011                )?;
15012                batch.window(&base_key, &base.table, unit);
15013                changed = true;
15014            }
15015
15016            self.finish_observation("syncular_window", batch)
15017        })();
15018        if let Err(error) = widened {
15019            self.rollback_observation("syncular_window");
15020            self.subs = prior_subs;
15021            return Err(error);
15022        }
15023        for (unit, sub_id) in live {
15024            if wanted.contains(&unit) {
15025                continue;
15026            }
15027            self.rebuild_overlay_if_dirty()?;
15028            let prior_subs = self.subs.clone();
15029            self.begin_observation("syncular_window")?;
15030            let effective = self
15031                .subs
15032                .iter()
15033                .find(|sub| sub.id == sub_id)
15034                .and_then(|sub| sub.effective.clone())
15035                .unwrap_or_else(|| unit_scopes(base, &unit));
15036            let mut batch = ChangeAccumulator::default();
15037            self.record_scope_map(&mut batch, &base.table, &effective);
15038            batch.window(&base_key, &base.table, &unit);
15039            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15040            let remaining = match result {
15041                Ok(remaining) => remaining,
15042                Err(error) => {
15043                    self.rollback_observation("syncular_window");
15044                    self.subs = prior_subs;
15045                    return Err(error);
15046                }
15047            };
15048            if let Err(error) = self.finish_observation("syncular_window", batch) {
15049                self.rollback_observation("syncular_window");
15050                self.subs = prior_subs;
15051                return Err(error);
15052            }
15053            if remaining {
15054                self.drain_pending_evictions()?;
15055            }
15056            changed = true;
15057        }
15058        Ok(if changed {
15059            CommandEffects::interactive()
15060        } else {
15061            CommandEffects::none()
15062        })
15063    }
15064
15065    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
15066    /// the subset still bootstrap-pending. Registration alone is not
15067    /// completeness — a unit is pending until its subscription completes a
15068    /// bootstrap round (cursor advances past -1 with no resume token held).
15069    pub fn window_state(&self, base: &WindowBase) -> WindowState {
15070        let mut units = Vec::new();
15071        let mut pending = Vec::new();
15072        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15073            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15074                Some(sub) => {
15075                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15076                }
15077                None => true,
15078            };
15079            if is_pending {
15080                pending.push(unit.clone());
15081            }
15082            units.push(unit);
15083        }
15084        WindowState { units, pending }
15085    }
15086
15087    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15088        let mut stmt = match self
15089            .conn
15090            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15091        {
15092            Ok(stmt) => stmt,
15093            Err(_) => return Vec::new(),
15094        };
15095        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15096            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15097        });
15098        match rows {
15099            Ok(rows) => rows.filter_map(Result::ok).collect(),
15100            Err(_) => Vec::new(),
15101        }
15102    }
15103
15104    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15105        let mut stmt = match self.conn.prepare(
15106            "SELECT windows.base, windows.unit, subscriptions.tbl
15107               FROM _syncular_windows AS windows
15108               JOIN _syncular_subscriptions AS subscriptions
15109                 ON subscriptions.id = windows.sub_id
15110               ORDER BY windows.base, windows.unit",
15111        ) {
15112            Ok(stmt) => stmt,
15113            Err(_) => return Vec::new(),
15114        };
15115        let rows = stmt.query_map([], |row| {
15116            Ok((
15117                row.get::<_, String>(0)?,
15118                row.get::<_, String>(1)?,
15119                row.get::<_, String>(2)?,
15120            ))
15121        });
15122        match rows {
15123            Ok(rows) => rows.filter_map(Result::ok).collect(),
15124            Err(_) => Vec::new(),
15125        }
15126    }
15127
15128    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15129        self.conn
15130            .query_row(
15131                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15132                rusqlite::params![sub_id],
15133                |row| Ok((row.get(0)?, row.get(1)?)),
15134            )
15135            .ok()
15136    }
15137
15138    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15139        self.conn
15140            .execute(
15141                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15142                rusqlite::params![base_key, unit, sub_id],
15143            )
15144            .map(|_| ())
15145            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15146    }
15147
15148    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15149        self.conn
15150            .execute(
15151                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15152                rusqlite::params![base_key, unit],
15153            )
15154            .map(|_| ())
15155            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15156    }
15157
15158    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
15159    /// Deletes the unit's rows except those pinned by a pending outbox
15160    /// commit (E1); records a deferred eviction if any pin remains; discards
15161    /// the subscription's cursor/resume/effective-echo (E3) and version
15162    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
15163    /// nothing.
15164    fn evict_unit(
15165        &mut self,
15166        base_key: &str,
15167        base: &WindowBase,
15168        unit: &str,
15169        sub_id: &str,
15170    ) -> Result<bool, String> {
15171        let effective = self
15172            .subs
15173            .iter()
15174            .find(|s| s.id == sub_id)
15175            .and_then(|s| s.effective.clone())
15176            .unwrap_or_else(|| unit_scopes(base, unit));
15177        let pinned = self.pinned_row_ids(&base.table);
15178        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15179        self.delete_window_unit(base_key, unit)?;
15180        self.conn
15181            .execute(
15182                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15183                [sub_id],
15184            )
15185            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15186        self.subs.retain(|sub| sub.id != sub_id);
15187        if deferred {
15188            self.save_pending_evict(sub_id, &base.table, &effective)?;
15189        } else {
15190            self.delete_pending_evict(sub_id)?;
15191        }
15192        Ok(remaining)
15193    }
15194
15195    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
15196    /// primary keys. Returns (more unpinned work, any scoped rows remain).
15197    /// A missing scope-column mapping fails without deleting rows.
15198    fn evict_scope_rows(
15199        &self,
15200        table_name: &str,
15201        effective: &[(String, Vec<String>)],
15202        pinned: &std::collections::HashSet<String>,
15203    ) -> Result<(bool, bool), String> {
15204        if effective.is_empty() {
15205            return Ok((false, false));
15206        }
15207        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15208        let mut clauses = Vec::new();
15209        let mut params: Vec<SqlValue> = Vec::new();
15210        for (variable, values) in effective {
15211            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15212            if values.is_empty() {
15213                return Ok((false, false));
15214            }
15215            let holes = values
15216                .iter()
15217                .map(|v| {
15218                    params.push(SqlValue::Text(v.clone()));
15219                    "?"
15220                })
15221                .collect::<Vec<_>>()
15222                .join(", ");
15223            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15224        }
15225        let scope = clauses.join(" AND ");
15226        let scope_params = params.len();
15227        let pk = quote_ident(&table.primary_key);
15228        if !pinned.is_empty() {
15229            let holes = pinned
15230                .iter()
15231                .map(|id| {
15232                    params.push(SqlValue::Text(id.clone()));
15233                    "?"
15234                })
15235                .collect::<Vec<_>>()
15236                .join(", ");
15237            clauses.push(format!("{pk} NOT IN ({holes})"));
15238        }
15239        let predicate = clauses.join(" AND ");
15240        let base = base_table(table_name);
15241        let visible = visible_table(table_name);
15242        let mut ids: Vec<SqlValue> = Vec::new();
15243        for source in [&base, &visible] {
15244            let mut stmt = self
15245                .conn
15246                .prepare_cached(&format!(
15247                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15248                ))
15249                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15250            ids = stmt
15251                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
15252                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15253                .collect::<Result<_, _>>()
15254                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15255            if !ids.is_empty() {
15256                break;
15257            }
15258        }
15259        if !ids.is_empty() {
15260            for id in &ids {
15261                self.conn
15262                    .execute(
15263                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15264                        rusqlite::params![table_name, id],
15265                    )
15266                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15267            }
15268            let holes = vec!["?"; ids.len()].join(", ");
15269            for source in [&base, &visible] {
15270                self.conn
15271                    .execute(
15272                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15273                        rusqlite::params_from_iter(&ids),
15274                    )
15275                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15276            }
15277        }
15278        let mut remaining = false;
15279        let mut deferred = false;
15280        for source in [&base, &visible] {
15281            remaining |= self
15282                .conn
15283                .query_row(
15284                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15285                    rusqlite::params_from_iter(&params),
15286                    |row| row.get::<_, bool>(0),
15287                )
15288                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15289            deferred |= self
15290                .conn
15291                .query_row(
15292                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15293                    rusqlite::params_from_iter(&params[..scope_params]),
15294                    |row| row.get::<_, bool>(0),
15295                )
15296                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15297        }
15298        Ok((remaining, deferred))
15299    }
15300
15301    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
15302    fn drain_pending_evictions(&mut self) -> Result<(), String> {
15303        let pending = self.load_pending_evictions()?;
15304        if pending.is_empty() {
15305            return Ok(());
15306        }
15307        self.rebuild_overlay_if_dirty()?;
15308        for (sub_id, table_name, effective) in pending {
15309            if self.schema.table(&table_name).is_none() {
15310                self.delete_pending_evict(&sub_id)?;
15311                continue;
15312            }
15313            loop {
15314                std::thread::yield_now();
15315                self.begin_observation("syncular_evict")?;
15316                let pinned = self.pinned_row_ids(&table_name);
15317                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15318                let (remaining, deferred) = match result {
15319                    Ok(result) => result,
15320                    Err(error) => {
15321                        self.rollback_observation("syncular_evict");
15322                        return Err(error);
15323                    }
15324                };
15325                if !deferred {
15326                    if let Err(error) = self.delete_pending_evict(&sub_id) {
15327                        self.rollback_observation("syncular_evict");
15328                        return Err(error);
15329                    }
15330                }
15331                let mut batch = ChangeAccumulator::default();
15332                self.record_scope_map(&mut batch, &table_name, &effective);
15333                if let Err(error) = self.finish_observation("syncular_evict", batch) {
15334                    self.rollback_observation("syncular_evict");
15335                    return Err(error);
15336                }
15337                if !remaining {
15338                    break;
15339                }
15340            }
15341        }
15342        Ok(())
15343    }
15344
15345    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
15346    /// commit — rows that MUST NOT be evicted until the commit drains.
15347    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15348        let mut pinned = std::collections::HashSet::new();
15349        for commit in self
15350            .outbox
15351            .iter()
15352            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15353        {
15354            for op in &commit.ops {
15355                if op.table == table {
15356                    pinned.insert(op.row_id.clone());
15357                }
15358            }
15359        }
15360        pinned
15361    }
15362
15363    fn save_pending_evict(
15364        &self,
15365        sub_id: &str,
15366        table: &str,
15367        effective: &[(String, Vec<String>)],
15368    ) -> Result<(), String> {
15369        self.conn.execute(
15370            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15371            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15372        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15373    }
15374
15375    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15376        self.conn
15377            .execute(
15378                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15379                rusqlite::params![sub_id],
15380            )
15381            .map(|_| ())
15382            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15383    }
15384
15385    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15386        let mut stmt = self
15387            .conn
15388            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15389            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15390        let rows = stmt
15391            .query_map([], |row| {
15392                Ok((
15393                    row.get::<_, String>(0)?,
15394                    row.get::<_, String>(1)?,
15395                    row.get::<_, String>(2)?,
15396                ))
15397            })
15398            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15399        rows.map(|row| {
15400            let (sub_id, table, json) =
15401                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15402            let value: Value = serde_json::from_str(&json)
15403                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15404            let effective = json_to_scope_map(&value)
15405                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15406            Ok((sub_id, table, effective))
15407        })
15408        .collect()
15409    }
15410
15411    /// Select failed-intent retention; reload durable retained aggregates.
15412    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15413        self.retain_failed_commits = enabled;
15414        for (commit, _) in &self.failed_commits {
15415            for operation in &commit.ops {
15416                self.overlay_dirty.table(&operation.table);
15417            }
15418        }
15419        self.failed_commits = self.load_failed_commits()?;
15420        for (commit, _) in &self.failed_commits {
15421            for operation in &commit.ops {
15422                self.overlay_dirty.table(&operation.table);
15423            }
15424        }
15425        Ok(())
15426    }
15427
15428    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15429        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15430            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15431        let rows = statement
15432            .query_map([], |row| {
15433                Ok((
15434                    row.get::<_, String>(0)?,
15435                    row.get::<_, String>(1)?,
15436                    row.get::<_, String>(2)?,
15437                ))
15438            })
15439            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15440        rows.map(|row| {
15441            let (id, operations, initial) =
15442                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15443            let operations: Vec<CommitOperation> =
15444                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15445            let initial =
15446                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15447            Ok((
15448                OutboxCommit {
15449                    client_commit_id: id,
15450                    ops: operations
15451                        .into_iter()
15452                        .map(|op| OutboxOp {
15453                            upsert: op.op == "upsert",
15454                            table: op.table,
15455                            row_id: op.row_id,
15456                            base_version: op.base_version,
15457                            values: op.values,
15458                        })
15459                        .collect(),
15460                },
15461                initial,
15462            ))
15463        })
15464        .collect()
15465    }
15466
15467    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15468        let mut initial = Map::new();
15469        for operation in operations {
15470            let table = self
15471                .schema
15472                .table(&operation.table)
15473                .ok_or_else(|| "sync.unknown_table".to_owned())?;
15474            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15475                Some(row) => Some(row),
15476                None => self.visible_row(table, &operation.row_id)?,
15477            };
15478            if let Some((values, _)) = initial_row {
15479                let mut intended: Map<String, Value> = table
15480                    .columns
15481                    .iter()
15482                    .zip(values.iter())
15483                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15484                    .collect();
15485                if let Some(patch) = &operation.values {
15486                    intended.extend(patch.clone());
15487                }
15488                initial.insert(
15489                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15490                        .map_err(|_| "sync.local_corrupt".to_owned())?,
15491                    Value::Object(intended),
15492                );
15493            }
15494        }
15495        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15496            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15497            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15498            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15499        self.failed_commits.push((
15500            OutboxCommit {
15501                client_commit_id: id.to_owned(),
15502                ops: operations.to_vec(),
15503            },
15504            initial,
15505        ));
15506        Ok(())
15507    }
15508
15509    fn drop_failed_commits(
15510        &mut self,
15511        matches: impl Fn(&str, &Map<String, Value>) -> bool,
15512    ) -> Result<Vec<OutboxCommit>, String> {
15513        let mut doomed = self.drop_acknowledged_rows(&matches)?;
15514        for (commit, initial) in &self.failed_commits {
15515            for operation in &commit.ops {
15516                let key =
15517                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15518                        .map_err(|_| "sync.local_corrupt".to_owned())?;
15519                if initial
15520                    .get(&key)
15521                    .and_then(Value::as_object)
15522                    .is_some_and(|values| matches(&operation.table, values))
15523                {
15524                    doomed.push(commit.clone());
15525                    break;
15526                }
15527            }
15528        }
15529        for commit in &doomed {
15530            self.conn
15531                .execute(
15532                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15533                    rusqlite::params![commit.client_commit_id],
15534                )
15535                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15536        }
15537        for commit in &doomed {
15538            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))?;
15539        }
15540        self.conflicts.retain(|record| {
15541            !doomed
15542                .iter()
15543                .any(|commit| commit.client_commit_id == record.client_commit_id)
15544        });
15545        self.rejections.retain(|record| {
15546            !doomed
15547                .iter()
15548                .any(|commit| commit.client_commit_id == record.client_commit_id)
15549        });
15550        self.failed_commits.retain(|(commit, _)| {
15551            !doomed
15552                .iter()
15553                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15554        });
15555        for commit in &doomed {
15556            for operation in &commit.ops {
15557                self.overlay_dirty.table(&operation.table);
15558            }
15559        }
15560        Ok(doomed)
15561    }
15562
15563    /// Record one commit of local mutations. The returned commit ID is the
15564    /// §2.3 idempotency key. Failures are structured so callers read
15565    /// `code`/`message`/`details` instead of parsing a string prefix.
15566    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15567        let prior = self.storage_failure.borrow_mut().take();
15568        let outcome = (|| -> Result<String, String> {
15569            if mutations.is_empty() {
15570                return Err(
15571                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15572                        .to_owned(),
15573                );
15574            }
15575            let mut ops = Vec::with_capacity(mutations.len());
15576            for mutation in mutations {
15577                match mutation {
15578                    Mutation::Patch {
15579                        table,
15580                        values,
15581                        base_version,
15582                    } => {
15583                        let schema_table = self
15584                            .schema
15585                            .table(&table)
15586                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
15587                        let values = normalize_values_casing(schema_table, values)?;
15588                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15589                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15590                    }
15591                    Mutation::Upsert {
15592                        table,
15593                        values,
15594                        base_version,
15595                    } => {
15596                        let schema_table = self.schema.table(&table).ok_or_else(|| {
15597                            format!("sync.unknown_table: unknown table {table:?}")
15598                        })?;
15599                        // §5: value keys are accepted in snake_case AND the
15600                        // generated row types' camelCase; normalize to SQL truth
15601                        // before the pk lookup / codec see them.
15602                        let values = normalize_values_casing(schema_table, values)?;
15603                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15604                        // §6.7 `mutate` marks every column present: fill missing
15605                        // nullable columns with NULL before the payload is built.
15606                        let values = full_row_values(schema_table, values)?;
15607                        // §5.11: validate the payload encodes with the current
15608                        // codec. A key-selection or unknown-key failure is a
15609                        // durable rejection at the push seam (§10.3), never an
15610                        // author-time error. Full-row `mutate` presents every
15611                        // column, so no stored fallback is needed.
15612                        self.validate_author_encode(schema_table, &row_id, &values)?;
15613                        ops.push(OutboxOp {
15614                            upsert: true,
15615                            table,
15616                            row_id,
15617                            base_version,
15618                            values: Some(values),
15619                        });
15620                    }
15621                    Mutation::Delete {
15622                        table,
15623                        row_id,
15624                        base_version,
15625                    } => {
15626                        if self.schema.table(&table).is_none() {
15627                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
15628                        }
15629                        ops.push(OutboxOp {
15630                            upsert: false,
15631                            table,
15632                            row_id,
15633                            base_version,
15634                            values: None,
15635                        });
15636                    }
15637                }
15638            }
15639            self.record_outbox_commit(ops)
15640        })();
15641        self.finish_authoring(prior, outcome)
15642    }
15643
15644    /// Scope the retained `storage_failure` diagnostic to this call so an
15645    /// earlier operation's failure cannot classify a new one, and surface the
15646    /// call's own classified failure with its details. A fresh classified
15647    /// failure replaces the retained diagnostic; otherwise the earlier one is
15648    /// kept for the next sync.
15649    fn finish_authoring(
15650        &self,
15651        prior: Option<QueryReadFailure>,
15652        outcome: Result<String, String>,
15653    ) -> Result<String, ClientError> {
15654        let fresh = self.storage_failure.borrow_mut().take();
15655        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15656        match outcome {
15657            Ok(id) => Ok(id),
15658            Err(message) => Err(fresh
15659                .map(ClientError::from)
15660                .unwrap_or_else(|| ClientError::from(message))),
15661        }
15662    }
15663
15664    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15665        let commit = OutboxCommit {
15666            client_commit_id: uuid::Uuid::new_v4().to_string(),
15667            ops,
15668        };
15669        self.begin_observation("syncular_mutation")?;
15670        let mut batch = ChangeAccumulator::default();
15671        for op in &commit.ops {
15672            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15673            if let Some(values) = &op.values {
15674                if let Some(table) = self.schema.table(&op.table) {
15675                    for scope in &table.scope_variables {
15676                        if let Some(Value::String(value)) = values.get(&scope.column) {
15677                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15678                            precise = true;
15679                        }
15680                    }
15681                }
15682            }
15683            if !precise {
15684                batch.table(&op.table);
15685            }
15686        }
15687        if let Err(error) = self.persist_outbox_insert(&commit) {
15688            self.rollback_observation("syncular_mutation");
15689            return Err(error);
15690        }
15691        let was_dirty = self.overlay_dirty.snapshot();
15692        // A clean overlay contains the FIFO fold of every previous commit, so
15693        // the new commit applies over it. §7.1: an operation that does not
15694        // apply (a secondary unique collision) fails the whole commit; the
15695        // savepoint rollback removes the outbox entry and every visible write.
15696        if let Err(error) = self.rebuild_overlay_if_dirty() {
15697            self.rollback_observation("syncular_mutation");
15698            self.overlay_dirty.restore(was_dirty.clone());
15699            return Err(error);
15700        }
15701        if let Some(error) = commit
15702            .ops
15703            .iter()
15704            .find_map(|op| self.apply_outbox_op(op).err())
15705        {
15706            self.rollback_observation("syncular_mutation");
15707            self.overlay_dirty.restore(was_dirty.clone());
15708            return Err(error);
15709        }
15710        let id = commit.client_commit_id.clone();
15711        self.outbox.push(commit);
15712        batch.status = true;
15713        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15714            self.rollback_observation("syncular_mutation");
15715            self.outbox.pop();
15716            self.overlay_dirty.restore(was_dirty.clone());
15717            return Err(error);
15718        }
15719        Ok(id)
15720    }
15721
15722    /// Record one §6.1 sparse upsert: the primary key plus the supplied
15723    /// non-scope columns are present; every other column is absent and stays
15724    /// untouched on the server and in the local overlay. An absent local base
15725    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
15726    /// Failures are structured so callers read `code`/`message`/`details`.
15727    pub fn patch(
15728        &mut self,
15729        table: &str,
15730        row_id: &str,
15731        partial: Map<String, Value>,
15732        base_version: Option<i64>,
15733    ) -> Result<String, ClientError> {
15734        let prior = self.storage_failure.borrow_mut().take();
15735        let outcome = self.perform_patch(table, row_id, partial, base_version);
15736        self.finish_authoring(prior, outcome)
15737    }
15738
15739    /// The unscoped patch body. Public authoring entry points scope
15740    /// `storage_failure` around the whole operation, including any pre-read.
15741    fn perform_patch(
15742        &mut self,
15743        table: &str,
15744        row_id: &str,
15745        partial: Map<String, Value>,
15746        base_version: Option<i64>,
15747    ) -> Result<String, String> {
15748        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15749        self.record_outbox_commit(vec![operation])
15750    }
15751
15752    fn prepare_patch(
15753        &self,
15754        table: &str,
15755        row_id: &str,
15756        partial: Map<String, Value>,
15757        base_version: Option<i64>,
15758    ) -> Result<OutboxOp, String> {
15759        let schema_table = self
15760            .schema
15761            .table(table)
15762            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15763        let partial = normalize_values_casing(schema_table, partial)?;
15764        if let Some(pk) = partial.get(&schema_table.primary_key) {
15765            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15766                return Err(format!(
15767                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15768                ));
15769            }
15770        }
15771        // Existence reads only the key. Locked encrypted values stay opaque;
15772        // decoding the complete row here would reject a valid plain patch.
15773        let exists: bool = self
15774            .conn
15775            .query_row(
15776                &format!(
15777                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15778                    visible_table(table),
15779                    row_id_predicate(schema_table)
15780                ),
15781                rusqlite::params![row_id],
15782                |row| row.get(0),
15783            )
15784            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15785        if !exists {
15786            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15787        }
15788        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
15789        // server accepts a present scope column whose value equals the
15790        // stored row and applies it as a no-op, so a decoded envelope
15791        // round-trips. The local row is the only value the client can prove
15792        // equality against; when the row is absent locally the client cannot
15793        // prove equality against the server's stored row and fails closed.
15794        // A primary key that is also a scope column needs no stored-row
15795        // read: the primary key in a sparse payload is by construction the
15796        // row id being patched (§6.1), so its value is proven equal already.
15797        for scope in &schema_table.scope_variables {
15798            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15799                continue;
15800            }
15801            let Some(column) = schema_table
15802                .columns
15803                .iter()
15804                .find(|column| column.name == scope.column)
15805            else {
15806                continue;
15807            };
15808            let stored = self
15809                .conn
15810                .query_row(
15811                    &format!(
15812                        "SELECT {} FROM {} WHERE {}",
15813                        quote_ident(&scope.column),
15814                        visible_table(&schema_table.name),
15815                        row_id_predicate(schema_table)
15816                    ),
15817                    rusqlite::params![row_id],
15818                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15819                )
15820                .optional()
15821                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15822            // Decode the supplied value through the same column-type path the
15823            // rest of the patch encoding uses, so a value that only equals
15824            // the stored one in another number representation still compares
15825            // equal.
15826            let incoming = json_to_column_value(column, partial.get(&scope.column))
15827                .ok()
15828                .map(|value| column_value_to_json(&value));
15829            // A stored blob never equals a patch value, matching the server's
15830            // scope-column comparison (§3.4): bytes are unstorable as scope
15831            // values.
15832            let equal = stored.as_ref().is_some_and(|stored| {
15833                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15834            });
15835            if !equal {
15836                return Err(format!(
15837                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15838                    scope.column
15839                ));
15840            }
15841        }
15842        let mut values = partial;
15843        // Presence-set semantics: a proven-equal scope column is dropped, so
15844        // the patch leaves the stored value untouched (§6.2).
15845        for scope in &schema_table.scope_variables {
15846            values.remove(&scope.column);
15847        }
15848        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15849            // §2.4: the rowId of an integer key is its canonical decimal form.
15850            ColumnType::Integer => match row_id.parse::<i64>() {
15851                Ok(v)
15852                    if v.to_string() == row_id
15853                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15854                            .contains(&v) =>
15855                {
15856                    Value::from(v)
15857                }
15858                _ => {
15859                    return Err(format!(
15860                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15861                ))
15862                }
15863            },
15864            ColumnType::Boolean => match row_id {
15865                "true" => Value::Bool(true),
15866                "false" => Value::Bool(false),
15867                _ => {
15868                    return Err(format!(
15869                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15870                ))
15871                }
15872            },
15873            _ => Value::from(row_id.to_owned()),
15874        };
15875        values.insert(schema_table.primary_key.clone(), pk_value);
15876        // §5.11: structural validation only; an absent key-id selector is
15877        // resolved at the push seam from the stored local row, and a
15878        // key-selection or unknown-key failure becomes a durable
15879        // `client.encrypt_failed` rejection there (§10.3), never an
15880        // author-time error.
15881        self.validate_author_encode(schema_table, row_id, &values)?;
15882        Ok(OutboxOp {
15883            upsert: true,
15884            table: table.to_owned(),
15885            row_id: row_id.to_owned(),
15886            base_version,
15887            values: Some(values),
15888        })
15889    }
15890
15891    /// §5.11 key-id fallback: the stored local row's selector value as a
15892    /// positional row (only the selector slot filled). Returns None when no
15893    /// selector is configured, the row is locally absent, or the stored
15894    /// value is not a usable key id.
15895    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15896        let selector = self.encryption.key_id_columns.get(&table.name)?;
15897        let index = table
15898            .columns
15899            .iter()
15900            .position(|column| &column.name == selector)?;
15901        let sql = format!(
15902            "SELECT {} FROM {} WHERE {}",
15903            quote_ident(selector),
15904            visible_table(&table.name),
15905            row_id_predicate(table)
15906        );
15907        let key: Option<String> = self
15908            .conn
15909            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15910            .ok()?;
15911        let key = key.filter(|key| !key.is_empty())?;
15912        let mut fallback: Row = vec![None; table.columns.len()];
15913        fallback[index] = Some(ColumnValue::String(key));
15914        Some(fallback)
15915    }
15916
15917    /// §5.11/§10.3 author-time encode check: a structural failure (a
15918    /// non-nullable NULL, a value the codec rejects) is an author-time
15919    /// error; a key-selection or unknown-key failure is deferred to the push
15920    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
15921    fn validate_author_encode(
15922        &self,
15923        table: &TableSchema,
15924        row_id: &str,
15925        values: &Map<String, Value>,
15926    ) -> Result<(), String> {
15927        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15928            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15929            result => result.map(|_| ()),
15930        }
15931    }
15932
15933    /// §5.11/§10.3: drop every pending commit whose push encode fails with
15934    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
15935    /// raise one durable rejection per commit. `sync()` never aborts for an
15936    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
15937    /// undoing the dropped commit's purely-optimistic rows.
15938    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
15939    /// (an envelope or a type an earlier version accepted). `with_encryption`
15940    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
15941    /// the startup boundary leaves those to the send seam, where hosts configure
15942    /// keys after open.
15943    /// Recovery runs as one observation. An enclosing observation (the guarded
15944    /// schema reset) passes its `batch`, so the rejection, outcome, and table
15945    /// domains join that change event instead of publishing a second one.
15946    fn drop_unencodable_outbox(
15947        &mut self,
15948        with_encryption: bool,
15949        batch: Option<&mut ChangeAccumulator>,
15950    ) -> Result<bool, String> {
15951        let failures = self
15952            .outbox
15953            .iter()
15954            .filter_map(|commit| {
15955                commit
15956                    .ops
15957                    .iter()
15958                    .find_map(|op| {
15959                        let values = op.values.as_ref().filter(|_| op.upsert)?;
15960                        let table = self.schema.table(&op.table)?;
15961                        let fallback = self.stored_key_fallback(table, &op.row_id);
15962                        match encode_sparse_row_json(
15963                            table,
15964                            &op.row_id,
15965                            values,
15966                            &self.encryption,
15967                            fallback.as_ref(),
15968                        ) {
15969                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15970                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15971                            }
15972                            // §7.1/§7.4.4: a value the current codec refuses was
15973                            // persisted by a version that accepted it; it leaves
15974                            // through the incompatible-outbox path, so the FIFO
15975                            // queue never wedges.
15976                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15977                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15978                                OUTBOX_INCOMPATIBLE_CODE,
15979                            )),
15980                            _ => None,
15981                        }
15982                    })
15983                    .map(|(error, code)| (commit.clone(), error, code))
15984            })
15985            .collect::<Vec<_>>();
15986        if failures.is_empty() {
15987            return Ok(false);
15988        }
15989        // §7.4.4: one observation covers the durable outcome writes, the outbox
15990        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
15991        // leaves the replica and the client unchanged, a successful recovery
15992        // publishes one batch with the rejection, outcome, and table domains, and
15993        // an ordinary open with nothing to recover publishes nothing.
15994        let prior_outbox = self.outbox.clone();
15995        let prior_rejections = self.rejections.clone();
15996        // §5.11 retention and a self-published observation mutate two more
15997        // in-memory fields before this transaction ends, so the rollback below
15998        // restores them with the rest.
15999        let prior_failed_commits = self.failed_commits.clone();
16000        let prior_changes = self.change_queue.len();
16001        let prior_intents = self.sync_intent_queue.len();
16002        let prior_last_change = self.last_change.clone();
16003        let prior_overlay_dirty = self.overlay_dirty.snapshot();
16004        let owns_observation = batch.is_none();
16005        let owns_transaction = self.conn.is_autocommit();
16006        let mut domains = ChangeAccumulator::default();
16007        if owns_observation {
16008            self.begin_observation("syncular_value_recovery")?;
16009        }
16010        let dropped = (|| -> Result<(), String> {
16011            for (commit, message, code) in failures {
16012                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16013                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16014                }
16015                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16016                let rejection = RejectionRecord {
16017                    client_commit_id: commit.client_commit_id.clone(),
16018                    op_index: 0,
16019                    code: code.to_owned(),
16020                    message,
16021                    retryable: false,
16022                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16023                        field_paths: None,
16024                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16025                        required_action: None,
16026                        references: None,
16027                    }),
16028                    operation: representable
16029                        .then(|| commit.ops.first())
16030                        .flatten()
16031                        .map(CommitOperation::from),
16032                };
16033                self.persist_commit_outcome(
16034                    &commit.client_commit_id,
16035                    CommitOutcomeStatus::Rejected,
16036                    &[CommitOperationOutcome::Error {
16037                        rejection: rejection.clone(),
16038                    }],
16039                    representable.then_some(commit.ops.as_slice()),
16040                )?;
16041                self.delete_outbox_persisted(&commit.client_commit_id)?;
16042                self.outbox
16043                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16044                self.rejections.push(rejection);
16045                for operation in &commit.ops {
16046                    domains.table(operation.table.as_str());
16047                }
16048            }
16049            domains.status = true;
16050            domains.rejections = true;
16051            domains.outcomes = true;
16052            self.prune_commit_outcomes()?;
16053            // The visible projection is re-derived from the surviving base + outbox.
16054            self.overlay_dirty.set(true);
16055            self.rebuild_overlay()?;
16056            Ok(())
16057        })();
16058        let result = match batch {
16059            Some(batch) => {
16060                if dropped.is_ok() {
16061                    for table in domains.tables.keys() {
16062                        batch.table(table.as_str());
16063                    }
16064                    batch.status |= domains.status;
16065                    batch.rejections |= domains.rejections;
16066                    batch.outcomes |= domains.outcomes;
16067                }
16068                dropped
16069            }
16070            None => {
16071                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16072            }
16073        };
16074        if let Err(error) = result {
16075            if owns_observation {
16076                if owns_transaction {
16077                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16078                        let mut failure = self.storage_failure.borrow_mut();
16079                        let failure =
16080                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16081                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16082                    }
16083                } else {
16084                    self.rollback_observation("syncular_value_recovery");
16085                }
16086            }
16087            self.outbox = prior_outbox;
16088            self.rejections = prior_rejections;
16089            self.failed_commits = prior_failed_commits;
16090            self.change_queue.truncate(prior_changes);
16091            self.sync_intent_queue.truncate(prior_intents);
16092            self.last_change = prior_last_change;
16093            self.overlay_dirty.restore(prior_overlay_dirty);
16094            return Err(error);
16095        }
16096        Ok(true)
16097    }
16098
16099    pub fn pending_commit_ids(&self) -> Vec<String> {
16100        self.outbox
16101            .iter()
16102            .map(|c| c.client_commit_id.clone())
16103            .collect()
16104    }
16105
16106    /// §6.1: the sparse push payload bytes of every pending upsert operation,
16107    /// FIFO — exactly what the next push round would put on the wire. Used by
16108    /// conformance B.21(h) to pin cross-core byte identity.
16109    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16110        let mut payloads = Vec::new();
16111        for commit in &self.outbox {
16112            for op in &commit.ops {
16113                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
16114                    continue;
16115                };
16116                let Some(table) = self.schema.table(&op.table) else {
16117                    continue;
16118                };
16119                let fallback = self.stored_key_fallback(table, &op.row_id);
16120                if let Ok(payload) = encode_sparse_row_json(
16121                    table,
16122                    &op.row_id,
16123                    values,
16124                    &self.encryption,
16125                    fallback.as_ref(),
16126                ) {
16127                    payloads.push(payload);
16128                }
16129            }
16130        }
16131        payloads
16132    }
16133
16134    pub fn conflicts(&self) -> &[ConflictRecord] {
16135        &self.conflicts
16136    }
16137
16138    pub fn rejections(&self) -> &[RejectionRecord] {
16139        &self.rejections
16140    }
16141
16142    pub fn resolve_commit_outcome(
16143        &mut self,
16144        input: ResolveCommitOutcomeInput,
16145    ) -> Result<CommitOutcome, String> {
16146        let current = self
16147            .commit_outcome(&input.client_commit_id)?
16148            .ok_or_else(|| {
16149                format!(
16150                    "sync.outcome_not_found: no durable outcome exists for {:?}",
16151                    input.client_commit_id
16152                )
16153            })?;
16154        if current.resolution != CommitOutcomeResolution::Active {
16155            return Ok(current);
16156        }
16157        if input.resolution == CommitOutcomeResolution::Active {
16158            return Err("sync.invalid_request: resolution must leave active state".to_owned());
16159        }
16160        match input.resolution {
16161            CommitOutcomeResolution::Superseded => {
16162                let replacement = input
16163                    .replacement_client_commit_id
16164                    .as_deref()
16165                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16166                    .ok_or_else(|| {
16167                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16168                            .to_owned()
16169                    })?;
16170                let _ = replacement;
16171            }
16172            _ if input.replacement_client_commit_id.is_some() => {
16173                return Err(
16174                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16175                        .to_owned(),
16176                );
16177            }
16178            _ => {}
16179        }
16180        let allowed = match current.status {
16181            CommitOutcomeStatus::Conflict => matches!(
16182                input.resolution,
16183                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16184            ),
16185            CommitOutcomeStatus::Rejected => {
16186                matches!(
16187                    input.resolution,
16188                    CommitOutcomeResolution::Superseded
16189                        | CommitOutcomeResolution::ResolvedKeepServer
16190                )
16191            }
16192            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16193                input.resolution == CommitOutcomeResolution::Dismissed
16194            }
16195        };
16196        if !allowed {
16197            return Err(format!(
16198                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16199                input.resolution, current.status
16200            ));
16201        }
16202
16203        self.begin_observation("syncular_outcome_resolution")?;
16204        let previous_failed = self.failed_commits.clone();
16205        let previous_conflicts = self.conflicts.clone();
16206        let previous_rejections = self.rejections.clone();
16207        let result = (|| {
16208            self.conn
16209                .execute(
16210                    "UPDATE _syncular_commit_outcomes
16211                        SET resolution = ?1, resolved_at_ms = ?2,
16212                            replacement_client_commit_id = ?3
16213                      WHERE client_commit_id = ?4 AND resolution = 'active'",
16214                    rusqlite::params![
16215                        Self::outcome_resolution_name(input.resolution),
16216                        self.clock_now_ms(),
16217                        input.replacement_client_commit_id,
16218                        input.client_commit_id
16219                    ],
16220                )
16221                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16222            let resolved = self
16223                .commit_outcome(&current.client_commit_id)?
16224                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16225            self.conn
16226                .execute(
16227                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16228                    rusqlite::params![input.client_commit_id],
16229                )
16230                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16231            for (commit, _) in &self.failed_commits {
16232                if commit.client_commit_id == input.client_commit_id {
16233                    for operation in &commit.ops {
16234                        self.overlay_dirty.table(&operation.table);
16235                    }
16236                }
16237            }
16238            self.failed_commits
16239                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16240            self.prune_commit_outcomes()?;
16241            Ok(resolved)
16242        })();
16243        let resolved = match result {
16244            Ok(outcome) => outcome,
16245            Err(error) => {
16246                self.rollback_observation("syncular_outcome_resolution");
16247                self.failed_commits = previous_failed;
16248                self.conflicts = previous_conflicts;
16249                self.rejections = previous_rejections;
16250                return Err(error);
16251            }
16252        };
16253        self.conflicts
16254            .retain(|record| record.client_commit_id != current.client_commit_id);
16255        self.rejections
16256            .retain(|record| record.client_commit_id != current.client_commit_id);
16257        let mut batch = ChangeAccumulator {
16258            conflicts: current.status == CommitOutcomeStatus::Conflict,
16259            rejections: current.status == CommitOutcomeStatus::Rejected,
16260            outcomes: true,
16261            ..ChangeAccumulator::default()
16262        };
16263        if let Some(operations) = &current.operations {
16264            for operation in operations {
16265                batch.table(&operation.table);
16266            }
16267        }
16268        if let Err(error) = self
16269            .rebuild_overlay_if_dirty()
16270            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16271        {
16272            self.rollback_observation("syncular_outcome_resolution");
16273            self.failed_commits = previous_failed;
16274            self.conflicts = previous_conflicts;
16275            self.rejections = previous_rejections;
16276            return Err(error);
16277        }
16278        Ok(resolved)
16279    }
16280
16281    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16282        self.schema_floor.as_ref()
16283    }
16284
16285    /// §7.3.5: the client's opaque auth-lease state, if any.
16286    pub fn lease_state(&self) -> Option<&LeaseState> {
16287        self.lease_state.as_ref()
16288    }
16289
16290    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
16291    /// the two lease codes set it; other errors leave leaseState untouched.
16292    fn record_lease_error(&mut self, code: &str) {
16293        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16294            return;
16295        }
16296        let mut next = self.lease_state.clone().unwrap_or_default();
16297        next.error_code = Some(code.to_owned());
16298        self.set_lease_state(Some(next));
16299    }
16300
16301    fn set_lease_state(&mut self, next: Option<LeaseState>) {
16302        if self.lease_state == next {
16303            return;
16304        }
16305        if self.begin_observation("syncular_lease").is_err() {
16306            return;
16307        }
16308        self.lease_state = next;
16309        if let Some(lease) = &self.lease_state {
16310            if let Ok(json) = serde_json::to_string(lease) {
16311                self.set_meta(LEASE_STATE_KEY, &json);
16312            }
16313        } else {
16314            self.delete_meta(LEASE_STATE_KEY);
16315        }
16316        let batch = ChangeAccumulator {
16317            status: true,
16318            ..ChangeAccumulator::default()
16319        };
16320        if self.finish_observation("syncular_lease", batch).is_err() {
16321            self.rollback_observation("syncular_lease");
16322        }
16323    }
16324
16325    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16326        if self.schema_floor == next {
16327            return;
16328        }
16329        if self.begin_observation("syncular_schema_floor").is_err() {
16330            return;
16331        }
16332        self.schema_floor = next;
16333        self.stopped = self.schema_floor.is_some();
16334        if let Some(floor) = &self.schema_floor {
16335            if let Ok(json) = serde_json::to_string(floor) {
16336                self.set_meta(SCHEMA_FLOOR_KEY, &json);
16337            }
16338        } else {
16339            self.delete_meta(SCHEMA_FLOOR_KEY);
16340        }
16341        let batch = ChangeAccumulator {
16342            status: true,
16343            ..ChangeAccumulator::default()
16344        };
16345        if self
16346            .finish_observation("syncular_schema_floor", batch)
16347            .is_err()
16348        {
16349            self.rollback_observation("syncular_schema_floor");
16350        }
16351    }
16352
16353    fn set_upgrading(&mut self, value: bool) {
16354        if self.upgrading == value {
16355            return;
16356        }
16357        if self.begin_observation("syncular_upgrading").is_err() {
16358            return;
16359        }
16360        self.upgrading = value;
16361        let batch = ChangeAccumulator {
16362            status: true,
16363            ..ChangeAccumulator::default()
16364        };
16365        if self
16366            .finish_observation("syncular_upgrading", batch)
16367            .is_err()
16368        {
16369            self.rollback_observation("syncular_upgrading");
16370        }
16371    }
16372
16373    pub fn sync_needed(&self) -> bool {
16374        self.sync_needed
16375    }
16376
16377    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16378        let sub = self.subs.iter().find(|s| s.id == id)?;
16379        Some(SubscriptionStateView {
16380            id: sub.id.clone(),
16381            table: sub.table.clone(),
16382            status: sub.state.name().to_owned(),
16383            cursor: sub.cursor,
16384            has_resume_token: sub.bootstrap_state.is_some(),
16385            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16386            reason_code: sub.reason_code.clone(),
16387        })
16388    }
16389
16390    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16391        let schema_table = self
16392            .schema
16393            .table(table)
16394            .ok_or_else(|| format!("unknown table {table:?}"))?;
16395        let sql = format!(
16396            "SELECT * FROM {} ORDER BY {} ASC",
16397            visible_table(table),
16398            quote_ident(&schema_table.primary_key)
16399        );
16400        let mut stmt = self
16401            .conn
16402            .prepare(&sql)
16403            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16404        let mut rows = stmt
16405            .query([])
16406            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16407        let mut out = Vec::new();
16408        while let Some(row) = rows
16409            .next()
16410            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16411        {
16412            let mut values = Map::new();
16413            for (i, column) in schema_table.columns.iter().enumerate() {
16414                let value = row
16415                    .get_ref(i)
16416                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16417                values.insert(column.name.clone(), sql_ref_to_json(column, value));
16418            }
16419            let version: i64 = row
16420                .get(schema_table.columns.len())
16421                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16422            let row_id = match values.get(&schema_table.primary_key) {
16423                Some(Value::String(s)) => s.clone(),
16424                Some(Value::Number(n)) => n.to_string(),
16425                Some(Value::Bool(b)) => b.to_string(),
16426                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16427            };
16428            out.push(RowState {
16429                row_id,
16430                version,
16431                values,
16432            });
16433        }
16434        Ok(out)
16435    }
16436
16437    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
16438    //
16439    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
16440    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
16441    // the op with `yrs`, re-encodes the whole doc state, and records a
16442    // baseVersion-less sparse upsert carrying only the primary key and the
16443    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
16444    // the overlay re-materializes the edit immediately (optimistic apply,
16445    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
16446    // Byte-compatible with `@syncular/crdt-yjs`.
16447
16448    /// The current stored value of a `crdt` column for one row — the visible
16449    /// (optimistic) bytes, or `None` when the row is absent or the column is
16450    /// NULL (the empty document, §5.10.1). Errors if the column is not a
16451    /// `crdt` column (guards the app against a typo'd column name).
16452    #[cfg(feature = "crdt-yjs")]
16453    fn crdt_column_bytes(
16454        &self,
16455        table: &str,
16456        row_id: &str,
16457        column: &str,
16458    ) -> Result<Option<Vec<u8>>, String> {
16459        let schema_table = self
16460            .schema
16461            .table(table)
16462            .ok_or_else(|| format!("unknown table {table:?}"))?;
16463        let col = schema_table
16464            .columns
16465            .iter()
16466            .find(|c| c.name == column)
16467            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16468        if col.ty != ColumnType::Crdt {
16469            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16470        }
16471        let sql = format!(
16472            "SELECT {} FROM {} WHERE {}",
16473            quote_ident(column),
16474            visible_table(table),
16475            row_id_predicate(schema_table)
16476        );
16477        let bytes: Option<Vec<u8>> = self
16478            .conn
16479            .prepare_cached(&sql)
16480            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16481            .query_row(rusqlite::params![row_id], |row| {
16482                row.get::<_, Option<Vec<u8>>>(0)
16483            })
16484            .map_err(|e| match e {
16485                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16486                other => Self::sqlite_failure(&self.storage_failure, other),
16487            })?;
16488        Ok(bytes)
16489    }
16490
16491    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
16492    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
16493    /// An absent row / NULL column is the empty document (empty string).
16494    #[cfg(feature = "crdt-yjs")]
16495    pub fn crdt_text(
16496        &self,
16497        table: &str,
16498        row_id: &str,
16499        column: &str,
16500        name: &str,
16501    ) -> Result<String, String> {
16502        let bytes = self
16503            .crdt_column_bytes(table, row_id, column)?
16504            .unwrap_or_default();
16505        crate::crdt::text(&bytes, name)
16506    }
16507
16508    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
16509    /// record the resulting crdt-only sparse operation. Returns the enqueued
16510    /// `clientCommitId`.
16511    #[cfg(feature = "crdt-yjs")]
16512    pub fn crdt_insert_text(
16513        &mut self,
16514        table: &str,
16515        row_id: &str,
16516        column: &str,
16517        name: &str,
16518        index: u32,
16519        value: &str,
16520    ) -> Result<String, ClientError> {
16521        let prior = self.storage_failure.borrow_mut().take();
16522        let outcome = (|| -> Result<String, String> {
16523            let current = self
16524                .crdt_column_bytes(table, row_id, column)?
16525                .unwrap_or_default();
16526            let update = crate::crdt::insert_text(&current, name, index, value)?;
16527            self.perform_crdt_push_update(table, row_id, column, &update)
16528        })();
16529        self.finish_authoring(prior, outcome)
16530    }
16531
16532    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
16533    /// record the resulting crdt-only sparse operation. Returns the enqueued
16534    /// `clientCommitId`.
16535    #[cfg(feature = "crdt-yjs")]
16536    pub fn crdt_delete_text(
16537        &mut self,
16538        table: &str,
16539        row_id: &str,
16540        column: &str,
16541        name: &str,
16542        index: u32,
16543        len: u32,
16544    ) -> Result<String, ClientError> {
16545        let prior = self.storage_failure.borrow_mut().take();
16546        let outcome = (|| -> Result<String, String> {
16547            let current = self
16548                .crdt_column_bytes(table, row_id, column)?
16549                .unwrap_or_default();
16550            let update = crate::crdt::delete_text(&current, name, index, len)?;
16551            self.perform_crdt_push_update(table, row_id, column, &update)
16552        })();
16553        self.finish_authoring(prior, outcome)
16554    }
16555
16556    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
16557    /// `crdt` column's current state and record the resulting crdt-only sparse
16558    /// operation. The app authored the update with its own `yrs` model.
16559    /// Returns the enqueued `clientCommitId`.
16560    #[cfg(feature = "crdt-yjs")]
16561    pub fn crdt_apply_update(
16562        &mut self,
16563        table: &str,
16564        row_id: &str,
16565        column: &str,
16566        update: &[u8],
16567    ) -> Result<String, ClientError> {
16568        let prior = self.storage_failure.borrow_mut().take();
16569        let outcome = (|| -> Result<String, String> {
16570            let current = self
16571                .crdt_column_bytes(table, row_id, column)?
16572                .unwrap_or_default();
16573            let next = crate::crdt::apply_update(&current, update)?;
16574            self.perform_crdt_push_update(table, row_id, column, &next)
16575        })();
16576        self.finish_authoring(prior, outcome)
16577    }
16578
16579    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
16580    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
16581    /// enqueue it. The server merges the crdt column and leaves every other
16582    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
16583    /// clobber a concurrent edit to another column. A locally absent row
16584    /// records the same partial operation; the server answers per §6.2.
16585    #[cfg(feature = "crdt-yjs")]
16586    fn perform_crdt_push_update(
16587        &mut self,
16588        table: &str,
16589        row_id: &str,
16590        column: &str,
16591        crdt_bytes: &[u8],
16592    ) -> Result<String, String> {
16593        let mut bytes_obj = Map::new();
16594        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16595        let mut partial = Map::new();
16596        partial.insert(column.to_owned(), Value::Object(bytes_obj));
16597        self.perform_patch(table, row_id, partial, None)
16598    }
16599
16600    /// Run an arbitrary read-only SQL query against the local database and
16601    /// return each row as a `column-name → JSON value` map. This is the seam
16602    /// the React `useSyncQuery` live-query API needs (it takes app-authored
16603    /// SQL over the visible tables/views, not a fixed table read like
16604    /// [`read_rows`]).
16605    ///
16606    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
16607    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
16608    /// same envelope the command surface uses everywhere else. Output BLOB
16609    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
16610    ///
16611    /// The result column typing is dynamic (SQLite's stored affinity), because
16612    /// arbitrary SQL can alias, join, and compute — there is no schema column
16613    /// to consult per output cell, unlike [`read_rows`].
16614    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16615        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16616    }
16617
16618    /// Repository benchmark and conformance access, present only with `bench-internals`.
16619    #[cfg(feature = "bench-internals")]
16620    #[doc(hidden)]
16621    pub fn benchmark_connection(&mut self) -> &mut Connection {
16622        &mut self.conn
16623    }
16624
16625    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
16626    #[cfg(feature = "bench-internals")]
16627    #[doc(hidden)]
16628    pub fn benchmark_phases(
16629        &self,
16630        enabled: Option<bool>,
16631        reset: bool,
16632    ) -> Result<Option<Value>, String> {
16633        self.benchmark_phases.configure(enabled, reset)?;
16634        Ok(self.benchmark_phases.snapshot())
16635    }
16636
16637    /// One serialized, read-only authority observation, including durable coverage.
16638    pub fn authority_snapshot(&self) -> Result<Value, String> {
16639        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16640        if self.limits.authority_reads.is_empty() {
16641            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16642        }
16643        self.conn
16644            .execute_batch("SAVEPOINT authority_snapshot")
16645            .map_err(|e| e.to_string())?;
16646        let result = (|| {
16647            let revision = self
16648                .get_meta(LOCAL_REVISION_KEY)
16649                .ok_or("sync.local_corrupt: authority revision is missing")?;
16650            let parsed_revision = revision
16651                .parse::<u64>()
16652                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16653            if parsed_revision.to_string() != revision {
16654                return Err("sync.local_corrupt: authority revision is invalid".into());
16655            }
16656            let mut tables = Vec::new();
16657            for read in &self.limits.authority_reads {
16658                let table = self
16659                    .schema
16660                    .table(&read.table)
16661                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16662                let scalar = |column: &str, value: &Value| -> Result<String, String> {
16663                    let ty = table
16664                        .columns
16665                        .iter()
16666                        .find(|c| c.name == column)
16667                        .expect("validated authority column")
16668                        .ty;
16669                    if ty == ColumnType::Boolean {
16670                        if let Some(value) = value.as_bool() {
16671                            return Ok(value.to_string());
16672                        }
16673                        return match value.as_i64() {
16674                            Some(0) => Ok("false".into()),
16675                            Some(1) => Ok("true".into()),
16676                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16677                        };
16678                    }
16679                    match value {
16680                        Value::String(s) => Ok(s.clone()),
16681                        Value::Number(n) => Ok(n.to_string()),
16682                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16683                            .get("$bigint")
16684                            .and_then(Value::as_str)
16685                            .map(str::to_owned)
16686                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16687                        _ => {
16688                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16689                        }
16690                    }
16691                };
16692                let matches = |row: &QueryRow| -> Result<bool, String> {
16693                    for (variable, values) in &read.scopes {
16694                        let column = table
16695                            .scope_column(variable)
16696                            .expect("validated authority scope");
16697                        let value = row
16698                            .get(column)
16699                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
16700                        if value.is_null() || !values.contains(&scalar(column, value)?) {
16701                            return Ok(false);
16702                        }
16703                    }
16704                    Ok(true)
16705                };
16706                let selected = read
16707                    .columns
16708                    .iter()
16709                    .map(|c| quote_ident(c))
16710                    .collect::<Vec<_>>()
16711                    .join(",");
16712                let mut intent = BTreeSet::new();
16713                let mut relevant = BTreeSet::new();
16714                for row in query_connection(&self.conn,
16715                    "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=?",
16716                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16717                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16718                    intent.insert(id.clone());
16719                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16720                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16721                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16722                    }
16723                }
16724                let mut rows = BTreeMap::new();
16725                for full in [base_table(&read.table), visible_table(&read.table)] {
16726                    for mut values in query_connection(
16727                        &self.conn,
16728                        &format!(
16729                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16730                        ),
16731                        &[],
16732                    )
16733                    .map_err(|e| e.to_string())?
16734                    {
16735                        if !matches(&values)? {
16736                            continue;
16737                        }
16738                        let id = scalar(
16739                            &table.primary_key,
16740                            values
16741                                .get(&table.primary_key)
16742                                .expect("selected primary key"),
16743                        )?;
16744                        relevant.insert(id.clone());
16745                        if full == base_table(&read.table) {
16746                            let version = values
16747                                .remove("authority_version")
16748                                .expect("selected version");
16749                            let version = version
16750                                .as_i64()
16751                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
16752                            if version >= 0 {
16753                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16754                            }
16755                        }
16756                    }
16757                }
16758                intent.retain(|id| relevant.contains(id));
16759                let rows = rows.into_values().collect::<Vec<_>>();
16760                let mut persisted = Vec::new();
16761                for row in query_connection(
16762                    &self.conn,
16763                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16764                    &[Value::String(read.table.clone())],
16765                )
16766                .map_err(|e| e.message)?
16767                {
16768                    let state: Value = serde_json::from_str(
16769                        row.get("state_json")
16770                            .and_then(Value::as_str)
16771                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16772                    )
16773                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16774                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
16775                    let status = state
16776                        .get("status")
16777                        .and_then(Value::as_str)
16778                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16779                    let cursor = state
16780                        .get("cursor")
16781                        .and_then(Value::as_i64)
16782                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16783                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16784                        && status == "active"
16785                        && cursor >= 0
16786                        && state.get("bootstrapState").is_none_or(Value::is_null)
16787                        && state.get("params").is_none_or(Value::is_null);
16788                    let exposed = effective.map(|s| {
16789                        read.scopes
16790                            .keys()
16791                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16792                            .collect::<Map<_, _>>()
16793                    });
16794                    let requested = read
16795                        .scopes
16796                        .keys()
16797                        .map(|v| {
16798                            (
16799                                v.clone(),
16800                                state
16801                                    .get("requested")
16802                                    .and_then(|s| s.get(v))
16803                                    .cloned()
16804                                    .unwrap_or(json!([])),
16805                            )
16806                        })
16807                        .collect::<Map<_, _>>();
16808                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16809                }
16810                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16811                    match selectors.split_first() {
16812                        None => !candidates.is_empty(),
16813                        Some(((variable, values), rest)) => values.iter().all(|value| {
16814                            covered(
16815                                rest,
16816                                &candidates
16817                                    .iter()
16818                                    .copied()
16819                                    .filter(|p| {
16820                                        p["effectiveScopes"][variable.as_str()]
16821                                            .as_array()
16822                                            .is_some_and(|held| {
16823                                                held.contains(&Value::String(value.clone()))
16824                                                    || held.contains(&Value::String("*".into()))
16825                                            })
16826                                    })
16827                                    .collect::<Vec<_>>(),
16828                            )
16829                        }),
16830                    }
16831                }
16832                let coverage = if covered(
16833                    &read.scopes.iter().collect::<Vec<_>>(),
16834                    &persisted
16835                        .iter()
16836                        .filter(|p| p["complete"] == true)
16837                        .collect::<Vec<_>>(),
16838                ) {
16839                    "complete"
16840                } else if persisted.iter().any(|p| {
16841                    p["status"] == "active"
16842                        && read.scopes.iter().all(|(v, values)| {
16843                            values.iter().any(|value| {
16844                                p["requestedScopes"][v].as_array().is_some_and(|held| {
16845                                    held.contains(&Value::String(value.clone()))
16846                                        || held.contains(&Value::String("*".into()))
16847                                })
16848                            })
16849                        })
16850                }) {
16851                    "pending"
16852                } else {
16853                    "missing"
16854                };
16855                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16856            }
16857            Ok(
16858                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16859            )
16860        })();
16861        match result {
16862            Ok(snapshot) => {
16863                self.conn
16864                    .execute_batch("RELEASE authority_snapshot")
16865                    .map_err(|e| e.to_string())?;
16866                Ok(snapshot)
16867            }
16868            Err(error) => {
16869                self.conn
16870                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16871                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16872                Err(error)
16873            }
16874        }
16875    }
16876
16877    /// Rows, coverage, and local revision from one SQLite read snapshot.
16878    /// §7.5 atomic snapshot read. An `owner` records a failure in
16879    /// `diagnostics_snapshot().query_failures` until its next successful read.
16880    pub fn query_snapshot(
16881        &mut self,
16882        sql: &str,
16883        params: &[QueryValue],
16884        coverage: &[WindowCoverage],
16885        owner: Option<QueryOwner<'_>>,
16886    ) -> Result<QuerySnapshot, String> {
16887        let result = snapshot_connection(&self.conn, sql, params, coverage);
16888        if let Some(owner) = owner {
16889            self.record_query_read(owner, result.as_ref().err());
16890        }
16891        result.map_err(|failure| failure.to_string())
16892    }
16893
16894    /// §7.6: record the outcome of one owned snapshot read, including reads a
16895    /// host ran on a separate connection. A success removes the owner's entry;
16896    /// an identical failure leaves it unchanged; a changed failure moves last.
16897    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16898        let position = self
16899            .query_failures
16900            .iter()
16901            .position(|entry| entry.id == owner.id);
16902        let Some(failure) = failure else {
16903            if let Some(index) = position {
16904                self.query_failures.remove(index);
16905            }
16906            return;
16907        };
16908        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16909            .into_iter()
16910            .map(str::to_owned)
16911            .collect();
16912        let code = failure.code.unwrap_or("client.query_failed").to_owned();
16913        if position.is_some_and(|index| {
16914            let entry = &self.query_failures[index];
16915            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16916        }) {
16917            return;
16918        }
16919        if let Some(index) = position {
16920            self.query_failures.remove(index);
16921        }
16922        self.query_failures.push(DiagnosticQueryFailure {
16923            id: owner.id.to_owned(),
16924            tables,
16925            code,
16926            sqlite_code: failure.sqlite_code,
16927            at_ms: self.clock_now_ms(),
16928        });
16929        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16930            self.query_failures.remove(0);
16931        }
16932    }
16933
16934    // -- request building ---------------------------------------------------------
16935
16936    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16937        #[cfg(feature = "bench-internals")]
16938        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16939        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16940        let max_commits = self.limits.max_push_commits_per_request;
16941        let max_ops = self
16942            .limits
16943            .max_push_operations_per_request
16944            .unwrap_or(PUSH_OPS_PER_REQUEST);
16945        let byte_cap = self.limits.max_push_request_bytes;
16946        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
16947        // bare URL — capability negotiation, decided per transport.
16948        let accept = self.limits.accept.unwrap_or(if url_capable {
16949            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16950        } else {
16951            DEFAULT_ACCEPT
16952        });
16953        let header = Frame::ReqHeader {
16954            client_id: self.client_id.clone(),
16955            schema_version: self.schema.version,
16956            log_epoch: log_epoch.clone(),
16957        };
16958        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
16959        // which stays ahead of every push, pull and subscription frame; the
16960        // byte-capacity probes below measure this exact sequence.
16961        let mut lead = vec![header];
16962        lead.extend(
16963            self.round_failure
16964                .iter()
16965                .map(|code| Frame::RoundFailure { code: code.clone() }),
16966        );
16967        let pull = Frame::PullHeader {
16968            limit_commits: self.limits.limit_commits.unwrap_or(0),
16969            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16970            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16971            accept,
16972        };
16973        let mut fresh = Vec::new();
16974        let mut sub_frames = Vec::new();
16975        for sub in &self.subs {
16976            if sub.state != SubState::Active {
16977                continue;
16978            }
16979            let mut scopes = sub.requested.clone();
16980            sort_scope_map(&mut scopes);
16981            sub_frames.push(Frame::Subscription {
16982                id: sub.id.clone(),
16983                table: sub.table.clone(),
16984                scopes,
16985                params: sub.params.clone().map(RawJson),
16986                cursor: sub.cursor,
16987                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16988            });
16989            fresh.push((
16990                sub.id.clone(),
16991                sub.cursor < 0 && sub.bootstrap_state.is_none(),
16992            ));
16993        }
16994        // The fixed frames (order-independent size) and the header+pull probe
16995        // both encode canonically, so the byte budget is the exact wire size.
16996        let fixed_bytes = match byte_cap {
16997            Some(_) => {
16998                let mut frames = lead.clone();
16999                frames.push(pull.clone());
17000                frames.extend(sub_frames.iter().cloned());
17001                encode_message(&Message {
17002                    wire_version: WIRE_VERSION,
17003                    msg_kind: MsgKind::Request,
17004                    frames,
17005                })
17006                .len()
17007            }
17008            None => 0,
17009        };
17010        if let Some(cap) = byte_cap {
17011            if fixed_bytes > cap {
17012                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17013            }
17014        }
17015        let base_bytes = match byte_cap {
17016            Some(_) => {
17017                let mut frames = lead.clone();
17018                frames.push(pull.clone());
17019                encode_message(&Message {
17020                    wire_version: WIRE_VERSION,
17021                    msg_kind: MsgKind::Request,
17022                    frames,
17023                })
17024                .len()
17025            }
17026            None => 0,
17027        };
17028        let total_outbox = if log_epoch.is_some() {
17029            self.outbox.len()
17030        } else {
17031            0
17032        };
17033        let mut push_frames: Vec<Frame> = Vec::new();
17034        let mut pushed_ids = Vec::new();
17035        let mut ops_in_request = 0usize;
17036        let mut bytes_in_request = 0usize;
17037        let mut deferred_commits = 0usize;
17038        #[cfg(feature = "bench-internals")]
17039        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17040        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17041            // §6.1/§7.1: the commit-count and operation caps are hard. A
17042            // first commit over the operation cap is a typed capacity error
17043            // and stays queued; a later one defers with the complete suffix.
17044            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17045                deferred_commits = total_outbox - index;
17046                break;
17047            }
17048            if ops_in_request + commit.ops.len() > max_ops {
17049                if push_frames.is_empty() {
17050                    return Err(Self::push_capacity_outcome(
17051                        "operations",
17052                        max_ops,
17053                        commit.ops.len(),
17054                        Some(commit.client_commit_id.as_str()),
17055                    ));
17056                }
17057                deferred_commits = total_outbox - index;
17058                break;
17059            }
17060            let operations = commit
17061                .ops
17062                .iter()
17063                .map(|op| {
17064                    let payload = op.values.as_ref().and_then(|values| {
17065                        let table = self.schema.table(&op.table)?;
17066                        // §0: outbox entries encode at send time with the
17067                        // current codec (validated at mutate()). §6.1: the
17068                        // values map's keys are the sparse presence set.
17069                        // §5.11: encrypted columns are encrypted here, with
17070                        // an absent key-id selector read from the stored row.
17071                        // `sync_inner` runs `drop_unencodable_outbox` first,
17072                        // so an encrypt failure cannot reach this encode; the
17073                        // author seam validated every other failure.
17074                        let fallback = self.stored_key_fallback(table, &op.row_id);
17075                        encode_sparse_row_json(
17076                            table,
17077                            &op.row_id,
17078                            values,
17079                            &self.encryption,
17080                            fallback.as_ref(),
17081                        )
17082                        .ok()
17083                    });
17084                    ssp2::model::Operation {
17085                        table: op.table.clone(),
17086                        row_id: op.row_id.clone(),
17087                        op: if op.upsert { Op::Upsert } else { Op::Delete },
17088                        base_version: op.base_version,
17089                        payload,
17090                    }
17091                })
17092                .collect();
17093            let frame = Frame::PushCommit {
17094                client_commit_id: commit.client_commit_id.clone(),
17095                operations,
17096            };
17097            let frame_bytes = match byte_cap {
17098                Some(_) => {
17099                    let mut frames = lead.clone();
17100                    frames.push(frame.clone());
17101                    frames.push(pull.clone());
17102                    encode_message(&Message {
17103                        wire_version: WIRE_VERSION,
17104                        msg_kind: MsgKind::Request,
17105                        frames,
17106                    })
17107                    .len()
17108                        - base_bytes
17109                }
17110                None => 0,
17111            };
17112            // The head is always encoded so the caller can report the full
17113            // projected request size; a later commit defers once it no longer
17114            // fits.
17115            if let Some(cap) = byte_cap {
17116                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17117                    deferred_commits = total_outbox - index;
17118                    break;
17119                }
17120            }
17121            push_frames.push(frame);
17122            pushed_ids.push(commit.client_commit_id.clone());
17123            ops_in_request += commit.ops.len();
17124            bytes_in_request += frame_bytes;
17125        }
17126        #[cfg(feature = "bench-internals")]
17127        drop(outbox_phase);
17128        if let Some(cap) = byte_cap {
17129            if fixed_bytes + bytes_in_request > cap {
17130                // The head alone does not fit this budget; the commit stays
17131                // queued and no transport round is attempted.
17132                let blocked = if push_frames.is_empty() {
17133                    None
17134                } else {
17135                    self.outbox.first().map(|c| c.client_commit_id.as_str())
17136                };
17137                return Err(Self::push_capacity_outcome(
17138                    "bytes",
17139                    cap,
17140                    fixed_bytes + bytes_in_request,
17141                    blocked,
17142                ));
17143            }
17144        }
17145        let mut frames = lead;
17146        frames.extend(push_frames);
17147        frames.push(pull);
17148        frames.extend(sub_frames);
17149        let message = Message {
17150            wire_version: WIRE_VERSION,
17151            msg_kind: MsgKind::Request,
17152            frames,
17153        };
17154        Ok((
17155            message,
17156            RequestMeta {
17157                pushed_ids,
17158                fresh,
17159                accept,
17160                deferred_commits,
17161            },
17162        ))
17163    }
17164
17165    /// §7.1: a configured push capacity limit cannot carry this request.
17166    fn push_capacity_outcome(
17167        kind: &str,
17168        limit: usize,
17169        size: usize,
17170        client_commit_id: Option<&str>,
17171    ) -> Box<SyncOutcome> {
17172        let mut details = Map::new();
17173        details.insert("kind".to_owned(), Value::from(kind));
17174        details.insert("limit".to_owned(), Value::from(limit as u64));
17175        details.insert("size".to_owned(), Value::from(size as u64));
17176        if let Some(id) = client_commit_id {
17177            details.insert("clientCommitId".to_owned(), Value::from(id));
17178        }
17179        Box::new(SyncOutcome::Failed {
17180            error_code: "client.push_request_too_large".into(),
17181            message: "the push request exceeds a configured capacity limit".into(),
17182            details: Some(Value::Object(details)),
17183            recommended_action: None,
17184        })
17185    }
17186
17187    // -- sync -------------------------------------------------------------------
17188
17189    /// Cloneable observation handle; no client lock is needed to read it.
17190    pub fn progress(&self) -> ProgressObserver {
17191        self.progress.clone()
17192    }
17193
17194    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17195        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17196            Ok(prepared) => prepared,
17197            Err(outcome) => return *outcome,
17198        };
17199        // The round's network deadline rides `PreparedSyncRound` and is scoped
17200        // to each exchange, so uploads, continuations, the main request, and
17201        // every segment fetch share one monotonic anchor without leaving
17202        // deadline state on a reused transport.
17203        let mut next = prepared;
17204        loop {
17205            match self.apply_sync_round(next.exchange(transport)) {
17206                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17207                crate::AppliedSyncRound::Complete {
17208                    outcome, controls, ..
17209                } => {
17210                    if self.transport_enabled {
17211                        for text in controls {
17212                            let _ = transport.realtime_send(&text);
17213                        }
17214                    } else {
17215                        self.disconnect_realtime(transport);
17216                    }
17217                    return outcome;
17218                }
17219            }
17220        }
17221    }
17222
17223    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17224        if let SyncOutcome::Failed {
17225            error_code,
17226            message,
17227            details,
17228            ..
17229        } = &mut outcome
17230        {
17231            if let Some(failure) = self.storage_failure.borrow().as_ref() {
17232                let code = failure
17233                    .code
17234                    .expect("retained storage failures are classified");
17235                *error_code = code.to_owned();
17236                *message = match code {
17237                    "client.storage_busy" => "local SQLite storage is busy",
17238                    "client.storage_full" => "local SQLite storage is full",
17239                    "client.storage_io" => "local SQLite storage I/O failed",
17240                    "client.storage_corrupt" => "local SQLite storage is corrupt",
17241                    _ => unreachable!("unrecognized classified storage failure"),
17242                }
17243                .to_owned();
17244                *details = failure.details();
17245            }
17246        }
17247        let retry_delay_ms = self.round_retry_delay_ms;
17248        self.progress.update(|p| match &outcome {
17249            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17250                if report.failed.is_empty() =>
17251            {
17252                p.state = ProgressState::Complete
17253            }
17254            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17255                p.state = ProgressState::Failed;
17256                p.error_code = Some("sync.scope_revoked".into());
17257            }
17258            SyncOutcome::Failed { error_code, .. } => {
17259                p.state = ProgressState::Failed;
17260                p.error_code = Some(Self::diagnostic_code(error_code));
17261                p.retry_delay_ms = retry_delay_ms;
17262            }
17263            SyncOutcome::RealtimeUnavailable { .. } => {
17264                p.state = ProgressState::Failed;
17265                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17266                p.retry_delay_ms = retry_delay_ms;
17267            }
17268        });
17269        let completed_at_ms = self.clock_now_ms();
17270        self.last_round = Some(match &outcome {
17271            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17272                status: "succeeded".to_owned(),
17273                started_at_ms,
17274                completed_at_ms,
17275                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17276                counters: Some(DiagnosticRoundCounters {
17277                    pushed: report.pushed,
17278                    applied: report.applied.len(),
17279                    rejected: report.rejected.len(),
17280                    retryable: report.retryable.len(),
17281                    conflicts: report.conflicts,
17282                    commits_applied: report.commits_applied,
17283                    segment_rows_applied: report.segment_rows_applied,
17284                    bootstrapping: report.bootstrapping.len(),
17285                    resets: report.resets.len(),
17286                    revoked: report.revoked.len(),
17287                    failed: report.failed.len(),
17288                    deferred_commits: report.deferred_commits,
17289                }),
17290                error_code: None,
17291            },
17292            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17293                status: "failed".to_owned(),
17294                started_at_ms,
17295                completed_at_ms,
17296                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17297                counters: None,
17298                error_code: Some(Self::diagnostic_code(error_code)),
17299            },
17300            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17301                status: "failed".to_owned(),
17302                started_at_ms,
17303                completed_at_ms,
17304                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17305                counters: None,
17306                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17307            },
17308        });
17309        outcome
17310    }
17311
17312    /// Capture one request on the client owner. Mutations made after this
17313    /// call remain in the outbox for a later round; no network I/O occurs here.
17314    pub fn prepare_sync_round(
17315        &mut self,
17316        url_capable: bool,
17317    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17318        if !self.transport_enabled {
17319            return Err(Box::new(SyncOutcome::Failed {
17320                error_code: "sync.offline".into(),
17321                message: "transport gate is closed".into(),
17322                details: None,
17323                recommended_action: None,
17324            }));
17325        }
17326        if self.active_round.is_some() {
17327            return Err(Box::new(SyncOutcome::Failed {
17328                error_code: "client.round_in_progress".into(),
17329                message: "a sync round is already in flight".into(),
17330                details: None,
17331                recommended_action: None,
17332            }));
17333        }
17334        self.storage_failure.borrow_mut().take();
17335        self.progress.start();
17336        self.round_retry_delay_ms = None;
17337        let started_at_ms = self.clock_now_ms();
17338        let prepared = (|| {
17339            if self.stopped {
17340                return Err(Box::new(SyncOutcome::Ok(SyncReport {
17341                    schema_floor: self.schema_floor.clone(),
17342                    ..SyncReport::default()
17343                })));
17344            }
17345            if self.security_preflight {
17346                return Err(Box::new(SyncOutcome::Failed {
17347                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17348                    message: "security preflight must complete before syncing".into(),
17349                    details: None,
17350                    recommended_action: None,
17351                }));
17352            }
17353            self.set_sync_needed(false, false);
17354            self.drain_pending_evictions()
17355                .and_then(|_| self.drop_incompatible_outbox())
17356                .and_then(|_| self.drop_unencodable_outbox(true, None))
17357                .map_err(|message| {
17358                    Box::new(SyncOutcome::Failed {
17359                        error_code: "storage.failed".into(),
17360                        message,
17361                        details: None,
17362                        recommended_action: None,
17363                    })
17364                })?;
17365            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17366                self.pending_blob_uploads().map_err(|e| {
17367                    Box::new(SyncOutcome::Failed {
17368                        error_code: e.code,
17369                        message: e.message,
17370                        details: e.details,
17371                        recommended_action: None,
17372                    })
17373                })?
17374            } else {
17375                Vec::new()
17376            };
17377            if self.realtime_state != RealtimeState::Connected
17378                && self.realtime_policy == RealtimePolicy::Required
17379            {
17380                let retry_delay_ms = self.schedule_background_retry();
17381                self.realtime_retry_delay_ms = Some(retry_delay_ms);
17382                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17383                    state: self.realtime_state,
17384                    reason_code: self.realtime_reason_code.clone(),
17385                    retry_delay_ms,
17386                }));
17387            }
17388            let (message, meta) = self.build_request(url_capable)?;
17389            let id = uuid::Uuid::new_v4();
17390            self.active_round = Some(id);
17391            let phase = if uploads.is_empty() {
17392                crate::round::RoundPhase::Sync
17393            } else {
17394                crate::round::RoundPhase::UploadGrant
17395            };
17396            Ok(crate::PreparedSyncRound {
17397                id,
17398                started_at_ms,
17399                message,
17400                meta,
17401                uploads: uploads.into(),
17402                phase,
17403                encoded: None,
17404                continuation: None,
17405                completed: None,
17406                realtime: self.realtime_state == RealtimeState::Connected,
17407                scopes: self
17408                    .subs
17409                    .iter()
17410                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17411                    .collect(),
17412                #[cfg(feature = "bench-internals")]
17413                benchmark_phases: self.benchmark_phases.clone(),
17414                progress: self.progress.clone(),
17415                fixed_now: self.now_ms,
17416                round_deadline_at: None,
17417            })
17418        })();
17419        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17420    }
17421
17422    /// Discard the reply of an old authorization/subscription context. Its
17423    /// submitted commits stay durable and can be retried with the same ids.
17424    pub fn cancel_sync_round(&mut self) {
17425        self.active_round = None;
17426    }
17427
17428    /// Apply captured network results on the owner, preserving all normal
17429    /// version, schema, content-address and cursor checks. No network I/O.
17430    pub fn apply_sync_round(
17431        &mut self,
17432        completed: crate::CompletedSyncRound,
17433    ) -> crate::AppliedSyncRound {
17434        let prepared = completed.prepared;
17435        if self.active_round != Some(prepared.id) || self.security_preflight {
17436            return crate::AppliedSyncRound::Complete {
17437                outcome: SyncOutcome::Failed {
17438                    error_code: "client.round_cancelled".into(),
17439                    message: "sync round belongs to a released client context".into(),
17440                    details: None,
17441                    recommended_action: None,
17442                },
17443                controls: Vec::new(),
17444                more: false,
17445                bootstrap_advanced: false,
17446            };
17447        }
17448        self.storage_failure.borrow_mut().take();
17449        let (response, mut downloads, transport_failed) = match completed.exchange {
17450            crate::round::ExchangeResult::Upload { id, result } => {
17451                let result = result.and_then(|()| {
17452                    self.conn
17453                        .execute(
17454                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17455                            rusqlite::params![id],
17456                        )
17457                        .map(|_| ())
17458                        .map_err(|error| {
17459                            TransportError::new(
17460                                "client.failed",
17461                                Self::sqlite_failure(&self.storage_failure, error),
17462                            )
17463                        })
17464                });
17465                match result {
17466                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17467                    Err(TransportError {
17468                        code,
17469                        message,
17470                        details,
17471                    }) => {
17472                        self.active_round = None;
17473                        if Self::retryable_failure_code(&code) {
17474                            self.schedule_background_retry();
17475                        }
17476                        let recommended_action = recommended_action_of(&details);
17477                        let outcome = self.finish_sync_round(
17478                            prepared.started_at_ms,
17479                            SyncOutcome::Failed {
17480                                error_code: code,
17481                                message,
17482                                details,
17483                                recommended_action,
17484                            },
17485                        );
17486                        return crate::AppliedSyncRound::Complete {
17487                            outcome,
17488                            controls: Vec::new(),
17489                            more: false,
17490                            bootstrap_advanced: false,
17491                        };
17492                    }
17493                }
17494            }
17495            crate::round::ExchangeResult::Reply {
17496                response,
17497                downloads,
17498                transport_failed,
17499            } => (response, downloads, transport_failed),
17500        };
17501        self.active_round = None;
17502        let before = self
17503            .subs
17504            .iter()
17505            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17506            .collect::<Vec<_>>();
17507        let outcome = (|| {
17508            let response = match response {
17509                Ok(response) => {
17510                    // §1.5: a complete, decodable response ends the report, an
17511                    // in-band ERROR included (the server already recorded it).
17512                    self.round_failure = None;
17513                    response
17514                }
17515                Err(TransportError {
17516                    code,
17517                    message,
17518                    details,
17519                }) => {
17520                    // §1.5: the server cannot see a round whose transport
17521                    // failed or whose response never decoded, so its code rides
17522                    // the following requests. A code the frame grammar rejects
17523                    // is not reportable, and the previous code stays.
17524                    if ssp2::model::valid_round_failure_code(&code) {
17525                        self.round_failure = Some(code.clone());
17526                    }
17527                    if transport_failed {
17528                        if prepared.realtime {
17529                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
17530                        }
17531                        self.record_lease_error(&code);
17532                    }
17533                    if Self::retryable_failure_code(&code) {
17534                        let delay = self.schedule_background_retry();
17535                        if prepared.realtime && transport_failed {
17536                            self.realtime_retry_delay_ms = Some(delay);
17537                        }
17538                    }
17539                    let recommended_action = recommended_action_of(&details);
17540                    return SyncOutcome::Failed {
17541                        error_code: code,
17542                        message,
17543                        details,
17544                        recommended_action,
17545                    };
17546                }
17547            };
17548            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17549            if let SyncOutcome::Ok(report) = &mut outcome {
17550                report.deferred_commits = prepared.meta.deferred_commits;
17551            }
17552            match &outcome {
17553                SyncOutcome::Ok(_) => self.reset_background_retry(),
17554                SyncOutcome::Failed { error_code, .. }
17555                    if Self::retryable_failure_code(error_code) =>
17556                {
17557                    self.schedule_background_retry();
17558                }
17559                _ => {}
17560            }
17561            if prepared.meta.deferred_commits > 0 {
17562                self.set_sync_needed(true, true);
17563            }
17564            self.previous_version_lifetime_check();
17565            outcome
17566        })();
17567        let more = self.transport_enabled
17568            && matches!(&outcome, SyncOutcome::Ok(report)
17569                if report.schema_floor.is_none()
17570                    && (!report.bootstrapping.is_empty()
17571                        || report.commits_applied > 0
17572                        || report.segment_rows_applied > 0
17573                        || !report.resets.is_empty()
17574                        || report.deferred_commits > 0
17575                        || self.sync_needed));
17576        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17577            && before
17578                != self
17579                    .subs
17580                    .iter()
17581                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17582                    .collect::<Vec<_>>();
17583        crate::AppliedSyncRound::Complete {
17584            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17585            controls: if self.transport_enabled {
17586                downloads.controls
17587            } else {
17588                Vec::new()
17589            },
17590            more,
17591            bootstrap_advanced,
17592        }
17593    }
17594
17595    pub fn sync_until_idle(
17596        &mut self,
17597        transport: &mut dyn Transport,
17598        max_rounds: Option<u32>,
17599    ) -> SyncOutcome {
17600        // §7.7: an explicit budget is a strict positive round count. Zero is a
17601        // caller error, not an exhaustion.
17602        if max_rounds == Some(0) {
17603            return SyncOutcome::Failed {
17604                details: None,
17605                error_code: "sync.invalid_request".into(),
17606                message: "maxRounds must be a positive integer".into(),
17607                recommended_action: None,
17608            };
17609        }
17610        let rounds = max_rounds.unwrap_or(20);
17611        let mut aggregate = SyncReport::default();
17612        let mut spent = 0;
17613        while spent < rounds {
17614            spent += 1;
17615            let before = self
17616                .subs
17617                .iter()
17618                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17619                .collect::<Vec<_>>();
17620            match self.sync(transport) {
17621                SyncOutcome::Failed {
17622                    error_code,
17623                    message,
17624                    details,
17625                    recommended_action,
17626                } => {
17627                    return SyncOutcome::Failed {
17628                        details,
17629                        error_code,
17630                        message,
17631                        recommended_action,
17632                    };
17633                }
17634                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17635                SyncOutcome::BudgetExhausted(_) => {
17636                    unreachable!("sync() never exhausts a round budget")
17637                }
17638                SyncOutcome::Ok(report) => {
17639                    if max_rounds.is_none()
17640                        && report.segment_rows_applied > 0
17641                        && !report.bootstrapping.is_empty()
17642                        && before
17643                            != self
17644                                .subs
17645                                .iter()
17646                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17647                                .collect::<Vec<_>>()
17648                    {
17649                        spent = 0;
17650                    }
17651                    // TS parity: a disabled transport or a schema floor ends
17652                    // the loop. The latest round still merges first.
17653                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
17654                    aggregate.merge(&report);
17655                    if stop {
17656                        return SyncOutcome::Ok(aggregate);
17657                    }
17658                    // §4.5: pull again whenever the response contained
17659                    // commits or segments; resets re-bootstrap; a pending
17660                    // resume token continues paging (§4.7); deferred outbox
17661                    // commits and a raised sync-needed signal push on the next
17662                    // round.
17663                    let more = !report.bootstrapping.is_empty()
17664                        || report.commits_applied > 0
17665                        || report.segment_rows_applied > 0
17666                        || !report.resets.is_empty()
17667                        || report.deferred_commits > 0
17668                        || self.sync_needed;
17669                    if !more {
17670                        return SyncOutcome::Ok(aggregate);
17671                    }
17672                }
17673            }
17674        }
17675        // §7.7: partial success. The aggregate report is retained and the run
17676        // is explicitly not idle.
17677        SyncOutcome::BudgetExhausted(aggregate)
17678    }
17679
17680    fn process_response(
17681        &mut self,
17682        transport: &mut dyn Transport,
17683        response: Message,
17684        meta: &RequestMeta,
17685    ) -> SyncOutcome {
17686        #[cfg(feature = "bench-internals")]
17687        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17688        let mut report = SyncReport {
17689            pushed: meta.pushed_ids.len() as u32,
17690            ..SyncReport::default()
17691        };
17692        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17693            HashMap::new();
17694        let pushed_ids = meta
17695            .pushed_ids
17696            .iter()
17697            .map(String::as_str)
17698            .collect::<HashSet<_>>();
17699        let mut last_final_push_result_id: Option<String> = None;
17700        for frame in &response.frames {
17701            match frame {
17702                Frame::PushResultDetails {
17703                    client_commit_id,
17704                    entries,
17705                } => {
17706                    let details = rejection_details_by_commit
17707                        .entry(client_commit_id.clone())
17708                        .or_default();
17709                    for entry in entries {
17710                        let parsed = match RejectionDetails::parse(&entry.details.0) {
17711                            Ok(value) => value,
17712                            Err(message) => {
17713                                return SyncOutcome::Failed {
17714                                    details: None,
17715                                    error_code: "sync.invalid_request".to_owned(),
17716                                    message,
17717                                    recommended_action: None,
17718                                };
17719                            }
17720                        };
17721                        details.insert(entry.op_index, parsed);
17722                    }
17723                }
17724                Frame::PushResult {
17725                    client_commit_id,
17726                    status,
17727                    results,
17728                    ..
17729                } if pushed_ids.contains(client_commit_id.as_str())
17730                    && Self::push_result_is_final(*status, results) =>
17731                {
17732                    last_final_push_result_id = Some(client_commit_id.clone());
17733                }
17734                _ => {}
17735            }
17736        }
17737        if response.wire_version < 2 {
17738            return SyncOutcome::Failed {
17739                details: None,
17740                error_code: "client.invalid_host_response".to_owned(),
17741                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17742                recommended_action: None,
17743            };
17744        }
17745        let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17746            status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17747        });
17748        let mut frames = response.frames.into_iter().peekable();
17749        match frames.next() {
17750            Some(Frame::RespHeader {
17751                required_schema_version,
17752                latest_schema_version,
17753                log_epoch,
17754                reset_required,
17755            }) => {
17756                let Some(log_epoch) = log_epoch else {
17757                    return SyncOutcome::Failed {
17758                        details: None,
17759                        error_code: "client.invalid_host_response".to_owned(),
17760                        message: "response header omits logEpoch".to_owned(),
17761                        recommended_action: None,
17762                    };
17763                };
17764                let Some(reset_required) = reset_required else {
17765                    return SyncOutcome::Failed {
17766                        details: None,
17767                        error_code: "client.invalid_host_response".to_owned(),
17768                        message: "response header omits resetRequired".to_owned(),
17769                        recommended_action: None,
17770                    };
17771                };
17772                if let Some(required) = required_schema_version {
17773                    // §1.6 schema-floor response: nothing else is processed;
17774                    // stop syncing and surface the upgrade requirement.
17775                    let floor = SchemaFloor {
17776                        required_schema_version: Some(required),
17777                        latest_schema_version,
17778                    };
17779                    self.set_schema_floor(Some(floor.clone()));
17780                    report.schema_floor = Some(floor);
17781                    return SyncOutcome::Ok(report);
17782                }
17783                if reset_required {
17784                    if frames.next().is_some() {
17785                        return SyncOutcome::Failed {
17786                            details: None,
17787                            error_code: "client.invalid_host_response".to_owned(),
17788                            message: "log-epoch reset response contains body frames".to_owned(),
17789                            recommended_action: None,
17790                        };
17791                    }
17792                    match self.run_log_epoch_reset(&log_epoch) {
17793                        Ok(resets) => {
17794                            report.resets = resets;
17795                            return SyncOutcome::Ok(report);
17796                        }
17797                        Err(message) => {
17798                            return SyncOutcome::Failed {
17799                                details: None,
17800                                error_code: "sync.local_corrupt".to_owned(),
17801                                message,
17802                                recommended_action: None,
17803                            };
17804                        }
17805                    }
17806                }
17807                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17808                    return SyncOutcome::Failed {
17809                        details: None,
17810                        error_code: "client.invalid_host_response".to_owned(),
17811                        message: "server changed logEpoch without requiring a reset".to_owned(),
17812                        recommended_action: None,
17813                    };
17814                }
17815            }
17816            _ => {
17817                return SyncOutcome::Failed {
17818                    details: None,
17819                    error_code: "sync.invalid_request".to_owned(),
17820                    message: "response does not start with RESP_HEADER".to_owned(),
17821                    recommended_action: None,
17822                };
17823            }
17824        }
17825
17826        if let Err(message) = self.begin_coalesced_apply() {
17827            return SyncOutcome::Failed {
17828                details: None,
17829                error_code: "storage.failed".into(),
17830                message,
17831                recommended_action: None,
17832            };
17833        }
17834        let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
17835        while let Some(frame) = frames.next() {
17836            match frame {
17837                frame @ Frame::PushResult { .. } => {
17838                    let successful = matches!(
17839                        &frame,
17840                        Frame::PushResult {
17841                            status: PushStatus::Applied | PushStatus::Cached,
17842                            ..
17843                        }
17844                    );
17845                    let mut results = vec![frame];
17846                    if successful {
17847                        while matches!(
17848                            frames.peek(),
17849                            Some(Frame::PushResult {
17850                                status: PushStatus::Applied | PushStatus::Cached,
17851                                ..
17852                            })
17853                        ) {
17854                            if let Some(next) = frames.next() {
17855                                results.push(next);
17856                            }
17857                        }
17858                    }
17859                    if self
17860                        .handle_push_results(
17861                            &results,
17862                            &pushed_ids,
17863                            &rejection_details_by_commit,
17864                            &mut report,
17865                            last_final_push_result_id.as_deref(),
17866                        )
17867                        .is_err()
17868                    {
17869                        failure = Some((
17870                            "client.outcome_persistence_failed".to_owned(),
17871                            "local commit outcome could not be persisted".to_owned(),
17872                            None,
17873                            None,
17874                        ));
17875                        break;
17876                    }
17877                }
17878                Frame::PushResultDetails { .. } => {}
17879                Frame::SubStart {
17880                    id,
17881                    status,
17882                    reason_code,
17883                    effective_scopes,
17884                    bootstrap: _,
17885                } => {
17886                    let mut body = Vec::new();
17887                    let mut sub_end: Option<(i64, Option<String>)> = None;
17888                    for inner in frames.by_ref() {
17889                        match inner {
17890                            Frame::SubEnd {
17891                                next_cursor,
17892                                bootstrap_state,
17893                            } => {
17894                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17895                                break;
17896                            }
17897                            Frame::Unknown { .. } => {}
17898                            other => body.push(other),
17899                        }
17900                    }
17901                    if let Err(SectionError::Abort(code, message)) = self.process_section(
17902                        transport,
17903                        &id,
17904                        status,
17905                        &reason_code,
17906                        effective_scopes,
17907                        body,
17908                        sub_end,
17909                        meta,
17910                        &mut report,
17911                        &coalesced_deletes,
17912                    ) {
17913                        failure = Some((code, message, None, None));
17914                        break;
17915                    }
17916                }
17917                Frame::Lease {
17918                    lease_id,
17919                    expires_at_ms,
17920                } => {
17921                    // §7.3.5: persist the opaque lease; a fresh lease clears
17922                    // any prior lease error (the outage/revocation is over).
17923                    self.set_lease_state(Some(LeaseState {
17924                        lease_id: Some(lease_id),
17925                        expires_at_ms: Some(expires_at_ms),
17926                        error_code: None,
17927                    }));
17928                }
17929                Frame::Error {
17930                    code,
17931                    message,
17932                    recommended_action,
17933                    details,
17934                    ..
17935                } => {
17936                    // §1.6: the whole request failed; already-completed
17937                    // subscriptions keep their applied data and cursors.
17938                    let recommended_action =
17939                        (!recommended_action.is_empty()).then_some(recommended_action);
17940                    let details = details
17941                        .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
17942                        .filter(Value::is_object);
17943                    failure = Some((code, message, recommended_action, details));
17944                    break;
17945                }
17946                Frame::Unknown { .. } => {}
17947                _ => {
17948                    failure = Some((
17949                        "sync.invalid_request".to_owned(),
17950                        "unexpected frame in response".to_owned(),
17951                        None,
17952                        None,
17953                    ));
17954                    break;
17955                }
17956            }
17957        }
17958
17959        let applied = match failure {
17960            Some(failure) => Err(failure),
17961            // Local apply boundaries already reconciled their affected overlay.
17962            // Refuse round success if any remaining dirty work fails to
17963            // reconcile. §4.8 E1: the push half may have drained commits that
17964            // pinned rows of a shrunk window unit, so retry deferred evictions.
17965            None => self
17966                .rebuild_overlay_if_dirty()
17967                .and_then(|()| self.drain_pending_evictions())
17968                .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
17969        };
17970        // §1.4 rule 4: frames completed before a failure stay committed; a
17971        // failed COMMIT of the coalesced transaction fails the round.
17972        if let Err(message) = self.finish_coalesced_apply() {
17973            return SyncOutcome::Failed {
17974                details: None,
17975                error_code: "storage.failed".into(),
17976                message,
17977                recommended_action: None,
17978            };
17979        }
17980        if let Err((error_code, message, recommended_action, details)) = applied {
17981            return SyncOutcome::Failed {
17982                details,
17983                error_code,
17984                message,
17985                recommended_action,
17986            };
17987        }
17988        self.ack_after_pull(transport);
17989        // §7.4.5: the reset is over once the first post-reset pull round
17990        // leaves no subscription mid-bootstrap — the tables are rebuilt.
17991        if self.upgrading && report.bootstrapping.is_empty() {
17992            self.set_upgrading(false);
17993        }
17994        SyncOutcome::Ok(report)
17995    }
17996
17997    // -- push results (§6.3, §7.2) ------------------------------------------------
17998
17999    fn handle_push_results(
18000        &mut self,
18001        frames: &[Frame],
18002        pushed_ids: &HashSet<&str>,
18003        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18004        report: &mut SyncReport,
18005        last_final_push_result_id: Option<&str>,
18006    ) -> Result<(), String> {
18007        self.begin_observation("syncular_push_results")?;
18008        let previous_failed = self.failed_commits.clone();
18009        let previous_sync_needed = self.sync_needed;
18010        let conflict_count = self.conflicts.len();
18011        let rejection_count = self.rejections.len();
18012        let applied_count = report.applied.len();
18013        let rejected_count = report.rejected.len();
18014        let retryable_count = report.retryable.len();
18015        let was_dirty = self.overlay_dirty.snapshot();
18016        let mut removed = Vec::new();
18017        let mut batch = ChangeAccumulator::default();
18018        let persisted = (|| {
18019            for frame in frames {
18020                let Frame::PushResult {
18021                    client_commit_id,
18022                    status,
18023                    results,
18024                    commit_seq,
18025                } = frame
18026                else {
18027                    return Err("unexpected acknowledgement frame".to_owned());
18028                };
18029                if !pushed_ids.contains(client_commit_id.as_str()) {
18030                    continue;
18031                }
18032                let Some(index) = self
18033                    .outbox
18034                    .iter()
18035                    .position(|commit| commit.client_commit_id == *client_commit_id)
18036                else {
18037                    continue;
18038                };
18039                let status = *status;
18040                let rejection_details = rejection_details_by_commit.get(client_commit_id);
18041                let operations = self.outbox[index].ops.clone();
18042                match status {
18043                    PushStatus::Applied | PushStatus::Cached => {
18044                        // §7.2: a lost ack replays as `cached` — proceed as if the
18045                        // ack had arrived.
18046                        let sequence =
18047                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18048                        let affected: HashSet<_> = operations
18049                            .iter()
18050                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
18051                            .collect();
18052                        let prior = self.acknowledged_operations(Some(&affected), None)?;
18053                        for (op_index, operation) in operations.iter().enumerate() {
18054                            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))
18055                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18056                            if delivered {
18057                                continue;
18058                            }
18059                            let table = self
18060                                .schema
18061                                .table(&operation.table)
18062                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
18063                            let mut intended: Map<String, Value> = self
18064                                .stored_row(table, &operation.row_id, true)?
18065                                .map(|(row, _)| {
18066                                    table
18067                                        .columns
18068                                        .iter()
18069                                        .zip(&row)
18070                                        .map(|(column, value)| {
18071                                            (column.name.clone(), column_value_to_json(value))
18072                                        })
18073                                        .collect()
18074                                })
18075                                .unwrap_or_default();
18076                            let mut scope_image = intended.clone();
18077                            for op in prior
18078                                .iter()
18079                                .chain(
18080                                    self.failed_commits
18081                                        .iter()
18082                                        .flat_map(|(commit, _)| &commit.ops),
18083                                )
18084                                .chain(
18085                                    self.outbox
18086                                        .iter()
18087                                        .take(index)
18088                                        .flat_map(|commit| &commit.ops),
18089                                )
18090                                .chain(operations.iter().take(op_index + 1))
18091                            {
18092                                if op.table != operation.table || op.row_id != operation.row_id {
18093                                    continue;
18094                                }
18095                                if let Some(values) = &op.values {
18096                                    intended.extend(values.clone());
18097                                } else {
18098                                    scope_image = intended.clone();
18099                                    intended.clear();
18100                                }
18101                            }
18102                            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)",
18103                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18104                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18105                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18106                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18107                        }
18108                        let journal_results = results
18109                            .iter()
18110                            .map(|result| {
18111                                let op_index = match result {
18112                                    OpResult::Applied { op_index }
18113                                    | OpResult::Conflict { op_index, .. }
18114                                    | OpResult::Error { op_index, .. } => *op_index,
18115                                };
18116                                CommitOperationOutcome::Applied { op_index }
18117                            })
18118                            .collect::<Vec<_>>();
18119                        let outcome_status = if status == PushStatus::Applied {
18120                            CommitOutcomeStatus::Applied
18121                        } else {
18122                            CommitOutcomeStatus::Cached
18123                        };
18124                        let persisted = self
18125                            .persist_commit_outcome(
18126                                client_commit_id,
18127                                outcome_status,
18128                                &journal_results,
18129                                None,
18130                            )
18131                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18132                        persisted?;
18133                        for operation in &operations {
18134                            self.overlay_dirty.table(&operation.table);
18135                        }
18136                        self.sync_needed = true;
18137                        batch.status = true;
18138                        batch.outcomes = true;
18139                    }
18140                    PushStatus::Rejected => {
18141                        if results.iter().any(|result| {
18142                            matches!(
18143                                result,
18144                                OpResult::Error {
18145                                    code,
18146                                    retryable: true,
18147                                    ..
18148                                } if code == "sync.idempotency_cache_miss"
18149                            )
18150                        }) {
18151                            // §6.3/§7.2: a serving failure, not an outcome — keep the
18152                            // exact commit queued for an identical retry.
18153                            report.retryable.push(client_commit_id.to_owned());
18154                            continue;
18155                        }
18156
18157                        let mut journal_results = Vec::with_capacity(results.len());
18158                        let mut conflicts = Vec::new();
18159                        let mut rejections = Vec::new();
18160                        for result in results {
18161                            match result {
18162                                OpResult::Applied { op_index } => {
18163                                    journal_results.push(CommitOperationOutcome::Applied {
18164                                        op_index: *op_index,
18165                                    });
18166                                }
18167                                OpResult::Conflict {
18168                                    op_index,
18169                                    code,
18170                                    message,
18171                                    server_version,
18172                                    server_row,
18173                                    conflict_columns: conflict_columns_bitmap,
18174                                } => {
18175                                    let operation = operations
18176                                        .get(*op_index as usize)
18177                                        .map(CommitOperation::from);
18178                                    let (table, row_id) = operation
18179                                        .as_ref()
18180                                        .map(|op| (op.table.clone(), op.row_id.clone()))
18181                                        .unwrap_or_default();
18182                                    let server_row_json = self
18183                                        .schema
18184                                        .table(&table)
18185                                        .and_then(|t| {
18186                                            decode_row_bytes(t, server_row, &self.encryption)
18187                                                .ok()
18188                                                .map(|row| (t, row))
18189                                        })
18190                                        .map(|(t, row)| {
18191                                            let mut map = Map::new();
18192                                            for (i, column) in t.columns.iter().enumerate() {
18193                                                map.insert(
18194                                                    column.name.clone(),
18195                                                    column_value_to_json(
18196                                                        row.get(i).unwrap_or(&None),
18197                                                    ),
18198                                                );
18199                                            }
18200                                            map
18201                                        })
18202                                        .unwrap_or_default();
18203                                    // §6.3: decode the presence-layout bitmap
18204                                    // into the marked column names.
18205                                    let conflict_columns = self
18206                                        .schema
18207                                        .table(&table)
18208                                        .map(|t| {
18209                                            t.columns
18210                                                .iter()
18211                                                .enumerate()
18212                                                .filter(|(index, _)| {
18213                                                    conflict_columns_bitmap
18214                                                        .get(index / 8)
18215                                                        .is_some_and(|byte| {
18216                                                            byte & (1 << (index % 8)) != 0
18217                                                        })
18218                                                })
18219                                                .map(|(_, column)| column.name.clone())
18220                                                .collect::<Vec<_>>()
18221                                        })
18222                                        .unwrap_or_default();
18223                                    let conflict = ConflictRecord {
18224                                        client_commit_id: client_commit_id.to_owned(),
18225                                        op_index: *op_index,
18226                                        table,
18227                                        row_id,
18228                                        code: code.clone(),
18229                                        message: message.clone(),
18230                                        server_version: *server_version,
18231                                        server_row: server_row_json,
18232                                        conflict_columns,
18233                                        operation,
18234                                    };
18235                                    journal_results.push(CommitOperationOutcome::Conflict {
18236                                        conflict: conflict.clone(),
18237                                    });
18238                                    conflicts.push(conflict);
18239                                }
18240                                OpResult::Error {
18241                                    op_index,
18242                                    code,
18243                                    message,
18244                                    retryable,
18245                                } => {
18246                                    let rejection = RejectionRecord {
18247                                        client_commit_id: client_commit_id.to_owned(),
18248                                        op_index: *op_index,
18249                                        code: code.clone(),
18250                                        message: message.clone(),
18251                                        retryable: *retryable,
18252                                        details: rejection_details
18253                                            .and_then(|details| details.get(op_index))
18254                                            .cloned(),
18255                                        operation: operations
18256                                            .get(*op_index as usize)
18257                                            .map(CommitOperation::from),
18258                                    };
18259                                    journal_results.push(CommitOperationOutcome::Error {
18260                                        rejection: rejection.clone(),
18261                                    });
18262                                    rejections.push(rejection);
18263                                }
18264                            }
18265                        }
18266                        if self.retain_failed_commits {
18267                            self.retain_failed_commit(client_commit_id, &operations)?;
18268                        }
18269                        let outcome_status = if conflicts.is_empty() {
18270                            CommitOutcomeStatus::Rejected
18271                        } else {
18272                            CommitOutcomeStatus::Conflict
18273                        };
18274                        let persisted = self
18275                            .persist_commit_outcome(
18276                                client_commit_id,
18277                                outcome_status,
18278                                &journal_results,
18279                                Some(&operations),
18280                            )
18281                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18282                        persisted?;
18283                        batch.conflicts = !conflicts.is_empty();
18284                        batch.rejections = !rejections.is_empty();
18285                        batch.status = true;
18286                        batch.outcomes = true;
18287                        self.conflicts.extend(conflicts);
18288                        self.rejections.extend(rejections);
18289                        for operation in &operations {
18290                            self.overlay_dirty.table(&operation.table);
18291                        }
18292                    }
18293                }
18294                removed.push((index, self.outbox.remove(index)));
18295                if batch.status {
18296                    for operation in &operations {
18297                        if !self.record_row_scopes(
18298                            &mut batch,
18299                            &operation.table,
18300                            &operation.row_id,
18301                            false,
18302                        ) {
18303                            batch.table(&operation.table);
18304                        }
18305                    }
18306                }
18307                match status {
18308                    PushStatus::Applied | PushStatus::Cached => {
18309                        report.applied.push(client_commit_id.to_owned())
18310                    }
18311                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18312                }
18313            }
18314            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)) {
18315                self.prune_commit_outcomes()?;
18316            }
18317            self.rebuild_overlay_if_dirty()?;
18318            self.finish_observation("syncular_push_results", batch)
18319        })();
18320        if let Err(error) = persisted {
18321            self.rollback_observation("syncular_push_results");
18322            self.failed_commits = previous_failed;
18323            self.sync_needed = previous_sync_needed;
18324            for (index, commit) in removed.into_iter().rev() {
18325                self.outbox.insert(index, commit);
18326            }
18327            self.conflicts.truncate(conflict_count);
18328            self.rejections.truncate(rejection_count);
18329            self.overlay_dirty.restore(was_dirty.clone());
18330            report.applied.truncate(applied_count);
18331            report.rejected.truncate(rejected_count);
18332            report.retryable.truncate(retryable_count);
18333            return Err(error);
18334        }
18335        if !previous_sync_needed && self.sync_needed {
18336            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18337        }
18338        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18339        Ok(())
18340    }
18341
18342    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18343        status != PushStatus::Rejected
18344            || !results.iter().any(|result| {
18345                matches!(
18346                    result,
18347                    OpResult::Error {
18348                        code,
18349                        retryable: true,
18350                        ..
18351                    } if code == "sync.idempotency_cache_miss"
18352                )
18353            })
18354    }
18355
18356    // -- subscription sections ------------------------------------------------------
18357
18358    #[allow(clippy::too_many_arguments)]
18359    fn process_section(
18360        &mut self,
18361        transport: &mut dyn Transport,
18362        id: &str,
18363        status: SubStatus,
18364        reason_code: &str,
18365        effective_scopes: Vec<(String, Vec<String>)>,
18366        body: Vec<Frame>,
18367        sub_end: Option<(i64, Option<String>)>,
18368        meta: &RequestMeta,
18369        report: &mut SyncReport,
18370        coalesced_deletes: &HashSet<(i64, String, String)>,
18371    ) -> Result<(), SectionError> {
18372        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18373            return Ok(()); // unknown echo: ignore
18374        };
18375        if status != SubStatus::Active && sub_end.is_none() {
18376            return Err(SectionError::Abort(
18377                "sync.invalid_request".into(),
18378                "subscription section without SUB_END".into(),
18379            ));
18380        }
18381        match status {
18382            SubStatus::Revoked => {
18383                self.begin_observation("syncular_revocation")
18384                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18385                let previous = self.subs[sub_index].clone();
18386                let was_dirty = self.overlay_dirty.snapshot();
18387                let previous_outbox = self.outbox.clone();
18388                let previous_failed = self.failed_commits.clone();
18389                let previous_rejections = self.rejections.clone();
18390                let previous_conflicts = self.conflicts.clone();
18391                let revoked_count = report.revoked.len();
18392                let failed_count = report.failed.len();
18393                let result = (|| {
18394                    let mut batch = ChangeAccumulator::default();
18395                    let registered = self.window_unit_by_sub_id(id);
18396                    // §3.3: stop pulling, purge exactly the last effective grant.
18397                    let (table, effective) = {
18398                        let sub = &self.subs[sub_index];
18399                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18400                    };
18401                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18402                        SectionError::Abort(
18403                            "sync.unknown_table".to_owned(),
18404                            "revoked scope references an unknown table".to_owned(),
18405                        )
18406                    })?;
18407                    let dropped = self
18408                        .drop_failed_commits(|name, values| {
18409                            name == table
18410                                && !effective.is_empty()
18411                                && effective.iter().all(|(variable, allowed)| {
18412                                    scoped_table
18413                                        .scope_column(variable)
18414                                        .and_then(|column| values.get(column))
18415                                        .and_then(Value::as_str)
18416                                        .is_some_and(|value| {
18417                                            allowed.iter().any(|allowed| allowed == value)
18418                                        })
18419                                })
18420                        })
18421                        .map_err(|message| {
18422                            SectionError::Abort("storage.failed".to_owned(), message)
18423                        })?;
18424                    for commit in dropped {
18425                        batch.conflicts = true;
18426                        batch.rejections = true;
18427                        batch.outcomes = true;
18428                        for operation in commit.ops {
18429                            batch.table(&operation.table);
18430                        }
18431                    }
18432                    let purged = self.purge_scope_rows(&table, &effective);
18433                    match purged {
18434                        Ok(()) => {
18435                            self.record_scope_map(&mut batch, &table, &effective);
18436                            let sub = &mut self.subs[sub_index];
18437                            sub.state = SubState::Revoked;
18438                            sub.reason_code = Some(if reason_code.is_empty() {
18439                                "sync.scope_revoked".to_owned()
18440                            } else {
18441                                reason_code.to_owned()
18442                            });
18443                            report.revoked.push(id.to_owned());
18444                            let doomed_effective = effective;
18445                            let sub_table = table;
18446                            self.persist_sub(&self.subs[sub_index].clone())
18447                                .map_err(|message| {
18448                                    SectionError::Abort("storage.failed".into(), message)
18449                                })?;
18450                            let dropped = self
18451                                .drop_doomed_outbox(&sub_table, &doomed_effective)
18452                                .map_err(|message| {
18453                                    SectionError::Abort("storage.failed".to_owned(), message)
18454                                })?;
18455                            if dropped {
18456                                batch.status = true;
18457                                batch.rejections = true;
18458                                batch.outcomes = true;
18459                            }
18460                            // §5.9.7 B2: revocation deletes now-unauthorized blob
18461                            // bodies (evicted ≠ revoked).
18462                            self.delete_unreferenced_cached_blobs().map_err(|message| {
18463                                SectionError::Abort("storage.failed".into(), message)
18464                            })?;
18465                        }
18466                        Err(()) => {
18467                            // §3.3 fail closed: no local mapping — never clear by
18468                            // approximation; fatal configuration error.
18469                            let sub = &mut self.subs[sub_index];
18470                            sub.state = SubState::Failed;
18471                            sub.reason_code = Some("sync.scope_revoked".to_owned());
18472                            report.failed.push(id.to_owned());
18473                            self.persist_sub(&self.subs[sub_index].clone())
18474                                .map_err(|message| {
18475                                    SectionError::Abort("storage.failed".into(), message)
18476                                })?;
18477                        }
18478                    }
18479                    if let Some((base_key, unit)) = registered {
18480                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18481                    }
18482                    self.rebuild_overlay_if_dirty()
18483                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18484                    self.finish_observation("syncular_revocation", batch)
18485                        .map_err(|message| {
18486                            SectionError::Abort("storage.failed".to_owned(), message)
18487                        })?;
18488                    Ok(())
18489                })();
18490                if result.is_err() {
18491                    self.rollback_observation("syncular_revocation");
18492                    self.subs[sub_index] = previous;
18493                    self.overlay_dirty.restore(was_dirty.clone());
18494                    self.outbox = previous_outbox;
18495                    self.failed_commits = previous_failed;
18496                    self.rejections = previous_rejections;
18497                    self.conflicts = previous_conflicts;
18498                    report.revoked.truncate(revoked_count);
18499                    report.failed.truncate(failed_count);
18500                }
18501                result
18502            }
18503            SubStatus::Reset => {
18504                self.begin_observation("syncular_reset")
18505                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18506                let previous = self.subs[sub_index].clone();
18507                let was_dirty = self.overlay_dirty.snapshot();
18508                let reset_count = report.resets.len();
18509                let result = (|| {
18510                    let mut batch = ChangeAccumulator::default();
18511                    let registered = self.window_unit_by_sub_id(id);
18512                    // §4.6: discard cursor + bootstrap state, keep local rows —
18513                    // reset is a staleness signal, not a purge signal.
18514                    let sub = &mut self.subs[sub_index];
18515                    sub.cursor = -1;
18516                    sub.bootstrap_state = None;
18517                    sub.reason_code = Some(if reason_code.is_empty() {
18518                        "sync.cursor_expired".to_owned()
18519                    } else {
18520                        reason_code.to_owned()
18521                    });
18522                    report.resets.push(id.to_owned());
18523                    self.persist_sub(&self.subs[sub_index].clone())
18524                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18525                    if let Some((base_key, unit)) = registered {
18526                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
18527                    }
18528                    self.finish_observation("syncular_reset", batch)
18529                        .map_err(|message| {
18530                            SectionError::Abort("storage.failed".to_owned(), message)
18531                        })?;
18532                    Ok(())
18533                })();
18534                if result.is_err() {
18535                    self.rollback_observation("syncular_reset");
18536                    self.subs[sub_index] = previous;
18537                    self.overlay_dirty.restore(was_dirty.clone());
18538                    report.resets.truncate(reset_count);
18539                }
18540                result
18541            }
18542            SubStatus::Active => {
18543                let fresh = meta
18544                    .fresh
18545                    .iter()
18546                    .find(|(fid, _)| fid == id)
18547                    .map(|(_, fresh)| *fresh)
18548                    .unwrap_or(false);
18549                let previous = self.subs[sub_index].clone();
18550                self.subs[sub_index].effective = Some(effective_scopes);
18551                let outcome = self
18552                    .apply_section_body(
18553                        transport,
18554                        sub_index,
18555                        body,
18556                        fresh,
18557                        meta,
18558                        report,
18559                        coalesced_deletes,
18560                    )
18561                    .and_then(|()| {
18562                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
18563                            SectionError::Abort(
18564                                "sync.invalid_request".into(),
18565                                "subscription section without SUB_END".into(),
18566                            )
18567                        })?;
18568                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
18569                            .map_err(|message| {
18570                                SectionError::Abort("storage.failed".into(), message)
18571                            })
18572                    });
18573                match outcome {
18574                    Ok(()) => {
18575                        if self.subs[sub_index].bootstrap_state.is_some() {
18576                            report.bootstrapping.push(id.to_owned());
18577                        }
18578                        Ok(())
18579                    }
18580                    Err(SectionError::FailClosed) => {
18581                        self.subs[sub_index] = previous.clone();
18582                        self.begin_observation("syncular_section_failure")
18583                            .map_err(|message| {
18584                                SectionError::Abort("storage.failed".into(), message)
18585                            })?;
18586                        let persisted = (|| {
18587                            let mut batch = ChangeAccumulator::default();
18588                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18589                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
18590                            }
18591                            self.subs[sub_index].state = SubState::Failed;
18592                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18593                            self.persist_sub(&self.subs[sub_index])?;
18594                            self.finish_observation("syncular_section_failure", batch)
18595                        })();
18596                        if let Err(message) = persisted {
18597                            self.rollback_observation("syncular_section_failure");
18598                            self.subs[sub_index] = previous;
18599                            return Err(SectionError::Abort("storage.failed".into(), message));
18600                        }
18601                        report.failed.push(id.to_owned());
18602                        Ok(())
18603                    }
18604                    Err(error) => {
18605                        // Frame/block transactions already completed. Preserve their
18606                        // rows and revisions, but leave this subscription cursor unchanged.
18607                        self.subs[sub_index] = previous;
18608                        Err(error)
18609                    }
18610                }
18611            }
18612        }
18613    }
18614
18615    fn apply_sub_end(
18616        &mut self,
18617        sub_index: usize,
18618        cursor: i64,
18619        bootstrap_state: Option<String>,
18620    ) -> Result<(), String> {
18621        #[cfg(feature = "bench-internals")]
18622        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18623        let previous = self.subs[sub_index].clone();
18624        self.begin_observation("syncular_sub_end")?;
18625        let persisted = (|| {
18626            let mut batch = ChangeAccumulator::default();
18627            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18628                && cursor >= 0
18629                && bootstrap_state.is_none();
18630            if completed {
18631                let table = self
18632                    .schema
18633                    .table(&previous.table)
18634                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
18635                let effective = previous.effective.as_deref().unwrap_or(&[]);
18636                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18637                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18638                let rows = statement
18639                    .query_map(rusqlite::params![previous.table, cursor], |row| {
18640                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18641                    })
18642                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18643                let mut covered = HashSet::new();
18644                for row in rows {
18645                    let (id, intent) =
18646                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18647                    let values: Map<String, Value> = serde_json::from_str(&intent)
18648                        .map_err(|_| "sync.local_corrupt".to_owned())?;
18649                    if !effective.is_empty()
18650                        && effective.iter().all(|(variable, allowed)| {
18651                            table
18652                                .scope_column(variable)
18653                                .and_then(|column| values.get(column))
18654                                .is_some_and(|value| {
18655                                    allowed.contains(
18656                                        &value
18657                                            .as_str()
18658                                            .map_or_else(|| value.to_string(), str::to_owned),
18659                                    )
18660                                })
18661                        })
18662                    {
18663                        covered.insert(id);
18664                    }
18665                }
18666                drop(statement);
18667                for id in covered {
18668                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18669                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18670                    self.overlay_dirty.table(&previous.table);
18671                    batch.table(&previous.table);
18672                }
18673                self.rebuild_overlay_if_dirty()?;
18674                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18675                    batch.window(&base_key, &previous.table, &unit);
18676                }
18677            }
18678            let sub = &mut self.subs[sub_index];
18679            sub.cursor = cursor;
18680            sub.bootstrap_state = bootstrap_state;
18681            // §4.6: a completed (or continuing) active end clears the reset /
18682            // revocation reason, matching the TS core's active-state persist.
18683            sub.reason_code = None;
18684            sub.synced_once = true;
18685            self.persist_sub(&self.subs[sub_index])?;
18686            self.finish_observation("syncular_sub_end", batch)
18687        })();
18688        if persisted.is_err() {
18689            self.rollback_observation("syncular_sub_end");
18690            self.subs[sub_index] = previous;
18691        }
18692        persisted
18693    }
18694
18695    #[allow(clippy::too_many_arguments)]
18696    fn apply_section_body(
18697        &mut self,
18698        transport: &mut dyn Transport,
18699        sub_index: usize,
18700        body: Vec<Frame>,
18701        fresh: bool,
18702        meta: &RequestMeta,
18703        report: &mut SyncReport,
18704        coalesced_deletes: &HashSet<(i64, String, String)>,
18705    ) -> Result<(), SectionError> {
18706        let mut saw_segment = false;
18707        for frame in body {
18708            match frame {
18709                Frame::Commit {
18710                    tables,
18711                    changes,
18712                    commit_seq,
18713                    ..
18714                } => {
18715                    if self
18716                        .apply_commit_frame_coalesced(
18717                            &tables,
18718                            changes,
18719                            commit_seq,
18720                            coalesced_deletes,
18721                        )
18722                        .map_err(|(c, m)| SectionError::Abort(c, m))?
18723                    {
18724                        report.commits_applied += 1;
18725                    }
18726                }
18727                Frame::SegmentInline { payload } => {
18728                    let segment = decode_rows_segment(&payload)
18729                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18730                    self.progress.update(|p| {
18731                        p.phase = ProgressPhase::Import;
18732                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18733                        p.table = Some(self.subs[sub_index].table.clone());
18734                        p.segment_id = None;
18735                        p.bytes_received = payload.len() as u64;
18736                        p.bytes_total = Some(payload.len() as u64);
18737                        p.rows_processed = 0;
18738                        p.rows_total =
18739                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18740                    });
18741                    let first = !saw_segment;
18742                    saw_segment = true;
18743                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18744                    report.segment_rows_applied += applied;
18745                }
18746                Frame::SegmentRef {
18747                    segment_id,
18748                    byte_length,
18749                    media_type,
18750                    table,
18751                    row_count,
18752                    as_of_commit_seq,
18753                    scope_digest,
18754                    row_cursor,
18755                    next_row_cursor,
18756                    url,
18757                    url_expires_at_ms,
18758                    ..
18759                } => {
18760                    // §4.2: reject a descriptor whose mediaType was not
18761                    // advertised — never skip or guess.
18762                    let advertised = match media_type {
18763                        MediaType::Rows => {
18764                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18765                                || meta.accept & ACCEPT_INLINE_ROWS != 0
18766                        }
18767                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18768                    };
18769                    if !advertised {
18770                        return Err(SectionError::Abort(
18771                            "sync.invalid_request".to_owned(),
18772                            format!(
18773                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18774                                media_type.name()
18775                            ),
18776                        ));
18777                    }
18778                    self.progress.update(|p| {
18779                        p.phase = ProgressPhase::Download;
18780                        p.subscription_id = Some(self.subs[sub_index].id.clone());
18781                        p.table = Some(table.clone());
18782                        p.segment_id = Some(segment_id.clone());
18783                        p.bytes_received = 0;
18784                        p.bytes_total = u64::try_from(byte_length).ok();
18785                        p.rows_processed = 0;
18786                        p.rows_total = u64::try_from(row_count).ok();
18787                    });
18788                    let observer = self.progress.clone();
18789                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18790                    let bytes = if let Some(url) = url {
18791                        // §5.4: a url-carrying descriptor MUST be fetched
18792                        // from that URL; failure invalidates the whole
18793                        // descriptor (no fall-through to §5.5 — re-pull
18794                        // recovers, §1.4 rule 5).
18795                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18796                            return Err(SectionError::Abort(
18797                                "sync.invalid_request".to_owned(),
18798                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18799                                    .to_owned(),
18800                            ));
18801                        }
18802                        // §5.4: MUST NOT start a fetch at/past expiry.
18803                        if url_expires_at_ms.is_some_and(|exp| {
18804                            exp <= transport
18805                                .segment_fetch_started_at(&url)
18806                                .unwrap_or_else(|| self.clock_now_ms())
18807                        }) {
18808                            return Err(SectionError::Abort(
18809                                "sync.segment_expired".to_owned(),
18810                                format!(
18811                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18812                                ),
18813                            ));
18814                        }
18815                        transport
18816                            .fetch_url(&url, &mut on_progress)
18817                            .map_err(|e| self.segment_fetch_failed(e))?
18818                    } else {
18819                        let requested_scopes_json =
18820                            canonical_scope_json(&self.subs[sub_index].requested);
18821                        transport
18822                            .download_segment(
18823                                &SegmentRequest {
18824                                    segment_id: segment_id.clone(),
18825                                    table,
18826                                    requested_scopes_json,
18827                                },
18828                                &mut on_progress,
18829                            )
18830                            .map_err(|e| self.segment_fetch_failed(e))?
18831                    };
18832                    // §5.1: verify the content address before applying.
18833                    on_progress(bytes.len() as u64);
18834                    let digest = Sha256::digest(&bytes);
18835                    let expected = segment_id
18836                        .strip_prefix("sha256:")
18837                        .unwrap_or(segment_id.as_str());
18838                    if bytes_to_hex(&digest) != expected {
18839                        // §5.1: the client discards the segment and re-pulls.
18840                        self.schedule_background_retry();
18841                        return Err(SectionError::Abort(
18842                            "sync.invalid_request".to_owned(),
18843                            "segment bytes do not match the content address (§5.1)".to_owned(),
18844                        ));
18845                    }
18846                    self.progress.update(|p| p.phase = ProgressPhase::Import);
18847                    if media_type == MediaType::Sqlite {
18848                        // §5.3: images are whole-table — a paged sqlite
18849                        // descriptor is invalid.
18850                        if row_cursor.is_some() || next_row_cursor.is_some() {
18851                            return Err(SectionError::Abort(
18852                                "sync.invalid_request".to_owned(),
18853                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
18854                                    .to_owned(),
18855                            ));
18856                        }
18857                        let first = !saw_segment;
18858                        saw_segment = true;
18859                        let applied = self.apply_sqlite_segment(
18860                            sub_index,
18861                            &bytes,
18862                            fresh && first,
18863                            row_count,
18864                            as_of_commit_seq,
18865                            &scope_digest,
18866                        )?;
18867                        report.segment_rows_applied += applied;
18868                    } else {
18869                        let segment = decode_rows_segment(&bytes).map_err(|e| {
18870                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
18871                        })?;
18872                        let first = row_cursor.is_none();
18873                        saw_segment = true;
18874                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18875                        report.segment_rows_applied += applied;
18876                    }
18877                }
18878                Frame::Unknown { .. } => {}
18879                Frame::Error { code, message, .. } => {
18880                    return Err(SectionError::Abort(code, message))
18881                }
18882                _ => {
18883                    return Err(SectionError::Abort(
18884                        "sync.invalid_request".to_owned(),
18885                        "unexpected frame inside a subscription section".to_owned(),
18886                    ));
18887                }
18888            }
18889        }
18890        Ok(())
18891    }
18892
18893    /// Apply one commit frame after dropping a scope move's duplicate delete
18894    /// when the same commit carries the row's upsert in another applied
18895    /// section (§2.2). A frame that carried both itself is untouched.
18896    fn apply_commit_frame_coalesced(
18897        &mut self,
18898        tables: &[String],
18899        changes: Vec<ssp2::model::Change>,
18900        commit_seq: i64,
18901        coalesced_deletes: &HashSet<(i64, String, String)>,
18902    ) -> Result<bool, (String, String)> {
18903        let changes: Vec<ssp2::model::Change> = changes
18904            .into_iter()
18905            .filter(|change| {
18906                if change.op != Op::Delete {
18907                    return true;
18908                }
18909                let Some(table) = tables.get(change.table_index as usize) else {
18910                    return true;
18911                };
18912                !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
18913            })
18914            .collect();
18915        if changes.is_empty() {
18916            return Ok(false);
18917        }
18918        self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
18919        Ok(true)
18920    }
18921
18922    // Both pull and realtime frames use the same durable observation boundary.
18923    fn apply_commit_frame(
18924        &mut self,
18925        tables: &[String],
18926        changes: &[ssp2::model::Change],
18927        commit_seq: Option<i64>,
18928    ) -> Result<(), (String, String)> {
18929        #[cfg(feature = "bench-internals")]
18930        let _phase = self.benchmark_phases.start(Phase::CommitApply);
18931        let was_dirty = self.overlay_dirty.snapshot();
18932        self.coalesce_unit(changes.len())
18933            .and_then(|()| self.begin_observation("syncular_commit"))
18934            .map_err(|message| ("storage.failed".into(), message))?;
18935        let applied = (|| {
18936            let mut batch = ChangeAccumulator::default();
18937            self.record_commit_changes(&mut batch, tables, changes);
18938            if let Some(sequence) = commit_seq {
18939                for change in changes {
18940                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
18941                        (
18942                            "sync.invalid_request".to_owned(),
18943                            "change tableIndex out of range".to_owned(),
18944                        )
18945                    })?;
18946                    if self
18947                        .outbox
18948                        .iter()
18949                        .flat_map(|commit| &commit.ops)
18950                        .any(|op| op.table == *table && op.row_id == change.row_id)
18951                    {
18952                        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])
18953                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18954                    }
18955                    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])
18956                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18957                }
18958            }
18959            self.apply_commit_changes(tables, changes)?;
18960            self.rebuild_overlay_if_dirty()
18961                .map_err(|message| ("storage.failed".into(), message))?;
18962            self.finish_observation("syncular_commit", batch)
18963                .map_err(|message| ("storage.failed".into(), message))
18964        })();
18965        if applied.is_err() {
18966            self.rollback_observation("syncular_commit");
18967            self.overlay_dirty.restore(was_dirty.clone());
18968        }
18969        applied
18970    }
18971
18972    fn apply_commit_changes(
18973        &mut self,
18974        tables: &[String],
18975        changes: &[ssp2::model::Change],
18976    ) -> Result<(), (String, String)> {
18977        // A clean overlay can reconcile changed primary keys independently
18978        // when their tables have no secondary unique constraints. Such a
18979        // constraint can change whether another row's pending write succeeds,
18980        // so those frames still require the complete FIFO replay below.
18981        let mirror_visible = !self.overlay_dirty.get()
18982            && self.failed_commits.is_empty()
18983            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18984                || changes.iter().all(|change| {
18985                    tables
18986                        .get(change.table_index as usize)
18987                        .and_then(|name| self.schema.table(name))
18988                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18989                }));
18990        for change in changes {
18991            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18992                (
18993                    "sync.invalid_request".to_owned(),
18994                    "change tableIndex out of range".to_owned(),
18995                )
18996            })?;
18997            let table = self.schema.table(table_name).ok_or_else(|| {
18998                (
18999                    "sync.schema_mismatch".to_owned(),
19000                    format!("change targets unknown table {table_name:?}"),
19001                )
19002            })?;
19003            match change.op {
19004                Op::Upsert => {
19005                    let payload = change.row.as_ref().ok_or_else(|| {
19006                        (
19007                            "sync.invalid_request".to_owned(),
19008                            "upsert change without row payload".to_owned(),
19009                        )
19010                    })?;
19011                    // §5.11: decrypt encrypted columns on apply.
19012                    let row = {
19013                        #[cfg(feature = "bench-internals")]
19014                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
19015                        decode_row_bytes(table, payload, &self.encryption)
19016                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19017                    };
19018                    let version = change.row_version.unwrap_or(0);
19019                    let table_name = table.name.clone();
19020                    self.write_base_row(&table_name, &row, version)
19021                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19022                    if mirror_visible {
19023                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
19024                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19025                    }
19026                }
19027                Op::Delete => {
19028                    self.delete_base_row(table_name, &change.row_id)
19029                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19030                    if mirror_visible {
19031                        let sql = format!(
19032                            "DELETE FROM {} WHERE {}",
19033                            visible_table(table_name),
19034                            row_id_predicate(table)
19035                        );
19036                        self.conn
19037                            .prepare_cached(&sql)
19038                            .and_then(|mut statement| {
19039                                statement.execute(rusqlite::params![change.row_id])
19040                            })
19041                            .map_err(|error| {
19042                                ("sync.invalid_request".to_owned(), error.to_string())
19043                            })?;
19044                    }
19045                }
19046            }
19047        }
19048        if mirror_visible {
19049            {
19050                let changed_rows: HashSet<(&str, &str)> = changes
19051                    .iter()
19052                    .map(|change| {
19053                        (
19054                            tables[change.table_index as usize].as_str(),
19055                            change.row_id.as_str(),
19056                        )
19057                    })
19058                    .collect();
19059                let acknowledged = self
19060                    .acknowledged_operations(Some(&changed_rows), None)
19061                    .map_err(|error| ("storage.failed".to_owned(), error))?;
19062                self.apply_outbox_ops(&acknowledged)
19063                    .map_err(|message| ("storage.failed".into(), message))?;
19064                self.apply_outbox_ops(
19065                    self.outbox
19066                        .iter()
19067                        .flat_map(|commit| &commit.ops)
19068                        .filter(|op| {
19069                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19070                        }),
19071                )
19072                .map_err(|message| ("storage.failed".into(), message))?;
19073            }
19074            self.overlay_dirty.set(false);
19075        }
19076        Ok(())
19077    }
19078
19079    /// §5.6 segment application: validate against the generated schema,
19080    /// clear the grant on a fresh bootstrap's first page (fail closed
19081    /// without a mapping), then replace-or-upsert each row with its
19082    /// segment-carried server version (§5.2).
19083    fn apply_segment(
19084        &mut self,
19085        sub_index: usize,
19086        segment: &RowsSegment,
19087        first_fresh_page: bool,
19088    ) -> Result<u32, SectionError> {
19089        let (sub_table, effective) = {
19090            let sub = &self.subs[sub_index];
19091            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19092        };
19093        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19094            SectionError::Abort(
19095                "sync.schema_mismatch".to_owned(),
19096                format!("subscription table {sub_table:?} is not in the client schema"),
19097            )
19098        })?;
19099        // §5.2: the column table validates against the generated schema —
19100        // order, names, types, nullability; mismatch is fatal. §5.11: the
19101        // server sends the WIRE types (bytes for an encrypted column), so
19102        // validate against wire_columns.
19103        let matches = segment.table == table.name
19104            && segment.schema_version == self.schema.version
19105            && segment.columns.len() == table.wire_columns.len()
19106            && segment
19107                .columns
19108                .iter()
19109                .zip(table.wire_columns.iter())
19110                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19111        if !matches {
19112            return Err(SectionError::Abort(
19113                "sync.schema_mismatch".to_owned(),
19114                "segment column table does not match the generated schema (§5.2)".to_owned(),
19115            ));
19116        }
19117        let mut applied = 0u32;
19118        let empty = Vec::new();
19119        let blocks = if segment.blocks.is_empty() {
19120            std::slice::from_ref(&empty)
19121        } else {
19122            &segment.blocks
19123        };
19124        for (index, block) in blocks.iter().enumerate() {
19125            let clear = first_fresh_page && index == 0;
19126            if !clear && block.is_empty() {
19127                continue;
19128            }
19129            let was_dirty = self.overlay_dirty.snapshot();
19130            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19131                && self
19132                    .outbox
19133                    .iter()
19134                    .flat_map(|commit| &commit.ops)
19135                    .any(|op| op.table == table.name);
19136            let mirror_visible = !reconcile_pending
19137                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19138            self.coalesce_unit(block.len())
19139                .and_then(|()| self.begin_observation("syncular_segment_block"))
19140                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19141            let outcome = (|| {
19142                self.rebuild_overlay_if_dirty()
19143                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19144                let mut changed_keys = Vec::new();
19145                let mut batch = ChangeAccumulator::default();
19146                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19147                    batch.table(&table.name);
19148                }
19149                if clear {
19150                    self.purge_scope_rows(&table.name, &effective)
19151                        .map_err(|()| SectionError::FailClosed)?;
19152                }
19153                for (row_index, row) in block.iter().enumerate() {
19154                    let decrypted;
19155                    let values = if table.has_encrypted_columns() {
19156                        let mut values = row.values.clone();
19157                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19158                            .map_err(|message| {
19159                            SectionError::Abort("client.decrypt_failed".into(), message)
19160                        })?;
19161                        decrypted = values;
19162                        &decrypted
19163                    } else {
19164                        &row.values
19165                    };
19166                    self.write_base_row(&table.name, values, row.server_version)
19167                        .map_err(|message| {
19168                            SectionError::Abort("sync.invalid_request".into(), message)
19169                        })?;
19170                    if reconcile_pending {
19171                        changed_keys.push(
19172                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19173                                |message| SectionError::Abort("storage.failed".into(), message),
19174                            )?,
19175                        );
19176                    }
19177                    if mirror_visible {
19178                        self.write_row(
19179                            &visible_table(&table.name),
19180                            &table.name,
19181                            values,
19182                            row.server_version,
19183                        )
19184                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19185                    }
19186                    let processed = u64::from(applied) + row_index as u64 + 1;
19187                    if processed.is_multiple_of(1024) {
19188                        self.progress.update(|p| p.rows_processed = processed);
19189                    }
19190                }
19191                if reconcile_pending {
19192                    self.reconcile_imported_rows(&table, &mut changed_keys)
19193                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19194                }
19195                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19196                    self.apply_outbox_ops(
19197                        self.outbox
19198                            .iter()
19199                            .flat_map(|commit| &commit.ops)
19200                            .filter(|op| op.table == table.name),
19201                    )
19202                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19203                    self.overlay_dirty.set(false);
19204                } else {
19205                    self.rebuild_overlay_if_dirty()
19206                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19207                }
19208                self.finish_observation("syncular_segment_block", batch)
19209                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19210            })();
19211            if let Err(error) = outcome {
19212                self.rollback_observation("syncular_segment_block");
19213                self.overlay_dirty.restore(was_dirty.clone());
19214                return Err(error);
19215            }
19216            applied += block.len() as u32;
19217            self.progress
19218                .update(|p| p.rows_processed = u64::from(applied));
19219            std::thread::yield_now();
19220        }
19221        self.progress
19222            .update(|p| p.rows_processed = u64::from(applied));
19223        Ok(applied)
19224    }
19225
19226    /// §5.3 sqlite-image application: validate the in-file metadata
19227    /// against the descriptor, validate column names/order against the
19228    /// generated schema, run the §5.6 first-page clear when fresh, then
19229    /// replace-or-upsert every image row with its `_syncular_version`.
19230    /// Mechanics: the image lands in a temp file read through a second
19231    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
19232    /// Each image chunk commits its writes and revision before readers continue.
19233    fn apply_sqlite_segment(
19234        &mut self,
19235        sub_index: usize,
19236        bytes: &[u8],
19237        first_fresh_page: bool,
19238        row_count: i64,
19239        as_of_commit_seq: i64,
19240        scope_digest: &str,
19241    ) -> Result<u32, SectionError> {
19242        let invalid = |detail: &str| {
19243            SectionError::Abort(
19244                "sync.invalid_request".to_owned(),
19245                format!("sqlite segment rejected: {detail} (§5.3)"),
19246            )
19247        };
19248        let (sub_table, effective) = {
19249            let sub = &self.subs[sub_index];
19250            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19251        };
19252        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19253            SectionError::Abort(
19254                "sync.schema_mismatch".to_owned(),
19255                format!("subscription table {sub_table:?} is not in the client schema"),
19256            )
19257        })?;
19258
19259        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19260        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19261        let img = match rusqlite::Connection::open_with_flags(
19262            &path,
19263            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19264        ) {
19265            Ok(conn) => conn,
19266            Err(_) => {
19267                let _ = std::fs::remove_file(&path);
19268                return Err(invalid("bytes do not open as a SQLite database"));
19269            }
19270        };
19271        let outcome = self.apply_sqlite_image(
19272            &img,
19273            &table,
19274            first_fresh_page,
19275            &effective,
19276            row_count,
19277            as_of_commit_seq,
19278            scope_digest,
19279        );
19280        drop(img);
19281        let _ = std::fs::remove_file(&path);
19282        outcome
19283    }
19284
19285    #[allow(clippy::too_many_arguments)]
19286    fn apply_sqlite_image(
19287        &mut self,
19288        img: &rusqlite::Connection,
19289        table: &crate::schema::TableSchema,
19290        first_fresh_page: bool,
19291        effective: &[(String, Vec<String>)],
19292        row_count: i64,
19293        as_of_commit_seq: i64,
19294        scope_digest: &str,
19295    ) -> Result<u32, SectionError> {
19296        let invalid = |detail: String| {
19297            SectionError::Abort(
19298                "sync.invalid_request".to_owned(),
19299                format!("sqlite segment rejected: {detail} (§5.3)"),
19300            )
19301        };
19302
19303        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
19304        //    one row).
19305        type MetaRow = (i64, String, i64, i64, String, i64, i64);
19306        let meta: MetaRow = img
19307            .query_row(
19308                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19309                        \"scopeDigest\", \"rowCount\",
19310                        (SELECT count(*) FROM _syncular_segment)
19311                 FROM _syncular_segment",
19312                [],
19313                |row| {
19314                    Ok((
19315                        row.get(0)?,
19316                        row.get(1)?,
19317                        row.get(2)?,
19318                        row.get(3)?,
19319                        row.get(4)?,
19320                        row.get(5)?,
19321                        row.get(6)?,
19322                    ))
19323                },
19324            )
19325            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19326        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19327        if meta_count != 1 {
19328            return Err(invalid(format!(
19329                "_syncular_segment must contain exactly one row, found {meta_count}"
19330            )));
19331        }
19332        if format != 1 {
19333            return Err(invalid(format!("format {format}")));
19334        }
19335        if meta_table != table.name {
19336            return Err(invalid(format!("image table {meta_table:?}")));
19337        }
19338        if schema_version != i64::from(self.schema.version) {
19339            return Err(invalid(format!("schemaVersion {schema_version}")));
19340        }
19341        if pin != as_of_commit_seq {
19342            return Err(invalid(format!("asOfCommitSeq {pin}")));
19343        }
19344        if digest != scope_digest {
19345            return Err(invalid("scopeDigest mismatch".to_owned()));
19346        }
19347        if meta_rows != row_count {
19348            return Err(invalid(format!("rowCount {meta_rows}")));
19349        }
19350
19351        // 2. Column names and order vs the generated schema (§5.3 rule 3).
19352        let mut names: Vec<String> = Vec::new();
19353        let mut primary_keys: Vec<String> = Vec::new();
19354        {
19355            let mut stmt = img
19356                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19357                .map_err(|_| invalid("image data table missing".to_owned()))?;
19358            let mut rows = stmt
19359                .query([])
19360                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19361            while let Some(row) = rows
19362                .next()
19363                .map_err(|_| invalid("image data table unreadable".to_owned()))?
19364            {
19365                if row
19366                    .get::<_, i64>(5)
19367                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19368                    > 0
19369                {
19370                    primary_keys.push(row.get(1).map_err(|error| {
19371                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19372                    })?);
19373                }
19374                names.push(
19375                    row.get::<_, String>(1)
19376                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19377                );
19378            }
19379        }
19380        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19381        expected.push("_syncular_version");
19382        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19383            return Err(SectionError::Abort(
19384                "sync.schema_mismatch".to_owned(),
19385                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19386            ));
19387        }
19388
19389        if primary_keys != vec![table.primary_key.clone()] {
19390            return Err(invalid(
19391                "image primary key does not match generated schema".into(),
19392            ));
19393        }
19394        let actual_count: i64 = img
19395            .query_row(
19396                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19397                [],
19398                |row| row.get(0),
19399            )
19400            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19401        if actual_count != row_count {
19402            return Err(invalid("image row count does not match descriptor".into()));
19403        }
19404        let insert = self.insert_row_sql(&base_table(&table.name), table);
19405        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19406        let column_list = names
19407            .iter()
19408            .map(|n| quote_ident(n))
19409            .collect::<Vec<_>>()
19410            .join(", ");
19411        let mut stmt = img
19412            .prepare(&format!(
19413                "SELECT {column_list} FROM {} ORDER BY {}",
19414                quote_ident(&table.name),
19415                quote_ident(&table.primary_key)
19416            ))
19417            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19418        let mut rows = stmt
19419            .query([])
19420            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19421        let mut applied = 0u32;
19422        loop {
19423            let clear = applied == 0 && first_fresh_page;
19424            let was_dirty = self.overlay_dirty.snapshot();
19425            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19426                && self
19427                    .outbox
19428                    .iter()
19429                    .flat_map(|commit| &commit.ops)
19430                    .any(|op| op.table == table.name);
19431            let mirror_visible = !reconcile_pending
19432                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19433            // A chunk holds up to 1,024 rows (§5.3).
19434            self.coalesce_unit(1024)
19435                .and_then(|()| self.begin_observation("syncular_image_chunk"))
19436                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19437            let result = (|| {
19438                self.rebuild_overlay_if_dirty()
19439                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19440                let mut changed_keys = Vec::new();
19441                let mut batch = ChangeAccumulator::default();
19442                if clear {
19443                    if self.scoped_rows_exist(&table.name, effective) {
19444                        batch.table(&table.name);
19445                    }
19446                    self.purge_scope_rows(&table.name, effective)
19447                        .map_err(|()| SectionError::FailClosed)?;
19448                }
19449                let mut processed = 0u32;
19450                {
19451                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19452                        invalid(Self::sqlite_failure(&self.storage_failure, error))
19453                    })?;
19454                    let mut mirror = if mirror_visible {
19455                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19456                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19457                        })?)
19458                    } else {
19459                        None
19460                    };
19461                    for _ in 0..1024 {
19462                        let Some(row) = rows.next().map_err(|error| {
19463                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19464                        })?
19465                        else {
19466                            break;
19467                        };
19468                        for (i, column) in table.columns.iter().enumerate() {
19469                            let cell = row.get_ref(i).map_err(|error| {
19470                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19471                            })?;
19472                            let param = image_cell_param(column, cell).map_err(invalid)?;
19473                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
19474                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19475                            })?;
19476                            if let Some(mirror) = &mut mirror {
19477                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
19478                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19479                                })?;
19480                            }
19481                        }
19482                        let version_index = table.columns.len();
19483                        let version: i64 = row.get(version_index).map_err(|error| {
19484                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19485                        })?;
19486                        if version < 1 {
19487                            return Err(invalid("image row version must be positive".into()));
19488                        }
19489                        ins.raw_bind_parameter(version_index + 1, version)
19490                            .map_err(|error| {
19491                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19492                            })?;
19493                        ins.raw_execute().map_err(|error| {
19494                            invalid(Self::sqlite_failure(&self.storage_failure, error))
19495                        })?;
19496                        if let Some(mirror) = &mut mirror {
19497                            mirror
19498                                .raw_bind_parameter(version_index + 1, version)
19499                                .map_err(|error| {
19500                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
19501                                })?;
19502                            mirror.raw_execute().map_err(|error| {
19503                                invalid(Self::sqlite_failure(&self.storage_failure, error))
19504                            })?;
19505                        }
19506                        if reconcile_pending {
19507                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19508                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19509                            )?);
19510                        }
19511                        processed += 1;
19512                    }
19513                }
19514                if processed > 0 {
19515                    batch.table(&table.name);
19516                    self.overlay_dirty.table(&table.name);
19517                }
19518                if reconcile_pending {
19519                    self.reconcile_imported_rows(table, &mut changed_keys)
19520                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19521                }
19522                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19523                    self.apply_outbox_ops(
19524                        self.outbox
19525                            .iter()
19526                            .flat_map(|commit| &commit.ops)
19527                            .filter(|op| op.table == table.name),
19528                    )
19529                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19530                    self.overlay_dirty.set(false);
19531                } else {
19532                    self.rebuild_overlay_if_dirty()
19533                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19534                }
19535                self.finish_observation("syncular_image_chunk", batch)
19536                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19537                Ok(processed)
19538            })();
19539            let processed = match result {
19540                Ok(processed) => processed,
19541                Err(error) => {
19542                    self.rollback_observation("syncular_image_chunk");
19543                    self.overlay_dirty.restore(was_dirty.clone());
19544                    return Err(error);
19545                }
19546            };
19547            applied += processed;
19548            self.progress
19549                .update(|p| p.rows_processed = u64::from(applied));
19550            std::thread::yield_now();
19551            if i64::from(applied) == actual_count {
19552                break;
19553            }
19554        }
19555        Ok(applied)
19556    }
19557
19558    /// Restore only imported and pending row identities before FIFO replay. All pending
19559    /// identities participate: a server row can free a unique value for a different edit.
19560    /// The caller owns the chunk transaction and publishes its revision after replay.
19561    fn reconcile_imported_rows(
19562        &self,
19563        table: &TableSchema,
19564        keys: &mut Vec<SqlValue>,
19565    ) -> Result<(), String> {
19566        let visible = visible_table(&table.name);
19567        let base = base_table(&table.name);
19568        let pk = quote_ident(&table.primary_key);
19569        let predicate = row_id_predicate(table);
19570        let mut lookup = self.conn.prepare_cached(&format!(
19571            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19572        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19573        let mut deleted = HashSet::new();
19574        for op in self
19575            .outbox
19576            .iter()
19577            .flat_map(|commit| &commit.ops)
19578            .filter(|op| op.table == table.name)
19579        {
19580            if op.upsert {
19581                if let Some(values) = &op.values {
19582                    let key = json_to_column_value(
19583                        &table.columns[table.pk_index],
19584                        values.get(&table.primary_key),
19585                    )?;
19586                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
19587                }
19588            } else if deleted.insert(&op.row_id) {
19589                keys.extend(
19590                    lookup
19591                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19592                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19593                        .collect::<Result<Vec<_>, _>>()
19594                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19595                );
19596            }
19597        }
19598        // Delete the complete affected set before restoring any base value, so
19599        // old optimistic unique values cannot collide with the new server rows.
19600        for chunk in keys.chunks(400) {
19601            let holes = vec!["?"; chunk.len()].join(",");
19602            self.conn
19603                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19604                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19605                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19606        }
19607        let updates = table
19608            .columns
19609            .iter()
19610            .map(|column| column.name.as_str())
19611            .chain(std::iter::once("_syncular_version"))
19612            .filter(|name| *name != table.primary_key)
19613            .map(|name| {
19614                let name = quote_ident(name);
19615                format!("{name}=excluded.{name}")
19616            })
19617            .collect::<Vec<_>>()
19618            .join(",");
19619        for chunk in keys.chunks(400) {
19620            let holes = vec!["?"; chunk.len()].join(",");
19621            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19622                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19623                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19624        }
19625        Ok(())
19626    }
19627
19628    // -- application-authorized local purge ---------------------------------------
19629
19630    fn compile_local_data_purge(
19631        &self,
19632        input: &LocalDataPurgeInput,
19633    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19634        let invalid = |message: String| format!("sync.invalid_request: {message}");
19635        if input.purge_id.is_empty()
19636            || input.purge_id.len() > 128
19637            || !is_local_operation_code_like(&input.purge_id)
19638        {
19639            return Err(invalid(
19640                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19641            ));
19642        }
19643        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19644            return Err(invalid(format!(
19645                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19646            )));
19647        }
19648
19649        let mut deduplicated: BTreeMap<
19650            String,
19651            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19652        > = BTreeMap::new();
19653        for target in &input.targets {
19654            let table = self.schema.table(&target.table).ok_or_else(|| {
19655                invalid(format!(
19656                    "local purge names unknown table {:?}",
19657                    target.table
19658                ))
19659            })?;
19660            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19661                return Err(invalid(format!(
19662                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19663                    target.table
19664                )));
19665            }
19666            let mut selectors = Vec::with_capacity(target.selectors.len());
19667            let mut canonical_selectors = BTreeMap::new();
19668            for (column_name, raw_values) in &target.selectors {
19669                let Some((column_index, column)) = table
19670                    .columns
19671                    .iter()
19672                    .enumerate()
19673                    .find(|(_, column)| column.name == *column_name)
19674                else {
19675                    return Err(invalid(format!(
19676                        "local purge target {:?} names unknown column {:?}",
19677                        target.table, column_name
19678                    )));
19679                };
19680                let encrypted = table
19681                    .encrypted_columns
19682                    .iter()
19683                    .any(|candidate| candidate.index == column_index);
19684                if column.ty != ColumnType::String || encrypted {
19685                    return Err(invalid(format!(
19686                        "local purge selector {:?}.{:?} must be a plaintext string column",
19687                        target.table, column_name
19688                    )));
19689                }
19690                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19691                    return Err(invalid(format!(
19692                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19693                        target.table, column_name
19694                    )));
19695                }
19696                let mut values = raw_values.clone();
19697                values.sort();
19698                values.dedup();
19699                if values.iter().any(|value| {
19700                    value.is_empty()
19701                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19702                        || !is_local_operation_code_like(value)
19703                }) {
19704                    return Err(invalid(format!(
19705                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19706                    )));
19707                }
19708                selectors.push((column_name.clone(), values.clone()));
19709                canonical_selectors.insert(column_name.clone(), values);
19710            }
19711            let canonical = LocalDataPurgeTarget {
19712                table: target.table.clone(),
19713                selectors: canonical_selectors,
19714            };
19715            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19716            deduplicated.insert(
19717                key,
19718                (
19719                    CompiledLocalDataPurgeTarget {
19720                        table: target.table.clone(),
19721                        selectors,
19722                    },
19723                    canonical,
19724                ),
19725            );
19726        }
19727        let targets = deduplicated
19728            .values()
19729            .map(|(compiled, _)| compiled.clone())
19730            .collect::<Vec<_>>();
19731        let canonical = deduplicated
19732            .values()
19733            .map(|(_, target)| target.clone())
19734            .collect::<Vec<_>>();
19735        let canonical_plan =
19736            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19737        Ok((targets, canonical_plan))
19738    }
19739
19740    fn local_purge_base_row_ids(
19741        &self,
19742        targets: &[CompiledLocalDataPurgeTarget],
19743    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19744        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19745        for target in targets {
19746            let table = self
19747                .schema
19748                .table(&target.table)
19749                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19750            let mut clauses = Vec::with_capacity(target.selectors.len());
19751            let mut params = Vec::new();
19752            for (column, values) in &target.selectors {
19753                clauses.push(format!(
19754                    "{} IN ({})",
19755                    quote_ident(column),
19756                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19757                ));
19758                params.extend(values.iter().cloned().map(SqlValue::Text));
19759            }
19760            let sql = format!(
19761                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19762                quote_ident(&table.primary_key),
19763                base_table(&target.table),
19764                clauses.join(" AND ")
19765            );
19766            let mut statement = self
19767                .conn
19768                .prepare(&sql)
19769                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19770            let rows = statement
19771                .query_map(rusqlite::params_from_iter(params), |row| {
19772                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19773                    render_row_id_json(Some(&value))
19774                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19775                })
19776                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19777            let ids = by_table.entry(target.table.clone()).or_default();
19778            for row in rows {
19779                ids.insert(
19780                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19781                );
19782            }
19783        }
19784        Ok(by_table)
19785    }
19786
19787    fn local_purge_values_match(
19788        target: &CompiledLocalDataPurgeTarget,
19789        values: &Map<String, Value>,
19790    ) -> bool {
19791        target.selectors.iter().all(|(column, allowed)| {
19792            values
19793                .get(column)
19794                .and_then(Value::as_str)
19795                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19796        })
19797    }
19798
19799    /// Apply one host-authorized local security purge. The host MUST gate the
19800    /// corresponding subscriptions first; this primitive owns local SQLite
19801    /// cleanup and never grants/revokes server authority by itself.
19802    pub fn purge_local_data(
19803        &mut self,
19804        input: &LocalDataPurgeInput,
19805    ) -> Result<LocalDataPurgeResult, String> {
19806        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19807        let meta_key = format!("localPurge:{}", input.purge_id);
19808        let applied_plan = self
19809            .conn
19810            .query_row(
19811                "SELECT value FROM _syncular_meta WHERE key = ?1",
19812                rusqlite::params![meta_key],
19813                |row| row.get::<_, String>(0),
19814            )
19815            .optional()
19816            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19817        if let Some(applied_plan) = applied_plan {
19818            if applied_plan != canonical_plan {
19819                return Err(format!(
19820                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
19821                    input.purge_id
19822                ));
19823            }
19824            return Ok(LocalDataPurgeResult {
19825                already_applied: true,
19826                purged_rows: 0,
19827                dropped_commits: 0,
19828            });
19829        }
19830
19831        // RFC 0005 D7: the container file cannot be a purge target (its name is
19832        // not in the running schema), so purge drops it as a fixed step, before
19833        // any mirror row is touched and unconditionally.
19834        let _ = self.drop_previous_version();
19835
19836        let prior_outbox = self.outbox.clone();
19837        let prior_failed = self.failed_commits.clone();
19838        let prior_rejections = self.rejections.clone();
19839        let prior_conflicts = self.conflicts.clone();
19840        let prior_overlay_dirty = self.overlay_dirty.snapshot();
19841        self.begin_observation("syncular_local_purge")?;
19842        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
19843            let row_ids = self.local_purge_base_row_ids(&targets)?;
19844            let retained_dropped = self.drop_failed_commits(|table, values| {
19845                targets.iter().any(|target| {
19846                    target.table == table && Self::local_purge_values_match(target, values)
19847                })
19848            })?;
19849            let doomed = self
19850                .outbox
19851                .iter()
19852                .filter(|commit| {
19853                    commit.ops.iter().any(|operation| {
19854                        let matching_targets = targets
19855                            .iter()
19856                            .filter(|target| target.table == operation.table)
19857                            .collect::<Vec<_>>();
19858                        if matching_targets.is_empty() {
19859                            return false;
19860                        }
19861                        if row_ids
19862                            .get(&operation.table)
19863                            .is_some_and(|ids| ids.contains(&operation.row_id))
19864                        {
19865                            return true;
19866                        }
19867                        operation.values.as_ref().is_some_and(|values| {
19868                            matching_targets
19869                                .iter()
19870                                .any(|target| Self::local_purge_values_match(target, values))
19871                        })
19872                    })
19873                })
19874                .cloned()
19875                .collect::<Vec<_>>();
19876            let doomed_ids = doomed
19877                .iter()
19878                .map(|commit| commit.client_commit_id.clone())
19879                .collect::<BTreeSet<_>>();
19880            let mut batch = ChangeAccumulator::default();
19881            for commit in &retained_dropped {
19882                batch.conflicts = true;
19883                batch.rejections = true;
19884                batch.outcomes = true;
19885                for operation in &commit.ops {
19886                    batch.table(&operation.table);
19887                }
19888            }
19889            let mut rejections = Vec::new();
19890            for commit in &doomed {
19891                for operation in &commit.ops {
19892                    batch.table(&operation.table);
19893                }
19894                let results = commit
19895                    .ops
19896                    .iter()
19897                    .enumerate()
19898                    .map(|(op_index, operation)| {
19899                        let rejection = RejectionRecord {
19900                            client_commit_id: commit.client_commit_id.clone(),
19901                            op_index: op_index as i32,
19902                            code: "client.local_data_purged".to_owned(),
19903                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
19904                            retryable: false,
19905                            details: None,
19906                            operation: Some(CommitOperation::from(operation)),
19907                        };
19908                        rejections.push(rejection.clone());
19909                        CommitOperationOutcome::Error { rejection }
19910                    })
19911                    .collect::<Vec<_>>();
19912                self.persist_commit_outcome(
19913                    &commit.client_commit_id,
19914                    CommitOutcomeStatus::Rejected,
19915                    &results,
19916                    Some(&commit.ops),
19917                )?;
19918                self.delete_outbox_persisted(&commit.client_commit_id)?;
19919            }
19920            if !doomed.is_empty() {
19921                self.outbox
19922                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
19923                self.rejections.extend(rejections);
19924                self.prune_commit_outcomes()?;
19925                batch.status = true;
19926                batch.rejections = true;
19927                batch.outcomes = true;
19928            }
19929
19930            let mut purged_rows = 0usize;
19931            for (table, ids) in &row_ids {
19932                if ids.is_empty() {
19933                    continue;
19934                }
19935                batch.table(table);
19936                for row_id in ids {
19937                    self.delete_base_row(table, row_id)?;
19938                    purged_rows += 1;
19939                }
19940            }
19941            self.rebuild_overlay_if_dirty()?;
19942            self.delete_unreferenced_cached_blobs()?;
19943            self.conn
19944                .execute(
19945                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19946                    rusqlite::params![meta_key, canonical_plan],
19947                )
19948                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19949            Ok((
19950                batch,
19951                LocalDataPurgeResult {
19952                    already_applied: false,
19953                    purged_rows,
19954                    dropped_commits: doomed.len(),
19955                },
19956            ))
19957        })();
19958        let (batch, result) = match applied {
19959            Ok(value) => value,
19960            Err(error) => {
19961                self.rollback_observation("syncular_local_purge");
19962                self.outbox = prior_outbox;
19963                self.failed_commits = prior_failed;
19964                self.rejections = prior_rejections;
19965                self.conflicts = prior_conflicts;
19966                self.overlay_dirty.restore(prior_overlay_dirty.clone());
19967                return Err(error);
19968            }
19969        };
19970        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19971            self.rollback_observation("syncular_local_purge");
19972            self.outbox = prior_outbox;
19973            self.failed_commits = prior_failed;
19974            self.rejections = prior_rejections;
19975            self.conflicts = prior_conflicts;
19976            self.overlay_dirty.restore(prior_overlay_dirty.clone());
19977            return Err(error);
19978        }
19979        self.cancel_sync_round();
19980        Ok(result)
19981    }
19982
19983    /// Application-authorized recovery of the replicated projection. Unlike a
19984    /// local security purge, this retains the entire outbox, device identity,
19985    /// lease, outcomes, subscription registrations, and protected bookkeeping.
19986    /// The projection reset, subscription rewind, optimistic replay, and
19987    /// idempotency marker share one savepoint for interruption safety.
19988    pub fn rebootstrap_local_data(
19989        &mut self,
19990        input: &LocalDataRebootstrapInput,
19991    ) -> Result<LocalDataRebootstrapResult, String> {
19992        if input.rebootstrap_id.is_empty()
19993            || input.rebootstrap_id.len() > 128
19994            || !is_local_operation_code_like(&input.rebootstrap_id)
19995        {
19996            return Err(
19997                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19998                    .to_owned(),
19999            );
20000        }
20001        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20002        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20003            let (retained_commits, reset_subscriptions) =
20004                decode_local_rebootstrap_receipt(&persisted)?;
20005            return Ok(LocalDataRebootstrapResult {
20006                already_applied: true,
20007                retained_commits,
20008                reset_subscriptions,
20009            });
20010        }
20011        if self.stopped || self.schema_floor.is_some() {
20012            return Err(
20013                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20014                    .to_owned(),
20015            );
20016        }
20017
20018        let retained_commits = self.outbox.len();
20019        let reset_subscriptions = self.subs.len();
20020        let prior_subs = self.subs.clone();
20021        let prior_upgrading = self.upgrading;
20022        let prior_stopped = self.stopped;
20023        let prior_schema_floor = self.schema_floor.clone();
20024        let prior_overlay_dirty = self.overlay_dirty.snapshot();
20025        let prior_sync_needed = self.sync_needed;
20026        let prior_sync_intents = self.sync_intent_queue.clone();
20027        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20028
20029        let prior_active_round = self.active_round;
20030        self.begin_observation("syncular_local_rebootstrap")?;
20031        let mut batch = ChangeAccumulator::default();
20032        let applied = (|| -> Result<(), String> {
20033            self.run_schema_reset_observed(&mut batch, false)?;
20034            self.conn
20035                .execute(
20036                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20037                    rusqlite::params![meta_key, receipt],
20038                )
20039                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20040            self.sync_needed = true;
20041            self.sync_intent_queue.push_back(SyncIntent::Interactive);
20042            batch.status = true;
20043            Ok(())
20044        })();
20045        if let Err(error) = applied {
20046            self.rollback_observation("syncular_local_rebootstrap");
20047            self.active_round = prior_active_round;
20048            self.subs = prior_subs;
20049            self.upgrading = prior_upgrading;
20050            self.stopped = prior_stopped;
20051            self.schema_floor = prior_schema_floor;
20052            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20053            self.sync_needed = prior_sync_needed;
20054            self.sync_intent_queue = prior_sync_intents;
20055            return Err(error);
20056        }
20057        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20058            self.rollback_observation("syncular_local_rebootstrap");
20059            self.active_round = prior_active_round;
20060            self.subs = prior_subs;
20061            self.upgrading = prior_upgrading;
20062            self.stopped = prior_stopped;
20063            self.schema_floor = prior_schema_floor;
20064            self.overlay_dirty.restore(prior_overlay_dirty.clone());
20065            self.sync_needed = prior_sync_needed;
20066            self.sync_intent_queue = prior_sync_intents;
20067            return Err(error);
20068        }
20069
20070        Ok(LocalDataRebootstrapResult {
20071            already_applied: false,
20072            retained_commits,
20073            reset_subscriptions,
20074        })
20075    }
20076
20077    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
20078
20079    /// Delete base rows matching the effective scopes; `Err(())` = no local
20080    /// scope-column mapping for a key (the fail-closed case).
20081    fn purge_scope_rows(
20082        &mut self,
20083        table_name: &str,
20084        effective: &[(String, Vec<String>)],
20085    ) -> Result<(), ()> {
20086        if effective.is_empty() {
20087            return Ok(());
20088        }
20089        let table = self.schema.table(table_name).ok_or(())?.clone();
20090        let mut clauses = Vec::new();
20091        let mut params: Vec<SqlValue> = Vec::new();
20092        for (variable, values) in effective {
20093            let column = table.scope_column(variable).ok_or(())?;
20094            let placeholders: Vec<String> = values
20095                .iter()
20096                .map(|v| {
20097                    params.push(SqlValue::Text(v.clone()));
20098                    "?".to_owned()
20099                })
20100                .collect();
20101            clauses.push(format!(
20102                "{} IN ({})",
20103                quote_ident(column),
20104                placeholders.join(", ")
20105            ));
20106        }
20107        let sql = format!(
20108            "DELETE FROM {} WHERE {}",
20109            base_table(table_name),
20110            clauses.join(" AND ")
20111        );
20112        self.overlay_dirty.table(table_name);
20113        self.conn
20114            .execute(&sql, rusqlite::params_from_iter(&params))
20115            .map_err(|_| ())?;
20116        self.conn
20117            .execute(
20118                &format!(
20119                    "DELETE FROM {} WHERE {}",
20120                    visible_table(table_name),
20121                    clauses.join(" AND ")
20122                ),
20123                rusqlite::params_from_iter(&params),
20124            )
20125            .map_err(|_| ())?;
20126        Ok(())
20127    }
20128
20129    /// §3.3: drop pending commits whose upserts provably land in the
20130    /// revoked effective scopes — whole-commit, never per-operation.
20131    fn drop_doomed_outbox(
20132        &mut self,
20133        table_name: &str,
20134        effective: &[(String, Vec<String>)],
20135    ) -> Result<bool, String> {
20136        if effective.is_empty() {
20137            return Ok(false);
20138        }
20139        let Some(table) = self.schema.table(table_name).cloned() else {
20140            return Ok(false);
20141        };
20142        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20143        for (variable, values) in effective {
20144            match table.scope_column(variable) {
20145                Some(column) => mappings.push((column, values)),
20146                None => return Ok(false), // not provable without a mapping
20147            }
20148        }
20149        let doomed: Vec<OutboxCommit> = self
20150            .outbox
20151            .iter()
20152            .filter(|commit| {
20153                commit.ops.iter().any(|op| {
20154                    op.upsert
20155                        && op.table == table_name
20156                        && op.values.as_ref().is_some_and(|values| {
20157                            mappings
20158                                .iter()
20159                                .all(|(column, allowed)| match values.get(*column) {
20160                                    Some(Value::String(s)) => allowed.contains(s),
20161                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20162                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20163                                    _ => false,
20164                                })
20165                        })
20166                })
20167            })
20168            .cloned()
20169            .collect();
20170        if doomed.is_empty() {
20171            return Ok(false);
20172        }
20173        let mut rejections = Vec::new();
20174        for commit in &doomed {
20175            let results = commit
20176                .ops
20177                .iter()
20178                .enumerate()
20179                .map(|(op_index, operation)| {
20180                    let rejection = RejectionRecord {
20181                        client_commit_id: commit.client_commit_id.clone(),
20182                        op_index: op_index as i32,
20183                        code: "sync.scope_revoked".to_owned(),
20184                        message: "the commit was dropped because its effective scope was revoked"
20185                            .to_owned(),
20186                        retryable: false,
20187                        details: None,
20188                        operation: Some(CommitOperation::from(operation)),
20189                    };
20190                    rejections.push(rejection.clone());
20191                    CommitOperationOutcome::Error { rejection }
20192                })
20193                .collect::<Vec<_>>();
20194            self.persist_commit_outcome(
20195                &commit.client_commit_id,
20196                CommitOutcomeStatus::Rejected,
20197                &results,
20198                Some(&commit.ops),
20199            )?;
20200            self.delete_outbox_persisted(&commit.client_commit_id)?;
20201        }
20202        self.prune_commit_outcomes()?;
20203        let doomed_ids = doomed
20204            .iter()
20205            .map(|commit| commit.client_commit_id.as_str())
20206            .collect::<BTreeSet<_>>();
20207        self.outbox
20208            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20209        self.rejections.extend(rejections);
20210        Ok(true)
20211    }
20212
20213    // -- blobs (§5.9) ----------------------------------------------------------------
20214
20215    /// §5.9.7: hash bytes into the content address, cache them, queue the
20216    /// upload (flushed before the next push, B4). Returns the canonical
20217    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
20218    /// column value.
20219    pub fn upload_blob(
20220        &mut self,
20221        bytes: &[u8],
20222        media_type: Option<String>,
20223        name: Option<String>,
20224    ) -> Result<Value, String> {
20225        let blob_id = blob_id_for(bytes);
20226        let now = self.clock_now_ms();
20227        let tx = self
20228            .conn
20229            .transaction()
20230            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20231        tx.execute(
20232            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20233             ON CONFLICT(blob_id) DO NOTHING",
20234            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20235        )
20236        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20237        tx.execute(
20238            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20239            rusqlite::params![blob_id, media_type, now],
20240        )
20241        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20242        tx.commit()
20243            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20244        self.enforce_blob_cache_cap();
20245        let mut obj = Map::new();
20246        obj.insert("blobId".to_owned(), Value::from(blob_id));
20247        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20248        if let Some(mt) = media_type {
20249            obj.insert("mediaType".to_owned(), Value::from(mt));
20250        }
20251        if let Some(n) = name {
20252            obj.insert("name".to_owned(), Value::from(n));
20253        }
20254        Ok(Value::Object(obj))
20255    }
20256
20257    /// §5.9.7: resolve an owned blob body without driver encoding. A
20258    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
20259    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
20260    pub fn fetch_blob_bytes(
20261        &mut self,
20262        transport: &mut dyn Transport,
20263        blob_id_or_ref: &str,
20264    ) -> Result<FetchedBlob, (String, String)> {
20265        let simple = |m: String| ("client.failed".to_owned(), m);
20266        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20267            blob_id_or_ref.to_owned()
20268        } else {
20269            let value: Value = serde_json::from_str(blob_id_or_ref)
20270                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20271            value
20272                .get("blobId")
20273                .and_then(Value::as_str)
20274                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20275                .to_owned()
20276        };
20277        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20278            return Ok(cached);
20279        }
20280        if !self.transport_enabled {
20281            return Err(("sync.offline".into(), "transport gate is closed".into()));
20282        }
20283        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
20284        // blob.not_found) verbatim so the harness can assert on it. The
20285        // authorized endpoint serves bytes inline OR (always-issue, presign
20286        // configured) a signed url the client fetches directly — no host auth,
20287        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
20288        #[cfg(feature = "bench-internals")]
20289        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20290        let bytes = match transport
20291            .blob_download(&blob_id)
20292            .map_err(|e| (e.code, e.message))?
20293        {
20294            BlobDownload::Bytes(bytes) => bytes,
20295            BlobDownload::Url {
20296                url,
20297                url_expires_at_ms,
20298            } => {
20299                // §5.9.5: MUST NOT start a fetch at/past expiry.
20300                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20301                    return Err((
20302                        "sync.segment_expired".to_owned(),
20303                        format!(
20304                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20305                        ),
20306                    ));
20307                }
20308                transport
20309                    .fetch_blob_url(&url)
20310                    .map_err(|e| (e.code, e.message))?
20311            }
20312        };
20313        #[cfg(feature = "bench-internals")]
20314        drop(download_phase);
20315        #[cfg(feature = "bench-internals")]
20316        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20317        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
20318        if blob_id_for(&bytes) != blob_id {
20319            return Err(simple(format!(
20320                "blob content address mismatch for {blob_id}"
20321            )));
20322        }
20323        #[cfg(feature = "bench-internals")]
20324        drop(validate_phase);
20325        #[cfg(feature = "bench-internals")]
20326        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20327        let now = self.clock_now_ms();
20328        self.conn
20329            .execute(
20330                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20331                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20332            )
20333            .map_err(|e| simple(e.to_string()))?;
20334        self.conn
20335            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20336            .map_err(|e| simple(e.to_string()))?;
20337        self.enforce_blob_cache_cap();
20338        #[cfg(feature = "bench-internals")]
20339        drop(insert_phase);
20340        self.get_cached_blob_bytes(&blob_id)
20341            .map_err(simple)?
20342            .ok_or_else(|| simple("blob cache write failed".to_owned()))
20343    }
20344
20345    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20346        #[cfg(feature = "bench-internals")]
20347        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20348        let mut stmt = self
20349            .conn
20350            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20351            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20352        let mut rows = stmt
20353            .query(rusqlite::params![blob_id])
20354            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20355        if let Some(row) = rows
20356            .next()
20357            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20358        {
20359            let bytes: Vec<u8> = row
20360                .get(0)
20361                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20362            let byte_length: i64 = row
20363                .get(1)
20364                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20365            let media_type: Option<String> = row
20366                .get(2)
20367                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20368            return Ok(Some(FetchedBlob {
20369                blob_id: blob_id.to_owned(),
20370                byte_length,
20371                bytes,
20372                media_type,
20373            }));
20374        }
20375        Ok(None)
20376    }
20377
20378    /// §5.9.7 B4: upload every queued blob before push.
20379    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20380        let pending: Vec<(String, Option<String>)> = {
20381            let mut stmt = self
20382                .conn
20383                .prepare(
20384                    "SELECT blob_id, media_type FROM (
20385                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20386                      UNION ALL
20387                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20388                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20389                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20390                    ) ORDER BY created_at_ms, blob_id",
20391                )
20392                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20393            let rows = stmt
20394                .query_map([], |row| {
20395                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20396                })
20397                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20398            rows.collect::<Result<_, _>>()
20399                .map_err(|error| match error {
20400                    rusqlite::Error::InvalidColumnType(..)
20401                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20402                        "sync.local_corrupt",
20403                        "Pending blob upload metadata is invalid",
20404                    ),
20405                    _ => TransportError::new("client.failed", error.to_string()),
20406                })?
20407        };
20408        let mut uploads = Vec::new();
20409        for (blob_id, media_type) in pending {
20410            let bytes = (|| {
20411                let stored: Option<(SqlValue, SqlValue)> = self
20412                    .conn
20413                    .query_row(
20414                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20415                        rusqlite::params![blob_id],
20416                        |row| Ok((row.get(0)?, row.get(1)?)),
20417                    )
20418                    .optional()
20419                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20420                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20421                    return Err(TransportError::new(
20422                        "sync.local_corrupt",
20423                        "Pending blob upload body is missing or corrupt",
20424                    ));
20425                };
20426                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20427                    return Err(TransportError::new(
20428                        "sync.local_corrupt",
20429                        "Pending blob upload body is missing or corrupt",
20430                    ));
20431                }
20432                Ok(bytes)
20433            })();
20434            let failed = bytes.is_err();
20435            uploads.push(crate::round::PendingUpload {
20436                id: blob_id,
20437                bytes,
20438                media_type,
20439            });
20440            if failed {
20441                break;
20442            }
20443        }
20444        Ok(uploads)
20445    }
20446
20447    fn visible_blob_ids_sql(&self) -> String {
20448        let mut selects = Vec::new();
20449        for table in &self.schema.tables {
20450            for column in table
20451                .columns
20452                .iter()
20453                .filter(|column| column.ty == ColumnType::BlobRef)
20454            {
20455                let identifier = quote_ident(&column.name);
20456                selects.push(format!(
20457                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20458                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20459                     AND json_type({identifier}, '$.blobId') = 'text'",
20460                    quote_ident(&table.name),
20461                ));
20462            }
20463        }
20464        if selects.is_empty() {
20465            "SELECT NULL AS blob_id WHERE 0".to_owned()
20466        } else {
20467            selects.join(" UNION ")
20468        }
20469    }
20470
20471    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
20472    /// staged uploads pin bodies directly; no derived refcount is stored.
20473    fn enforce_blob_cache_cap(&self) {
20474        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20475            return;
20476        };
20477        let mut total: i64 = self
20478            .conn
20479            .query_row(
20480                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20481                [],
20482                |row| row.get(0),
20483            )
20484            .unwrap_or(0);
20485        if total <= max_bytes {
20486            return;
20487        }
20488        let query = format!(
20489            "SELECT blob_id, byte_length FROM _syncular_blobs
20490             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20491               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20492               AND blob_id NOT IN ({})
20493             ORDER BY created_at_ms ASC, blob_id ASC",
20494            self.visible_blob_ids_sql(),
20495        );
20496        let candidates: Vec<(String, i64)> = {
20497            let Ok(mut stmt) = self.conn.prepare(&query) else {
20498                return;
20499            };
20500            let Ok(rows) = stmt.query_map([], |row| {
20501                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20502            }) else {
20503                return;
20504            };
20505            rows.filter_map(Result::ok).collect()
20506        };
20507        for (blob_id, byte_length) in candidates {
20508            if total <= max_bytes {
20509                break;
20510            }
20511            let _ = self.conn.execute(
20512                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20513                rusqlite::params![blob_id],
20514            );
20515            total -= byte_length;
20516        }
20517    }
20518
20519    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20520        #[cfg(feature = "bench-internals")]
20521        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20522        if !self.schema_has_blobs() {
20523            return Ok(());
20524        }
20525        self.rebuild_overlay_if_dirty()?;
20526        self.conn
20527            .execute(
20528                &format!(
20529                    "DELETE FROM _syncular_blobs
20530                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20531                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20532                   AND blob_id NOT IN ({})",
20533                    self.visible_blob_ids_sql(),
20534                ),
20535                [],
20536            )
20537            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20538        Ok(())
20539    }
20540
20541    // -- local row storage ----------------------------------------------------------
20542
20543    /// The cached per-table primary-key upsert SQL for `full_table` (see
20544    /// the `insert_sql` field: built once, reused per row).
20545    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20546        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20547            return sql.clone();
20548        }
20549        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20550        columns.push(quote_ident("_syncular_version"));
20551        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20552        let primary_key = quote_ident(&table.primary_key);
20553        let updates = columns
20554            .iter()
20555            .filter(|column| **column != primary_key)
20556            .map(|column| format!("{column}=excluded.{column}"))
20557            .collect::<Vec<_>>()
20558            .join(", ");
20559        let sql = format!(
20560            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20561            columns.join(", "),
20562            placeholders.join(", ")
20563        );
20564        self.insert_sql
20565            .borrow_mut()
20566            .insert(full_table.to_owned(), sql.clone());
20567        sql
20568    }
20569
20570    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20571        self.overlay_dirty.table(table_name);
20572        self.write_row(&base_table(table_name), table_name, row, version)
20573    }
20574
20575    fn write_row(
20576        &self,
20577        full_table: &str,
20578        table_name: &str,
20579        row: &Row,
20580        version: i64,
20581    ) -> Result<(), String> {
20582        #[cfg(feature = "bench-internals")]
20583        let _phase = self.benchmark_phases.start(Phase::RowWrite);
20584        let table = self
20585            .schema
20586            .table(table_name)
20587            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20588        let sql = self.insert_row_sql(full_table, table);
20589        let mut stmt = self
20590            .conn
20591            .prepare_cached(&sql)
20592            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20593        let params = row
20594            .iter()
20595            .map(RowParam::Cell)
20596            .chain(std::iter::once(RowParam::Version(version)));
20597        stmt.execute(rusqlite::params_from_iter(params))
20598            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20599        Ok(())
20600    }
20601
20602    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20603        let table = self
20604            .schema
20605            .table(table_name)
20606            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20607        self.overlay_dirty.table(table_name);
20608        let sql = format!(
20609            "DELETE FROM {} WHERE {}",
20610            base_table(table_name),
20611            row_id_predicate(table)
20612        );
20613        let mut stmt = self
20614            .conn
20615            .prepare_cached(&sql)
20616            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20617        stmt.execute(rusqlite::params![row_id])
20618            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20619        Ok(())
20620    }
20621
20622    fn has_acknowledged_rows(&self) -> bool {
20623        self.conn
20624            .query_row(
20625                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20626                [],
20627                |row| row.get(0),
20628            )
20629            .expect("read acknowledged intent")
20630    }
20631
20632    fn acknowledged_operations(
20633        &self,
20634        rows: Option<&HashSet<(&str, &str)>>,
20635        tables: Option<&BTreeSet<&str>>,
20636    ) -> Result<Vec<OutboxOp>, String> {
20637        let keys = rows.map(|rows| {
20638            serde_json::to_string(
20639                &rows
20640                    .iter()
20641                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20642                    .collect::<Vec<_>>(),
20643            )
20644            .expect("row keys")
20645        });
20646        let mut statement = self
20647            .conn
20648            .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")
20649            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20650        let rows = statement
20651            .query_map(
20652                rusqlite::params![
20653                    keys,
20654                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20655                ],
20656                |row| {
20657                    #[cfg(test)]
20658                    self.acknowledged_replay_count
20659                        .set(self.acknowledged_replay_count.get() + 1);
20660                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20661                },
20662            )
20663            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20664        rows.map(|row| {
20665            let (op, intent) =
20666                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20667            let op: CommitOperation =
20668                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20669            let table = self
20670                .schema
20671                .table(&op.table)
20672                .ok_or_else(|| "sync.unknown_table".to_owned())?;
20673            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20674                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20675            } else {
20676                op.values
20677            };
20678            Ok(OutboxOp {
20679                table: op.table,
20680                row_id: op.row_id,
20681                upsert: op.op == "upsert",
20682                base_version: op.base_version,
20683                values,
20684            })
20685        })
20686        .collect()
20687    }
20688
20689    fn drop_acknowledged_rows(
20690        &self,
20691        matches: impl Fn(&str, &Map<String, Value>) -> bool,
20692    ) -> Result<Vec<OutboxCommit>, String> {
20693        let mut statement = self
20694            .conn
20695            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20696            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20697        let rows = statement
20698            .query_map([], |row| {
20699                Ok((
20700                    row.get::<_, String>(0)?,
20701                    row.get::<_, String>(1)?,
20702                    row.get::<_, String>(2)?,
20703                    row.get::<_, String>(3)?,
20704                ))
20705            })
20706            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20707        let mut doomed = HashSet::new();
20708        for row in rows {
20709            let (id, table, intent, row_id) =
20710                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20711            let values =
20712                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20713            let base_matches = self
20714                .schema
20715                .table(&table)
20716                .map(|schema| self.stored_row(schema, &row_id, true))
20717                .transpose()?
20718                .flatten()
20719                .is_some_and(|(base, _)| {
20720                    let schema = self.schema.table(&table).expect("acknowledged table");
20721                    let values = schema
20722                        .columns
20723                        .iter()
20724                        .zip(base.iter())
20725                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20726                        .collect();
20727                    matches(&table, &values)
20728                });
20729            if matches(&table, &values) || base_matches {
20730                doomed.insert(id);
20731            }
20732        }
20733        let mut dropped = Vec::new();
20734        for id in doomed {
20735            let mut statement = self
20736                .conn
20737                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20738                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20739            let operations = statement
20740                .query_map([&id], |row| row.get::<_, String>(0))
20741                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20742            let mut ops = Vec::new();
20743            for operation in operations {
20744                let json = operation
20745                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20746                let op: CommitOperation =
20747                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20748                self.overlay_dirty.table(&op.table);
20749                ops.push(OutboxOp {
20750                    table: op.table,
20751                    row_id: op.row_id,
20752                    upsert: op.op == "upsert",
20753                    base_version: op.base_version,
20754                    values: op.values,
20755                });
20756            }
20757            dropped.push(OutboxCommit {
20758                client_commit_id: id.clone(),
20759                ops,
20760            });
20761            self.conn
20762                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20763                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20764        }
20765        Ok(dropped)
20766    }
20767
20768    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
20769    /// the outbox changed since the last rebuild (the no-op sync round).
20770    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20771        if self.overlay_dirty.get() {
20772            self.rebuild_overlay()?;
20773        }
20774        Ok(())
20775    }
20776
20777    /// §7.1: local reads see outbox state applied optimistically — rebuild
20778    /// every visible table as (base server state) + (pending outbox replay
20779    /// on top). Optimistic rows carry version `-1`.
20780    fn rebuild_overlay(&mut self) -> Result<(), String> {
20781        #[cfg(feature = "bench-internals")]
20782        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20783        #[cfg(test)]
20784        self.overlay_rebuild_count
20785            .set(self.overlay_rebuild_count.get() + 1);
20786        let dirty = self.overlay_dirty.snapshot();
20787        let tables: Vec<_> = self
20788            .schema
20789            .tables
20790            .iter()
20791            .filter(|table| {
20792                dirty
20793                    .as_ref()
20794                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20795            })
20796            .cloned()
20797            .collect();
20798        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20799        self.begin_observation("syncular_overlay")?;
20800        let rebuilt = (|| {
20801            for table in &tables {
20802                for index in &table.fts_indexes {
20803                    self.drop_fts_triggers(index)?;
20804                }
20805                let visible = visible_table(&table.name);
20806                let base = base_table(&table.name);
20807                self.conn
20808                    .execute_batch(&format!(
20809                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20810                    ))
20811                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20812            }
20813            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20814            self.apply_outbox_ops(
20815                acknowledged
20816                    .iter()
20817                    .filter(|op| names.contains(op.table.as_str())),
20818            )?;
20819            for (commit, initial) in &self.failed_commits {
20820                for operation in &commit.ops {
20821                    if !names.contains(operation.table.as_str()) || !operation.upsert {
20822                        continue;
20823                    }
20824                    if let Some(table) = self.schema.table(&operation.table) {
20825                        if self.visible_row(table, &operation.row_id)?.is_none()
20826                            && operation.values.as_ref().is_some_and(|values| {
20827                                table
20828                                    .columns
20829                                    .iter()
20830                                    .all(|column| values.contains_key(&column.name))
20831                            })
20832                        {
20833                            let key = serde_json::to_string(&(
20834                                operation.table.clone(),
20835                                operation.row_id.clone(),
20836                            ))
20837                            .expect("row key serialization");
20838                            if let Some(Value::Object(values)) = initial.get(&key) {
20839                                self.apply_outbox_ops(&[OutboxOp {
20840                                    values: Some(values.clone()),
20841                                    ..operation.clone()
20842                                }])?;
20843                            }
20844                        }
20845                    }
20846                }
20847                self.apply_outbox_ops(
20848                    commit
20849                        .ops
20850                        .iter()
20851                        .filter(|op| names.contains(op.table.as_str())),
20852                )?;
20853            }
20854            for commit in &self.outbox {
20855                self.apply_outbox_ops(
20856                    commit
20857                        .ops
20858                        .iter()
20859                        .filter(|op| names.contains(op.table.as_str())),
20860                )?;
20861            }
20862            for table in &tables {
20863                for index in &table.fts_indexes {
20864                    self.rebuild_fts_projection(table, index)?;
20865                    self.create_fts_triggers(table, index)?;
20866                }
20867            }
20868            self.conn
20869                .execute_batch("RELEASE syncular_overlay")
20870                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20871            Ok(())
20872        })();
20873        if rebuilt.is_err() {
20874            self.conn
20875                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
20876                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20877        } else {
20878            self.overlay_dirty.set(false);
20879        }
20880        rebuilt
20881    }
20882
20883    /// §7.1 replay: every operation is applied over the current visible
20884    /// state. An operation that no longer applies (a server row now holds its
20885    /// unique value) stays pending and invisible until a later replay admits
20886    /// it or the server answers its push.
20887    fn apply_outbox_ops<'a>(
20888        &self,
20889        ops: impl IntoIterator<Item = &'a OutboxOp>,
20890    ) -> Result<(), String> {
20891        #[cfg(feature = "bench-internals")]
20892        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
20893        for op in ops {
20894            #[cfg(feature = "bench-internals")]
20895            if let Some(phase) = &mut phase {
20896                phase.unit();
20897            }
20898            if op.upsert {
20899                if let (Some(table), Some(values)) = (
20900                    self.schema
20901                        .table(&op.table)
20902                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
20903                    op.values.as_ref(),
20904                ) {
20905                    let mut intended: Map<String, Value> = self
20906                        .visible_row(table, &op.row_id)?
20907                        .map(|(row, _)| {
20908                            table
20909                                .columns
20910                                .iter()
20911                                .zip(&row)
20912                                .map(|(column, value)| {
20913                                    (column.name.clone(), column_value_to_json(value))
20914                                })
20915                                .collect()
20916                        })
20917                        .unwrap_or_default();
20918                    intended.extend(values.clone());
20919                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20920                        continue;
20921                    }
20922                }
20923            }
20924            self.apply_outbox_op(op)?;
20925        }
20926        Ok(())
20927    }
20928
20929    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20930        let Some(table) = self.schema.table(&op.table) else {
20931            return Ok(());
20932        };
20933        let visible = visible_table(&table.name);
20934        if !op.upsert {
20935            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20936            self.conn
20937                .prepare_cached(&sql)
20938                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20939                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20940            return Ok(());
20941        }
20942        let Some(values) = op.values.as_ref() else {
20943            return Ok(());
20944        };
20945        // §7.1: the overlay applies the operation's PRESENT columns
20946        // over the current local row. A partial operation over an
20947        // absent local row leaves it absent — the server answers it
20948        // with sync.row_deleted or sync.row_missing (§6.2).
20949        let full = table
20950            .columns
20951            .iter()
20952            .all(|column| values.contains_key(&column.name));
20953        let local = self.visible_row(table, &op.row_id)?;
20954        if local.is_none() && !full {
20955            return Ok(());
20956        }
20957        let mut row: Row = Vec::with_capacity(table.columns.len());
20958        for (index, column) in table.columns.iter().enumerate() {
20959            let incoming = match values.get(&column.name) {
20960                Some(raw) => json_to_column_value(
20961                    if raw.get("$bytes").is_some()
20962                        && table
20963                            .encrypted_columns
20964                            .iter()
20965                            .any(|encrypted| encrypted.index == index)
20966                    {
20967                        &table.wire_columns[index]
20968                    } else {
20969                        column
20970                    },
20971                    Some(raw),
20972                ),
20973                None => Ok(local
20974                    .as_ref()
20975                    .and_then(|(values, _)| values.get(index).cloned())
20976                    .flatten()),
20977            };
20978            row.push(incoming.map_err(|_| {
20979                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20980            })?);
20981        }
20982        let version = local.as_ref().map_or(-1, |(_, version)| *version);
20983        self.write_row(&visible, &table.name, &row, version)
20984            .map_err(|error| {
20985                if error.starts_with("UNIQUE constraint failed:") {
20986                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
20987                } else {
20988                    error
20989                }
20990            })
20991    }
20992
20993    /// The visible (optimistic) row's values plus version, or `None` when the
20994    /// row is absent from the overlay.
20995    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20996        self.stored_row(table, row_id, false)
20997    }
20998
20999    fn stored_row(
21000        &self,
21001        table: &TableSchema,
21002        row_id: &str,
21003        base: bool,
21004    ) -> Result<Option<(Row, i64)>, String> {
21005        let sql = format!(
21006            "SELECT * FROM {} WHERE {}",
21007            if base {
21008                base_table(&table.name)
21009            } else {
21010                visible_table(&table.name)
21011            },
21012            row_id_predicate(table)
21013        );
21014        let mut stmt = self
21015            .conn
21016            .prepare_cached(&sql)
21017            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21018        let mut rows = stmt
21019            .query(rusqlite::params![row_id])
21020            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21021        let Some(row) = rows
21022            .next()
21023            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21024        else {
21025            return Ok(None);
21026        };
21027        let mut values = Vec::with_capacity(table.columns.len());
21028        for (index, column) in table.columns.iter().enumerate() {
21029            let raw = row
21030                .get_ref(index)
21031                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21032            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21033                if table
21034                    .encrypted_columns
21035                    .iter()
21036                    .any(|encrypted| encrypted.index == index)
21037                {
21038                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21039                    continue;
21040                }
21041            }
21042            let json = sql_ref_to_json(column, raw);
21043            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21044                "sync.local_corrupt: persisted local row value is invalid".to_owned()
21045            })?);
21046        }
21047        let version: i64 = row
21048            .get(table.columns.len())
21049            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21050        Ok(Some((values, version)))
21051    }
21052
21053    // -- realtime (§8) ---------------------------------------------------------------
21054
21055    /// §8.8: the configured realtime policy.
21056    #[must_use]
21057    pub fn realtime_policy(&self) -> RealtimePolicy {
21058        self.realtime_policy
21059    }
21060
21061    /// §8.8: the explicit realtime availability state.
21062    #[must_use]
21063    pub fn realtime_state(&self) -> RealtimeState {
21064        self.realtime_state
21065    }
21066
21067    /// §8.8: set the realtime policy. Refused while the socket is connected:
21068    /// a policy change never silently re-routes an ownership state.
21069    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21070        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21071            return Err(
21072                "sync.invalid_request: disconnect realtime before changing the realtime policy"
21073                    .to_owned(),
21074            );
21075        }
21076        self.realtime_policy = policy;
21077        match policy {
21078            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21079            RealtimePolicy::Required | RealtimePolicy::Optional
21080                if self.realtime_state == RealtimeState::Disabled =>
21081            {
21082                self.set_realtime_state(RealtimeState::Disconnected, None);
21083            }
21084            RealtimePolicy::Required | RealtimePolicy::Optional => {}
21085        }
21086        Ok(())
21087    }
21088
21089    /// Record an availability transition, keeping the reason code bounded and
21090    /// code-like (§7.6) and clearing it where it cannot apply.
21091    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21092        self.realtime_state = state;
21093        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21094        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21095            self.realtime_reason_code = None;
21096            self.realtime_retry_delay_ms = None;
21097        }
21098    }
21099
21100    #[must_use]
21101    fn realtime_connected(&self) -> bool {
21102        self.realtime_state == RealtimeState::Connected
21103    }
21104
21105    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21106        if !self.transport_enabled {
21107            return Err("sync.offline: transport gate is closed".into());
21108        }
21109        if self.realtime_policy == RealtimePolicy::Off {
21110            return Err(
21111                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21112            );
21113        }
21114        if self.realtime_connected() {
21115            return Ok(());
21116        }
21117        self.realtime_state = RealtimeState::Connecting;
21118        match transport.realtime_connect_for_client(&self.client_id) {
21119            Ok(()) => {
21120                self.set_realtime_state(RealtimeState::Connected, None);
21121                Ok(())
21122            }
21123            Err(error) => {
21124                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21125                Err(format!("{}: {}", error.code, error.message))
21126            }
21127        }
21128    }
21129
21130    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21131        self.cancel_sync_round();
21132        // A deliberately disconnected or policy-disabled client has nothing
21133        // to release; a `lost` socket still does (the host owns the handle).
21134        if self.realtime_state == RealtimeState::Disconnected
21135            || self.realtime_state == RealtimeState::Disabled
21136        {
21137            return;
21138        }
21139        let _ = transport.realtime_close();
21140        self.set_realtime_state(
21141            if self.realtime_policy == RealtimePolicy::Off {
21142                RealtimeState::Disabled
21143            } else {
21144                RealtimeState::Disconnected
21145            },
21146            None,
21147        );
21148        self.presence.clear(); // §8.6.1: presence is per-connection
21149    }
21150
21151    /// §8.8: the host observed the socket end without disconnecting it (a
21152    /// peer close, an I/O error, a failed send; the native transport reports
21153    /// these as `Inbound::Disconnected`). Releases the transport, moves a
21154    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
21155    /// one background retry intent so the host reconnects and catches up.
21156    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21157        if self.realtime_state != RealtimeState::Connected {
21158            return;
21159        }
21160        self.cancel_sync_round();
21161        let _ = transport.realtime_close();
21162        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21163        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21164        self.presence.clear();
21165    }
21166
21167    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
21168    /// document for `scope_key`. Requires a live socket; the document is
21169    /// ephemeral (lost on disconnect). Authorization is the connection's
21170    /// registration (§8.6.3) — an unheld key is rejected loudly by the
21171    /// server with `presence.forbidden`.
21172    pub fn set_presence(
21173        &mut self,
21174        transport: &mut dyn Transport,
21175        scope_key: &str,
21176        doc: Option<&Value>,
21177    ) -> Result<(), String> {
21178        if !self.transport_enabled {
21179            return Err("sync.offline: transport gate is closed".into());
21180        }
21181        if !self.realtime_connected() {
21182            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21183        }
21184        let text = encode_presence_publish(scope_key, doc);
21185        transport
21186            .realtime_send(&text)
21187            .map_err(|e| format!("{}: {}", e.code, e.message))
21188    }
21189
21190    /// §8.6: the peers currently present on a scope key (ephemeral).
21191    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21192        self.presence
21193            .get(scope_key)
21194            .map(|peers| peers.values().cloned().collect())
21195            .unwrap_or_default()
21196    }
21197
21198    /// §8.6 apply an inbound presence fanout to the local map.
21199    fn apply_presence(
21200        &mut self,
21201        scope_key: String,
21202        kind: Option<PresenceKind>,
21203        actor_id: Option<String>,
21204        client_id: Option<String>,
21205        doc: Option<Value>,
21206        error: Option<String>,
21207    ) {
21208        // The publisher-directed error variant is out-of-band; nothing to
21209        // record in the peer map.
21210        if error.is_some() {
21211            return;
21212        }
21213        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21214            return;
21215        };
21216        let peer_key = format!("{actor_id} {client_id}");
21217        match kind {
21218            PresenceKind::Leave => {
21219                if let Some(peers) = self.presence.get_mut(&scope_key) {
21220                    peers.remove(&peer_key);
21221                    if peers.is_empty() {
21222                        self.presence.remove(&scope_key);
21223                    }
21224                }
21225            }
21226            _ => {
21227                let doc = match doc {
21228                    Some(Value::Object(_)) => doc.unwrap(),
21229                    _ => return,
21230                };
21231                self.presence.entry(scope_key).or_default().insert(
21232                    peer_key,
21233                    PresencePeer {
21234                        actor_id,
21235                        client_id,
21236                        doc,
21237                    },
21238                );
21239            }
21240        }
21241    }
21242
21243    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
21244    pub fn on_realtime_text(&mut self, text: &str) {
21245        if self.stopped || !self.transport_enabled || self.security_preflight {
21246            return;
21247        }
21248        match parse_control(text) {
21249            Ok(ControlMessage::Hello { requires_sync, .. }) => {
21250                if requires_sync {
21251                    // §8.1: pull before trusting the socket for continuity.
21252                    self.set_sync_needed(true, true);
21253                }
21254            }
21255            Ok(ControlMessage::Presence {
21256                scope_key,
21257                kind,
21258                actor_id,
21259                client_id,
21260                doc,
21261                error,
21262                ..
21263            }) => {
21264                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21265            }
21266            Ok(ControlMessage::Wake { .. }) => {
21267                // §8.3: any wake-up means "run a pull soon", never data.
21268                self.set_sync_needed(true, true);
21269            }
21270            _ => {}
21271        }
21272    }
21273
21274    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
21275    /// a pull response per section; an unapplied delta is a wake-up.
21276    /// Mailbox hosts send durable acknowledgements on their I/O executor.
21277    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21278        let mut transport = crate::round::DownloadResults::default();
21279        self.on_realtime_binary(&mut transport, bytes);
21280        transport.controls
21281    }
21282
21283    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21284        if self.stopped || !self.transport_enabled || self.security_preflight {
21285            return;
21286        }
21287        #[cfg(feature = "bench-internals")]
21288        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21289        let message = match decode_message(bytes) {
21290            Ok(m) if m.msg_kind == MsgKind::Response => m,
21291            _ => {
21292                self.set_sync_needed(true, true);
21293                return;
21294            }
21295        };
21296        #[cfg(feature = "bench-internals")]
21297        drop(decode_phase);
21298        let coalesced_deletes =
21299            coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21300                if status != SubStatus::Active {
21301                    return false;
21302                }
21303                let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21304                    return false;
21305                };
21306                sub.state == SubState::Active
21307                    && sub.bootstrap_state.is_none()
21308                    && sub.synced_once
21309                    && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21310            });
21311        let mut frames = message.frames.into_iter();
21312        let mut applied_cursor: Option<i64> = None;
21313        let mut any_covered = false;
21314        let mut dropped = self.begin_coalesced_apply().is_err();
21315        while let Some(frame) = frames.next() {
21316            let Frame::SubStart {
21317                id,
21318                status,
21319                effective_scopes,
21320                ..
21321            } = frame
21322            else {
21323                continue;
21324            };
21325            let mut body = Vec::new();
21326            let mut next_cursor: Option<i64> = None;
21327            for inner in frames.by_ref() {
21328                match inner {
21329                    Frame::SubEnd {
21330                        next_cursor: nc, ..
21331                    } => {
21332                        next_cursor = Some(nc);
21333                        break;
21334                    }
21335                    Frame::Unknown { .. } => {}
21336                    other => body.push(other),
21337                }
21338            }
21339            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21340                dropped = true;
21341                continue;
21342            };
21343            let sub = &self.subs[sub_index];
21344            // §8.2: only locally active, not mid-bootstrap subscriptions
21345            // apply; skipped sections are repaired by the next pull.
21346            if status != SubStatus::Active
21347                || sub.state != SubState::Active
21348                || sub.bootstrap_state.is_some()
21349                || !sub.synced_once
21350            {
21351                dropped = true;
21352                continue;
21353            }
21354            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21355                // Idempotent redelivery of an already-covered window.
21356                any_covered = true;
21357                continue;
21358            }
21359            let previous_effective = self.subs[sub_index].effective.clone();
21360            let previous_cursor = self.subs[sub_index].cursor;
21361            self.subs[sub_index].effective = Some(effective_scopes);
21362            let mut failed = false;
21363            for inner in body {
21364                match inner {
21365                    Frame::Commit {
21366                        tables,
21367                        changes,
21368                        commit_seq,
21369                        ..
21370                    } => {
21371                        if self
21372                            .apply_commit_frame_coalesced(
21373                                &tables,
21374                                changes,
21375                                commit_seq,
21376                                &coalesced_deletes,
21377                            )
21378                            .is_err()
21379                        {
21380                            failed = true;
21381                            break;
21382                        }
21383                    }
21384                    _ => {
21385                        failed = true;
21386                        break;
21387                    }
21388                }
21389            }
21390            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21391                self.apply_sub_end(sub_index, cursor, None)
21392                    .ok()
21393                    .map(|()| cursor)
21394            });
21395            let Some(next_cursor) = committed else {
21396                self.subs[sub_index].effective = previous_effective;
21397                self.subs[sub_index].cursor = previous_cursor;
21398                dropped = true;
21399                continue;
21400            };
21401            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21402        }
21403        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
21404        // the cursors, so the delta counts as a wake-up.
21405        if self.finish_coalesced_apply().is_err() {
21406            applied_cursor = None;
21407            dropped = true;
21408        }
21409        if let Some(cursor) = applied_cursor {
21410            // §8.2 ack point: the highest applied SUB_END.nextCursor.
21411            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21412            let _ = transport.realtime_send(&ack);
21413        } else if !any_covered || dropped {
21414            // §8.2: a delta not applied at all is treated as a wake-up.
21415            self.set_sync_needed(true, true);
21416        }
21417    }
21418
21419    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
21420    /// cursor across active, non-bootstrapping subscriptions that have
21421    /// synced at least once. No such subscription, no ack.
21422    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21423        if !self.realtime_connected() {
21424            return;
21425        }
21426        let floor = self
21427            .subs
21428            .iter()
21429            .filter(|s| {
21430                s.state == SubState::Active
21431                    && s.bootstrap_state.is_none()
21432                    && s.synced_once
21433                    && s.cursor >= 0
21434            })
21435            .map(|s| s.cursor)
21436            .min();
21437        if let Some(cursor) = floor {
21438            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21439            let _ = transport.realtime_send(&ack);
21440        }
21441    }
21442}
21443
21444#[cfg(test)]
21445mod previous_version_wiring_tests {
21446    use super::*;
21447    use serde_json::{json, Value};
21448
21449    fn previous_version_schema(version: i32) -> Value {
21450        json!({
21451            "version": version,
21452            "tables": [{
21453                "name": "tasks",
21454                "primaryKey": "id",
21455                "columns": [
21456                    { "name": "id", "type": "string", "nullable": false },
21457                    { "name": "note", "type": "string", "nullable": true }
21458                ],
21459                "scopes": []
21460            }]
21461        })
21462    }
21463
21464    fn previous_version_limits() -> crate::api::ClientLimits {
21465        crate::api::ClientLimits {
21466            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21467                enabled: true,
21468                ..Default::default()
21469            }),
21470            ..Default::default()
21471        }
21472    }
21473
21474    /// The configuration the live client carries, for a direct capture call.
21475    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21476        previous_version_limits()
21477            .previous_version_context
21478            .expect("configured")
21479    }
21480
21481    /// Capture the container the way a bump would, so a read test starts from a
21482    /// container this client's own state will then decide about.
21483    fn recapture_previous_version(client: &SyncClient) {
21484        let replica_path = client
21485            .previous_version_replica_path()
21486            .expect("file-backed replica");
21487        let outcome = crate::previous_version::capture_previous_version_from_replica(
21488            &client.conn,
21489            &replica_path,
21490            2,
21491            2,
21492            &previous_version_config(),
21493            1,
21494        )
21495        .expect("capture");
21496        assert!(matches!(
21497            outcome,
21498            crate::previous_version::CaptureOutcome::Captured(_)
21499        ));
21500    }
21501
21502    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21503        Subscription {
21504            id: "wiring-sub".to_owned(),
21505            table: "tasks".to_owned(),
21506            requested: Vec::new(),
21507            params: None,
21508            cursor,
21509            bootstrap_state: bootstrap_state.map(str::to_owned),
21510            state,
21511            reason_code: None,
21512            effective: None,
21513            synced_once: false,
21514        }
21515    }
21516
21517    #[test]
21518    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
21519        let path =
21520            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
21521        let path_str = path.to_str().expect("temp path");
21522        let schema = previous_version_schema(3);
21523        let mut older = previous_version_schema(2);
21524        older["tables"][0]["columns"] =
21525            json!([{ "name": "id", "type": "string", "nullable": false }]);
21526        let mut client = SyncClient::open_path(
21527            "downgrade".into(),
21528            &previous_version_schema(1),
21529            previous_version_limits(),
21530            path_str,
21531        )
21532        .expect("fresh");
21533        client
21534            .mutate(vec![Mutation::Upsert {
21535                table: "tasks".into(),
21536                values: Map::from_iter([
21537                    ("id".into(), json!("accepted")),
21538                    ("note".into(), json!("old")),
21539                ]),
21540                base_version: None,
21541            }])
21542            .expect("v1 write");
21543        client
21544            .recreate_with_schema(&schema)
21545            .expect("upgrade captures context");
21546        let commit = client
21547            .mutate(vec![Mutation::Upsert {
21548                table: "tasks".into(),
21549                values: Map::from_iter([
21550                    ("id".into(), json!("queued")),
21551                    ("note".into(), json!("v3-only")),
21552                ]),
21553                base_version: None,
21554            }])
21555            .expect("queued v3 write");
21556        let changes = client.conn.total_changes();
21557        let outbox = client.pending_commit_ids();
21558        assert!(outbox.contains(&commit));
21559        let descriptor = client.get_meta("localSchemaDescriptor");
21560        let container = format!(
21561            "{path_str}{}",
21562            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21563        );
21564        let context = std::fs::read(&container).expect("captured context");
21565        assert!(client
21566            .recreate_with_schema(&older)
21567            .expect_err("refuse recreate")
21568            .starts_with("client.schema_downgrade:"));
21569        assert_eq!(client.schema.version, 3);
21570        assert_eq!(client.conn.total_changes(), changes);
21571        assert_eq!(client.pending_commit_ids(), outbox);
21572        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21573        assert_eq!(
21574            client
21575                .conn
21576                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21577                    row.get::<_, String>(0)
21578                })
21579                .expect("queued row"),
21580            "v3-only"
21581        );
21582        assert_eq!(std::fs::read(&container).expect("context remains"), context);
21583        drop(client);
21584        let before = std::fs::read(&path).expect("replica bytes");
21585        // open_path uses DELETE journaling. The identity open must refuse
21586        // before its normal WAL configuration changes this database.
21587        for entry in 0..3 {
21588            let error = match entry {
21589                0 => SyncClient::open_path(
21590                    "downgrade".into(),
21591                    &older,
21592                    previous_version_limits(),
21593                    path_str,
21594                ),
21595                1 => SyncClient::open_path_with_identity(
21596                    None,
21597                    &older,
21598                    previous_version_limits(),
21599                    path_str,
21600                ),
21601                _ => SyncClient::with_connection(
21602                    "downgrade".into(),
21603                    &older,
21604                    previous_version_limits(),
21605                    Connection::open(&path).expect("connection"),
21606                ),
21607            }
21608            .err()
21609            .expect("refuse open");
21610            assert!(error.starts_with("client.schema_downgrade:"));
21611            let conn = Connection::open(&path).expect("inspect");
21612            assert_eq!(
21613                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21614                    .expect("journal mode"),
21615                "delete"
21616            );
21617            drop(conn);
21618            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21619            assert_eq!(
21620                std::fs::read(&container).expect("context unchanged"),
21621                context
21622            );
21623        }
21624        let reopened = SyncClient::open_path(
21625            "downgrade".into(),
21626            &schema,
21627            previous_version_limits(),
21628            path_str,
21629        )
21630        .expect("compatible reopen");
21631        assert_eq!(reopened.pending_commit_ids(), outbox);
21632        assert_eq!(
21633            reopened
21634                .conn
21635                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21636                    row.get::<_, String>(0)
21637                })
21638                .expect("queued row remains"),
21639            "v3-only"
21640        );
21641        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21642        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21643        drop(reopened);
21644        let legacy = SyncClient::open_path(
21645            "downgrade".into(),
21646            &schema,
21647            previous_version_limits(),
21648            path_str,
21649        )
21650        .expect("legacy no-marker reopen");
21651        assert_eq!(legacy.pending_commit_ids(), outbox);
21652        assert!(!legacy.upgrading());
21653        assert_eq!(
21654            legacy
21655                .conn
21656                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21657                    row.get::<_, String>(0)
21658                })
21659                .expect("legacy row remains"),
21660            "v3-only"
21661        );
21662        drop(legacy);
21663        std::fs::remove_file(path).expect("remove replica");
21664        let _ = std::fs::remove_file(container);
21665    }
21666
21667    #[test]
21668    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21669        for marker in [
21670            "''",
21671            "'bad'",
21672            "'0'",
21673            "'-1'",
21674            "'2.5'",
21675            "'03'",
21676            "'3\n'",
21677            "' 3'",
21678            "'+3'",
21679            "'3e0'",
21680            "'2147483648'",
21681            "NULL",
21682            "X'33'",
21683        ] {
21684            let conn = Connection::open_in_memory().expect("connection");
21685            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21686            let error = SyncClient::with_connection(
21687                "marker".into(),
21688                &previous_version_schema(3),
21689                ClientLimits::default(),
21690                conn,
21691            )
21692            .err()
21693            .expect("refuse marker");
21694            assert!(
21695                error.starts_with("sync.local_corrupt:"),
21696                "{marker}: {error}"
21697            );
21698        }
21699        for fault in [
21700            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21701            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21702        ] {
21703            let mut client = SyncClient::new(
21704                "marker".into(),
21705                &previous_version_schema(3),
21706                ClientLimits::default(),
21707            )
21708            .expect("fresh");
21709            let commit = client
21710                .mutate(vec![Mutation::Upsert {
21711                    table: "tasks".into(),
21712                    values: Map::from_iter([("id".into(), json!("queued"))]),
21713                    base_version: None,
21714                }])
21715                .expect("queued write");
21716            client.conn.execute_batch(fault).expect("marker fault");
21717            let changes = client.conn.total_changes();
21718            let error = client
21719                .recreate_with_schema(&previous_version_schema(4))
21720                .expect_err("refuse corrupt marker");
21721            assert!(error.starts_with("sync.local_corrupt:"));
21722            assert_eq!(client.conn.total_changes(), changes);
21723            assert_eq!(client.schema.version, 3);
21724            assert_eq!(client.pending_commit_ids(), vec![commit]);
21725        }
21726        for setup in [
21727            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21728            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21729        ] {
21730            let path =
21731                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21732            let conn = Connection::open(&path).expect("connection");
21733            conn.execute_batch(setup)
21734                .expect("unreadable metadata fixture");
21735            drop(conn);
21736            let before = std::fs::read(&path).expect("before");
21737            let error = SyncClient::open_path_with_identity(
21738                None,
21739                &previous_version_schema(3),
21740                ClientLimits::default(),
21741                path.to_str().expect("path"),
21742            )
21743            .err()
21744            .expect("refuse unreadable marker");
21745            assert!(error.starts_with("sync.local_corrupt:"));
21746            assert_eq!(std::fs::read(&path).expect("after"), before);
21747            std::fs::remove_file(path).expect("remove fixture");
21748        }
21749    }
21750
21751    std::thread_local! {
21752        /// The pending concurrent upgrade a busy handler commits, so a lock
21753        /// collision deterministically becomes the marker race under test.
21754        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21755            const { RefCell::new(None) };
21756    }
21757
21758    fn commit_concurrent_upgrade(attempts: i32) -> bool {
21759        CONCURRENT_UPGRADE.with(|slot| {
21760            if let Some(banner) = slot.borrow_mut().take() {
21761                banner
21762                    .execute_batch(
21763                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21764                    )
21765                    .expect("commit the concurrent upgrade");
21766            }
21767        });
21768        attempts < 10
21769    }
21770
21771    fn journal_mode(conn: &Connection) -> String {
21772        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21773            .expect("journal mode")
21774    }
21775
21776    fn temp_replica(label: &str) -> std::path::PathBuf {
21777        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21778    }
21779
21780    #[test]
21781    fn file_identity_constructor_requires_the_returned_wal_mode() {
21782        for path in [":memory:", ""] {
21783            let error = SyncClient::open_path_with_identity(
21784                None,
21785                &previous_version_schema(1),
21786                ClientLimits::default(),
21787                path,
21788            )
21789            .err()
21790            .expect("temporary SQLite databases cannot enter WAL mode");
21791            assert_eq!(
21792                error,
21793                "sync.invalid_request: file-backed client requires WAL journal mode"
21794            );
21795        }
21796        let path = temp_replica("confirmed-wal");
21797        let client = SyncClient::open_path_with_identity(
21798            None,
21799            &previous_version_schema(1),
21800            ClientLimits::default(),
21801            path.to_str().unwrap(),
21802        )
21803        .unwrap();
21804        assert_eq!(journal_mode(&client.conn), "wal");
21805        drop(client);
21806        std::fs::remove_file(path).unwrap();
21807    }
21808
21809    #[test]
21810    fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
21811        for path in [":memory:", ""] {
21812            let client = SyncClient::open_path_with_identity_journal(
21813                None,
21814                &previous_version_schema(1),
21815                ClientLimits::default(),
21816                path,
21817                false,
21818            )
21819            .expect("a host without WAL can still open the identity constructor");
21820            assert_ne!(journal_mode(&client.conn), "wal");
21821            assert_eq!(
21822                client
21823                    .conn
21824                    .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
21825                    .expect("synchronous"),
21826                2,
21827                "every identity open pins FULL durability"
21828            );
21829        }
21830    }
21831
21832    #[test]
21833    fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
21834        let path = temp_replica("reset");
21835        let path_str = path.to_str().expect("path");
21836        let client = SyncClient::open_path(
21837            "reset-replica".into(),
21838            &previous_version_schema(1),
21839            ClientLimits::default(),
21840            path_str,
21841        )
21842        .expect("replica");
21843        let original_id = client.client_id().to_owned();
21844        drop(client);
21845        for suffix in ["-journal", "-wal", "-shm"] {
21846            std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
21847        }
21848
21849        SyncClient::reset_replica(path_str).expect("reset through the VFS");
21850
21851        for suffix in ["", "-journal", "-wal", "-shm"] {
21852            assert!(
21853                !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
21854                "reset removes the replica file {suffix:?}"
21855            );
21856        }
21857        let reopened = SyncClient::open_path_with_identity(
21858            None,
21859            &previous_version_schema(1),
21860            ClientLimits::default(),
21861            path_str,
21862        )
21863        .expect("fresh replica");
21864        assert_ne!(reopened.client_id(), original_id);
21865        assert!(reopened
21866            .query("SELECT id FROM tasks", &[])
21867            .expect("empty")
21868            .is_empty());
21869        drop(reopened);
21870        assert_eq!(
21871            SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
21872            "sync.invalid_request: a memory replica has nothing to reset"
21873        );
21874    }
21875
21876    #[test]
21877    fn non_positive_generated_schema_versions_are_refused_before_storage() {
21878        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
21879            let mut schema = previous_version_schema(1);
21880            schema["version"] = version.clone();
21881            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21882                .err()
21883                .expect("refuse an out-of-range generated version");
21884            assert_eq!(
21885                error,
21886                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
21887            );
21888            // The file-backed seam refuses before the replica gains a table.
21889            let path = temp_replica("bad-version");
21890            let path_str = path.to_str().expect("path");
21891            for identity in [false, true] {
21892                let error = if identity {
21893                    SyncClient::open_path_with_identity(
21894                        None,
21895                        &schema,
21896                        ClientLimits::default(),
21897                        path_str,
21898                    )
21899                } else {
21900                    SyncClient::open_path(
21901                        "bad-version".into(),
21902                        &schema,
21903                        ClientLimits::default(),
21904                        path_str,
21905                    )
21906                }
21907                .err()
21908                .expect("refuse before storage");
21909                assert!(
21910                    error.starts_with("sync.invalid_request:"),
21911                    "{version}: {error}"
21912                );
21913                assert!(
21914                    !path.exists(),
21915                    "{version}: a refused constructor creates no replica file"
21916                );
21917            }
21918        }
21919        // A missing version is the same request error, not a parse failure.
21920        let mut schema = previous_version_schema(1);
21921        schema
21922            .as_object_mut()
21923            .expect("schema object")
21924            .remove("version");
21925        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21926            .err()
21927            .expect("refuse a missing version");
21928        assert!(error.starts_with("sync.invalid_request:"), "{error}");
21929    }
21930
21931    #[test]
21932    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
21933        let path = temp_replica("duplicate-marker");
21934        let path_str = path.to_str().expect("path");
21935        {
21936            // A hand-built/legacy metadata table without its primary key, with
21937            // the older marker first: `query_row` would pick it and reset.
21938            let conn = Connection::open(path_str).expect("fixture");
21939            conn.execute_batch(
21940                "CREATE TABLE _syncular_meta(key TEXT, value);
21941                 INSERT INTO _syncular_meta VALUES
21942                   ('localSchemaVersion', '1'),
21943                   ('localSchemaVersion', '2'),
21944                   ('clientId', 'duplicate');",
21945            )
21946            .expect("duplicate marker fixture");
21947        }
21948        let error = SyncClient::open_path_with_identity(
21949            None,
21950            &previous_version_schema(3),
21951            ClientLimits::default(),
21952            path_str,
21953        )
21954        .err()
21955        .expect("refuse a duplicated marker");
21956        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
21957        let probe = Connection::open(path_str).expect("probe");
21958        assert_eq!(
21959            probe
21960                .query_row(
21961                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
21962                    [],
21963                    |row| row.get::<_, i64>(0),
21964                )
21965                .expect("marker rows"),
21966            2,
21967            "the duplicated rows are left for the operator, not collapsed"
21968        );
21969        assert_eq!(
21970            probe
21971                .query_row(
21972                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
21973                    [],
21974                    |row| row.get::<_, i64>(0),
21975                )
21976                .expect("synced tables"),
21977            0,
21978            "a duplicated marker never drives the reset that recreates v3 tables"
21979        );
21980        assert_eq!(
21981            journal_mode(&probe),
21982            "delete",
21983            "the refusal leaves the journal mode alone"
21984        );
21985        drop(probe);
21986        std::fs::remove_file(path).expect("remove replica");
21987    }
21988
21989    #[test]
21990    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
21991        let path = temp_replica("refused-wal");
21992        let path_str = path.to_str().expect("path");
21993        // `open_path` leaves the default journal mode, so a refused open is
21994        // the only thing that could switch this replica to WAL.
21995        drop(
21996            SyncClient::open_path(
21997                "owner".into(),
21998                &previous_version_schema(2),
21999                ClientLimits::default(),
22000                path_str,
22001            )
22002            .expect("v2 replica"),
22003        );
22004        let before = std::fs::read(&path).expect("before");
22005        let error = SyncClient::open_path_with_identity(
22006            Some("intruder".into()),
22007            &previous_version_schema(2),
22008            ClientLimits::default(),
22009            path_str,
22010        )
22011        .err()
22012        .expect("refuse the identity mismatch");
22013        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22014        let probe = Connection::open(path_str).expect("probe");
22015        assert_eq!(
22016            journal_mode(&probe),
22017            "delete",
22018            "a refused open must not persist WAL"
22019        );
22020        assert_eq!(std::fs::read(&path).expect("after"), before);
22021        assert_eq!(
22022            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22023            Some("2")
22024        );
22025        drop(probe);
22026        std::fs::remove_file(path).expect("remove replica");
22027    }
22028
22029    #[test]
22030    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22031        let path = temp_replica("marker-race");
22032        let path_str = path.to_str().expect("path");
22033        drop(
22034            SyncClient::open_path_with_identity(
22035                Some("stale".into()),
22036                &previous_version_schema(1),
22037                ClientLimits::default(),
22038                path_str,
22039            )
22040            .expect("v1 replica"),
22041        );
22042        // Another process holds the write lock with an uncommitted upgrade; a
22043        // stale open reads the v1 marker first and only then meets the lock.
22044        // The first busy therefore lands after this replica became v2.
22045        let banner = Connection::open(path_str).expect("banner");
22046        banner
22047            .execute_batch(
22048                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22049            )
22050            .expect("pending upgrade");
22051        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22052        let conn = Connection::open(path_str).expect("stale connection");
22053        conn.busy_handler(Some(commit_concurrent_upgrade))
22054            .expect("busy handler");
22055        let error = SyncClient::with_connection(
22056            "stale".into(),
22057            &previous_version_schema(1),
22058            ClientLimits::default(),
22059            conn,
22060        )
22061        .err()
22062        .expect("a concurrent upgrade must refuse the stale open");
22063        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22064        let probe = Connection::open(path_str).expect("probe");
22065        assert_eq!(
22066            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22067            Some("2"),
22068            "the concurrent upgrade survives"
22069        );
22070        drop(probe);
22071        std::fs::remove_file(path).expect("remove replica");
22072    }
22073
22074    #[test]
22075    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22076        let path = temp_replica("recreate-race");
22077        let path_str = path.to_str().expect("path");
22078        let mut client = SyncClient::open_path_with_identity(
22079            Some("stale".into()),
22080            &previous_version_schema(1),
22081            ClientLimits::default(),
22082            path_str,
22083        )
22084        .expect("v1 replica");
22085        client
22086            .mutate(vec![Mutation::Upsert {
22087                table: "tasks".into(),
22088                values: Map::from_iter([("id".into(), json!("queued"))]),
22089                base_version: None,
22090            }])
22091            .expect("queued write");
22092        let commit = client.pending_commit_ids();
22093        let banner = Connection::open(path_str).expect("banner");
22094        banner
22095            .execute_batch(
22096                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22097            )
22098            .expect("pending upgrade");
22099        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22100        client
22101            .conn
22102            .busy_handler(Some(commit_concurrent_upgrade))
22103            .expect("busy handler");
22104        let error = client
22105            .recreate_with_schema(&previous_version_schema(1))
22106            .expect_err("a concurrent upgrade must refuse the stale recreate");
22107        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22108        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22109        assert_eq!(
22110            client.pending_commit_ids(),
22111            commit,
22112            "the outbox is preserved"
22113        );
22114        let probe = Connection::open(path_str).expect("probe");
22115        assert_eq!(
22116            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22117            Some("2")
22118        );
22119        drop(probe);
22120        drop(client);
22121        std::fs::remove_file(path).expect("remove replica");
22122    }
22123
22124    #[test]
22125    fn failed_schema_record_writes_roll_back_recreation() {
22126        for key in [
22127            LOCAL_SCHEMA_VERSION_KEY,
22128            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22129        ] {
22130            let mut client = SyncClient::new(
22131                "schema-record".into(),
22132                &previous_version_schema(1),
22133                ClientLimits::default(),
22134            )
22135            .expect("v1");
22136            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22137            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22138            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");
22139            client
22140                .recreate_with_schema(&previous_version_schema(2))
22141                .expect_err("both record writes must propagate failure");
22142            assert_eq!(client.schema.version, 1);
22143            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22144            assert_eq!(
22145                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22146                descriptor
22147            );
22148            client
22149                .conn
22150                .execute_batch("DROP TRIGGER refuse_schema_record")
22151                .expect("remove fault");
22152            client
22153                .recreate_with_schema(&previous_version_schema(2))
22154                .expect("retry");
22155            assert_eq!(client.schema.version, 2);
22156        }
22157    }
22158
22159    #[test]
22160    fn a_failed_outer_commit_restores_every_reset_side_effect() {
22161        let path = temp_replica("outer-commit");
22162        let path_str = path.to_str().expect("path");
22163        // A rollback-journal replica (no WAL), so a second read connection can
22164        // hold SHARED across the recreated transaction's COMMIT.
22165        let conn = Connection::open(path_str).expect("replica connection");
22166        let mut client = SyncClient::with_connection(
22167            "outer-commit".into(),
22168            &previous_version_schema(1),
22169            previous_version_limits(),
22170            conn,
22171        )
22172        .expect("v1 replica");
22173        assert_eq!(journal_mode(&client.conn), "delete");
22174        let commit = client
22175            .mutate(vec![Mutation::Upsert {
22176                table: "tasks".into(),
22177                values: Map::from_iter([
22178                    ("id".into(), json!("queued")),
22179                    ("note".into(), json!("dropped-by-v2")),
22180                ]),
22181                base_version: None,
22182            }])
22183            .expect("queued write");
22184        client.drain_change_batches();
22185        // v2 drops `note`, so the queued upsert cannot re-encode.
22186        let mut dropped = previous_version_schema(2);
22187        dropped["tables"][0]["columns"] =
22188            json!([{ "name": "id", "type": "string", "nullable": false }]);
22189        // Prior database and memory state: the refused COMMIT must restore all
22190        // of it.
22191        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22192        let revision = client.local_revision();
22193        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22194        let columns: i64 = client
22195            .conn
22196            .query_row(
22197                "SELECT count(*) FROM pragma_table_info('tasks')",
22198                [],
22199                |row| row.get(0),
22200            )
22201            .expect("column count");
22202        let rebuilds = client.overlay_rebuild_count.get();
22203        let prior_subs = client.subs.len();
22204        let prior_changes = client.change_queue.len();
22205        let prior_intents = client.sync_intent_queue.len();
22206        let prior_last_change = client.last_change.clone();
22207        let prior_overlay_dirty = client.overlay_dirty.get();
22208        let prior_sync_needed = client.sync_needed;
22209        let prior_upgrading = client.upgrading;
22210        let prior_stopped = client.stopped;
22211        let prior_schema_floor = client.schema_floor.clone();
22212        let prior_active_round = client.active_round;
22213        // The reader holds SHARED for the whole attempt: the writer takes
22214        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
22215        let reader = Connection::open(path_str).expect("reader");
22216        reader
22217            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22218            .expect("reader snapshot");
22219        client
22220            .conn
22221            .busy_timeout(std::time::Duration::ZERO)
22222            .expect("busy timeout");
22223        let error = client
22224            .recreate_with_schema(&dropped)
22225            .expect_err("a blocked COMMIT must refuse the recreation");
22226        assert!(error.starts_with("client.storage_busy:"), "{error}");
22227        assert!(
22228            client.overlay_rebuild_count.get() > rebuilds,
22229            "the reset ran to its overlay rebuild inside the transaction"
22230        );
22231        assert!(client.conn.is_autocommit(), "the transaction is released");
22232        // Database: the rolled-back reset left nothing behind.
22233        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22234        assert_eq!(client.local_revision(), revision);
22235        assert_eq!(
22236            client.query("SELECT * FROM tasks", &[]).expect("rows"),
22237            rows
22238        );
22239        assert_eq!(
22240            client
22241                .conn
22242                .query_row(
22243                    "SELECT count(*) FROM pragma_table_info('tasks')",
22244                    [],
22245                    |row| { row.get::<_, i64>(0) }
22246                )
22247                .expect("column count"),
22248            columns,
22249            "the previous table layout survives the rollback"
22250        );
22251        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22252        // Memory: every side effect of the successful reset is restored with
22253        // the rolled-back database.
22254        assert_eq!(client.schema.version, 1, "compiled schema");
22255        assert_eq!(client.subs.len(), prior_subs);
22256        assert_eq!(client.change_queue.len(), prior_changes);
22257        assert_eq!(client.sync_intent_queue.len(), prior_intents);
22258        assert_eq!(client.last_change, prior_last_change);
22259        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22260        assert_eq!(client.sync_needed, prior_sync_needed);
22261        assert_eq!(client.upgrading, prior_upgrading);
22262        assert_eq!(client.stopped, prior_stopped);
22263        assert_eq!(client.schema_floor, prior_schema_floor);
22264        assert_eq!(client.active_round, prior_active_round);
22265        assert!(
22266            client.rejections.is_empty(),
22267            "the dropped-column rejection is rolled back too"
22268        );
22269        // The capture committed in its separate file before the failed replica
22270        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
22271        let container = crate::previous_version::previous_version_container_path(path_str);
22272        assert!(std::path::Path::new(&container).exists());
22273        // The reader no longer needs its snapshot, so discard can clean the
22274        // replica's audit records along with the stale sibling file.
22275        drop(reader);
22276        let snapshot = client
22277            .previous_version_snapshot(&PreviousVersionReadSpec {
22278                table: "tasks".into(),
22279                ..Default::default()
22280            })
22281            .expect("refuse stale capture");
22282        assert!(!snapshot.available);
22283        assert_eq!(snapshot.current_version, 1);
22284        assert!(!std::path::Path::new(&container).exists());
22285        // The client remains usable: releasing the reader lets the identical
22286        // recreation run to completion, and the reset preserves the
22287        // incompatible intent until the send-time prepass classifies it
22288        // (§7.4.3/§7.4.4: the reset never touches the outbox).
22289        client
22290            .conn
22291            .busy_timeout(std::time::Duration::from_secs(1))
22292            .expect("busy timeout");
22293        client
22294            .recreate_with_schema(&dropped)
22295            .expect("the recreation succeeds once the reader releases");
22296        assert_eq!(client.schema.version, 2);
22297        assert_eq!(
22298            client.pending_commit_ids(),
22299            vec![commit.clone()],
22300            "the reset kept the incompatible commit pending"
22301        );
22302        assert!(
22303            client.rejections.is_empty(),
22304            "no rejection exists before the send-time prepass"
22305        );
22306        // The send boundary classifies and drops it.
22307        client
22308            .prepare_sync_round(false)
22309            .expect("the send prepass runs during round preparation");
22310        assert!(client.pending_commit_ids().is_empty());
22311        assert!(
22312            client
22313                .rejections
22314                .iter()
22315                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22316            "the dropped-column commit surfaced as a rejection at send time"
22317        );
22318        client.previous_version_discard();
22319        drop(client);
22320        std::fs::remove_file(path).expect("remove replica");
22321    }
22322
22323    #[test]
22324    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22325        let v1 = previous_version_schema(1);
22326        let mut v2 = previous_version_schema(2);
22327        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
22328        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22329        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22330        let dropped = client
22331            .mutate(vec![Mutation::Upsert {
22332                table: "tasks".into(),
22333                values: Map::from_iter([
22334                    ("id".into(), json!("dropped")),
22335                    ("note".into(), json!("gone")),
22336                ]),
22337                base_version: None,
22338            }])
22339            .unwrap();
22340        // A full-row v1 upsert always carries the v1 columns, so the surviving
22341        // commit is a value-free delete (it encodes under any schema that still
22342        // has the table).
22343        let survivor = client
22344            .mutate(vec![Mutation::Delete {
22345                table: "tasks".into(),
22346                row_id: "gone".into(),
22347                base_version: None,
22348            }])
22349            .unwrap();
22350        client
22351            .recreate_with_schema(&v2)
22352            .expect("the reset preserves the outbox");
22353        assert_eq!(
22354            client.pending_commit_ids(),
22355            vec![dropped.clone(), survivor.clone()],
22356            "the reset preserves both commits"
22357        );
22358        assert!(client.rejections.is_empty(), "the reset classifies nothing");
22359        client.drain_change_batches();
22360        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22361        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22362
22363        // A fault anywhere in the atomic drop leaves the replica, the outbox,
22364        // the durable outcomes, the visible projection, the revision, and the
22365        // publish surface untouched. Clearing `syncNeeded` first keeps the
22366        // round's own status flip out of the assertion, so any published change
22367        // can only be the drop's.
22368        for fault in [
22369            "outcome-insert",
22370            "outbox-delete",
22371            "overlay-rebuild",
22372            "publish-revision",
22373        ] {
22374            client.set_sync_needed(false, false);
22375            client.drain_change_batches();
22376            let revision = client.local_revision();
22377            let trigger = match fault {
22378                "outcome-insert" => {
22379                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22380                }
22381                "outbox-delete" => {
22382                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22383                }
22384                // The optimized replay removes only the dropped projection, so
22385                // the replay fault belongs on the DELETE; a publish fault lands
22386                // on the revision write that `finish_observation` performs after
22387                // every durable effect already succeeded.
22388                "publish-revision" => {
22389                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22390                }
22391                _ => {
22392                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22393                }
22394            };
22395            client.conn.execute_batch(trigger).unwrap();
22396            let outcome = client
22397                .prepare_sync_round(false)
22398                .err()
22399                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22400            assert!(
22401                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22402                "{fault}: {outcome:?}"
22403            );
22404            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22405            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22406            assert_eq!(
22407                client.pending_commit_ids(),
22408                vec![dropped.clone(), survivor.clone()],
22409                "{fault}: the outbox is restored"
22410            );
22411            assert!(
22412                client.rejections.is_empty(),
22413                "{fault}: no in-memory rejection survives"
22414            );
22415            assert!(
22416                client.commit_outcome(&dropped).unwrap().is_none(),
22417                "{fault}: the durable outcome rolled back"
22418            );
22419            assert_eq!(client.local_revision(), revision, "{fault}: revision");
22420            assert_eq!(
22421                client.query("SELECT * FROM tasks", &[]).unwrap(),
22422                rows,
22423                "{fault}: the visible projection is unchanged"
22424            );
22425            assert!(
22426                client.drain_change_batches().is_empty(),
22427                "{fault}: nothing is published"
22428            );
22429        }
22430
22431        // A nested failure leaves the caller's transaction and earlier writes
22432        // intact; only the drop's savepoint is rolled back.
22433        client
22434            .conn
22435            .execute_batch(
22436                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22437             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22438             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22439            )
22440            .unwrap();
22441        assert!(client.drop_incompatible_outbox().is_err());
22442        assert!(!client.conn.is_autocommit());
22443        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22444        assert_eq!(
22445            client.pending_commit_ids(),
22446            vec![dropped.clone(), survivor.clone()]
22447        );
22448        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22449        client
22450            .conn
22451            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22452            .unwrap();
22453
22454        // The successful drop publishes exactly once and leaves the surviving
22455        // intent queued for the same round. A log epoch is required before a
22456        // round builds push frames at all, so the classification provably runs
22457        // before any operation of the dropped commit is sent.
22458        client.set_meta(LOG_EPOCH_KEY, "epoch");
22459        client.set_sync_needed(false, false);
22460        client.drain_change_batches();
22461        let revision = client.local_revision();
22462        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22463        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22464        assert_eq!(
22465            prepared.meta.pushed_ids,
22466            vec![survivor.clone()],
22467            "the incompatible commit is classified before any operation is sent"
22468        );
22469        let rejection = client
22470            .rejections
22471            .iter()
22472            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22473            .expect("a sync.outbox_incompatible rejection");
22474        assert_eq!(rejection.client_commit_id, dropped);
22475        assert!(
22476            !rejection.retryable,
22477            "a schema-incompatible commit is final"
22478        );
22479        assert_eq!(
22480            client
22481                .commit_outcome(&dropped)
22482                .unwrap()
22483                .expect("a durable rejected outcome")
22484                .status,
22485            CommitOutcomeStatus::Rejected
22486        );
22487        assert_eq!(client.local_revision(), revision + 1, "one revision");
22488        let batches = client.drain_change_batches();
22489        assert_eq!(batches.len(), 1, "one change batch");
22490        assert!(
22491            batches[0].rejections_changed
22492                && batches[0].outcomes_changed
22493                && batches[0].status.is_some(),
22494            "{:?}",
22495            batches[0]
22496        );
22497        assert!(
22498            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22499            "{:?}",
22500            batches[0]
22501        );
22502        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22503        assert!(
22504            visible.is_empty(),
22505            "the dropped projection is undone: {visible:?}"
22506        );
22507
22508        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
22509        // a rollback-journal reader holding SHARED makes it fail with the typed
22510        // busy classification, and releasing the reader lets the retry succeed.
22511        let path = temp_replica("outbox-drop-commit");
22512        let path_str = path.to_str().expect("path");
22513        let conn = Connection::open(path_str).expect("replica connection");
22514        let mut client =
22515            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
22516                .expect("v1 replica");
22517        let dropped = client
22518            .mutate(vec![Mutation::Upsert {
22519                table: "tasks".into(),
22520                values: Map::from_iter([
22521                    ("id".into(), json!("dropped")),
22522                    ("note".into(), json!("gone")),
22523                ]),
22524                base_version: None,
22525            }])
22526            .unwrap();
22527        client
22528            .recreate_with_schema(&v2)
22529            .expect("the reset preserves the outbox");
22530        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22531        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22532        assert_eq!(rows.len(), 1, "the optimistic row is visible");
22533        client.set_sync_needed(false, false);
22534        client.drain_change_batches();
22535        let revision = client.local_revision();
22536        let reader = Connection::open(path_str).expect("reader");
22537        reader
22538            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22539            .expect("reader snapshot");
22540        client
22541            .conn
22542            .busy_timeout(std::time::Duration::ZERO)
22543            .expect("busy timeout");
22544        let outcome = client
22545            .prepare_sync_round(false)
22546            .err()
22547            .expect("a blocked COMMIT must fail the round");
22548        assert!(
22549            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22550            "{outcome:?}"
22551        );
22552        assert!(client.conn.is_autocommit(), "the savepoint is released");
22553        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22554        assert!(client.rejections.is_empty());
22555        assert!(client.commit_outcome(&dropped).unwrap().is_none());
22556        assert_eq!(client.local_revision(), revision);
22557        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22558        assert!(client.drain_change_batches().is_empty());
22559        // The same round succeeds once the reader releases.
22560        drop(reader);
22561        client
22562            .conn
22563            .busy_timeout(std::time::Duration::from_secs(1))
22564            .expect("busy timeout");
22565        client
22566            .prepare_sync_round(false)
22567            .expect("the drop succeeds after the reader releases");
22568        assert!(client.pending_commit_ids().is_empty());
22569        assert!(client
22570            .rejections
22571            .iter()
22572            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22573        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22574        drop(client);
22575        std::fs::remove_file(path).expect("remove replica");
22576    }
22577
22578    #[test]
22579    fn previous_version_read_reasons_come_from_live_client_state() {
22580        use crate::previous_version::{
22581            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22582        };
22583        let path = std::env::temp_dir()
22584            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22585            .to_string_lossy()
22586            .into_owned();
22587        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22588        {
22589            let mut setup = SyncClient::open_path(
22590                "wiring".to_owned(),
22591                &previous_version_schema(1),
22592                previous_version_limits(),
22593                &path,
22594            )
22595            .expect("v1 install");
22596            setup
22597                .mutate(vec![Mutation::Upsert {
22598                    table: "tasks".to_owned(),
22599                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22600                    base_version: None,
22601                }])
22602                .expect("seed a row");
22603        }
22604        let mut client = SyncClient::open_path(
22605            "wiring".to_owned(),
22606            &previous_version_schema(2),
22607            previous_version_limits(),
22608            &path,
22609        )
22610        .expect("v2 bump captures");
22611        assert!(std::path::Path::new(&container).exists());
22612        let spec = PreviousVersionReadSpec {
22613            table: "tasks".to_owned(),
22614            ..Default::default()
22615        };
22616
22617        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
22618        recapture_previous_version(&client);
22619        client.lease_state = Some(LeaseState {
22620            lease_id: None,
22621            expires_at_ms: None,
22622            error_code: Some("lease.expired".to_owned()),
22623        });
22624        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22625        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22626        assert!(!snapshot.available);
22627        assert!(!std::path::Path::new(&container).exists());
22628
22629        // An expired lease uses the client clock (injected, no sleeps).
22630        recapture_previous_version(&client);
22631        client.set_now_ms(1_000);
22632        client.lease_state = Some(LeaseState {
22633            lease_id: Some("lease".to_owned()),
22634            expires_at_ms: Some(1_000),
22635            error_code: None,
22636        });
22637        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22638        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22639        assert!(!std::path::Path::new(&container).exists());
22640        client.lease_state = None;
22641
22642        // A revoked subscription ⇒ scope-revoked.
22643        recapture_previous_version(&client);
22644        client.subs.push(subscription(SubState::Revoked, 0, None));
22645        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22646        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22647        assert!(!std::path::Path::new(&container).exists());
22648        client.subs.clear();
22649
22650        // Coverage completion: every ACTIVE subscription has a cursor and no
22651        // resume token — a still-resuming subscription is not completion.
22652        recapture_previous_version(&client);
22653        client
22654            .subs
22655            .push(subscription(SubState::Active, 0, Some("resume")));
22656        assert!(!client.previous_version_coverage_complete());
22657        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22658        assert!(snapshot.available);
22659        assert_eq!(snapshot.previous_version, Some(2));
22660        assert_eq!(snapshot.rows.len(), 1);
22661
22662        recapture_previous_version(&client);
22663        client.subs[0].bootstrap_state = None;
22664        assert!(client.previous_version_coverage_complete());
22665        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22666        assert_eq!(
22667            snapshot.reason,
22668            Some(PreviousVersionReason::CoverageComplete)
22669        );
22670        assert!(!std::path::Path::new(&container).exists());
22671        client.subs.clear();
22672
22673        // With no active subscription at all, coverage is NOT complete.
22674        assert!(!client.previous_version_coverage_complete());
22675
22676        drop(client);
22677        let _ = std::fs::remove_file(&path);
22678    }
22679
22680    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
22681    /// come from live state; this proves the OTHER caller —
22682    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
22683    /// actually removes the container for lease-inactivity, coverage
22684    /// completion and scope revocation, and leaves it alone while the
22685    /// replacement bootstrap is still resuming.
22686    #[test]
22687    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22688        let path = std::env::temp_dir()
22689            .join(format!(
22690                "syncular-prev-lifetime-{}.db",
22691                uuid::Uuid::new_v4()
22692            ))
22693            .to_string_lossy()
22694            .into_owned();
22695        let container = format!(
22696            "{path}{}",
22697            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22698        );
22699        let exists = || std::path::Path::new(&container).exists();
22700        {
22701            let mut setup = SyncClient::open_path(
22702                "lifetime".to_owned(),
22703                &previous_version_schema(1),
22704                previous_version_limits(),
22705                &path,
22706            )
22707            .expect("v1 install");
22708            setup
22709                .mutate(vec![Mutation::Upsert {
22710                    table: "tasks".to_owned(),
22711                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22712                    base_version: None,
22713                }])
22714                .expect("seed a row");
22715        }
22716        let mut client = SyncClient::open_path(
22717            "lifetime".to_owned(),
22718            &previous_version_schema(2),
22719            previous_version_limits(),
22720            &path,
22721        )
22722        .expect("v2 bump captures");
22723        // Pin the clock so the 24h TTL never fires in this test (the TTL has
22724        // its own coverage); the capture record's createdAtMs is 1.
22725        client.set_now_ms(1_000);
22726        assert!(exists());
22727
22728        // §7.3.5 lease error ⇒ the lifetime check drops the container.
22729        client.lease_state = Some(LeaseState {
22730            lease_id: None,
22731            expires_at_ms: None,
22732            error_code: Some("lease.expired".to_owned()),
22733        });
22734        client.previous_version_lifetime_check();
22735        assert!(!exists());
22736        client.lease_state = None;
22737
22738        // §7.4.5 coverage completion (every active sub has a cursor and no
22739        // resume token) ⇒ drop.
22740        recapture_previous_version(&client);
22741        client.subs.push(subscription(SubState::Active, 0, None));
22742        client.previous_version_lifetime_check();
22743        assert!(!exists());
22744        client.subs.clear();
22745
22746        // §3.3 scope revocation ⇒ drop.
22747        recapture_previous_version(&client);
22748        client.subs.push(subscription(SubState::Revoked, 0, None));
22749        client.previous_version_lifetime_check();
22750        assert!(!exists());
22751        client.subs.clear();
22752
22753        // Negative control: a still-resuming active subscription is NOT
22754        // coverage completion, so the container survives the same call.
22755        recapture_previous_version(&client);
22756        client
22757            .subs
22758            .push(subscription(SubState::Active, 0, Some("resume")));
22759        client.previous_version_lifetime_check();
22760        assert!(exists());
22761        client.subs.clear();
22762
22763        drop(client);
22764        let _ = std::fs::remove_file(&path);
22765        let _ = std::fs::remove_file(&container);
22766        let _ = std::fs::remove_file(format!("{container}-wal"));
22767        let _ = std::fs::remove_file(format!("{container}-shm"));
22768    }
22769}
22770
22771#[cfg(test)]
22772mod scope_move_delivery_tests {
22773    use super::*;
22774    use crate::native_transport::HostTransport;
22775    use crate::values::encode_row_json;
22776    use serde_json::json;
22777
22778    fn client() -> SyncClient {
22779        SyncClient::new(
22780            "scope-move".to_owned(),
22781            &json!({
22782                "version": 1,
22783                "tables": [{
22784                    "name": "tasks",
22785                    "primaryKey": "id",
22786                    "columns": [
22787                        { "name": "id", "type": "string", "nullable": false },
22788                        { "name": "project_id", "type": "string", "nullable": false }
22789                    ],
22790                    "scopes": [{ "pattern": "project:{project_id}" }]
22791                }]
22792            }),
22793            ClientLimits::default(),
22794        )
22795        .expect("test client")
22796    }
22797
22798    fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22799        ssp2::model::Change {
22800            table_index: 0,
22801            row_id: "t1".to_owned(),
22802            op: Op::Upsert,
22803            row_version: Some(1),
22804            scopes: vec![("project_id".to_owned(), project.to_owned())],
22805            row: Some(
22806                encode_row_json(
22807                    client.schema.table("tasks").unwrap(),
22808                    "t1",
22809                    &Map::from_iter([
22810                        ("id".to_owned(), Value::from("t1")),
22811                        ("project_id".to_owned(), Value::from(project)),
22812                    ]),
22813                    &client.encryption,
22814                )
22815                .unwrap(),
22816            ),
22817        }
22818    }
22819
22820    fn delete(project: &str) -> ssp2::model::Change {
22821        ssp2::model::Change {
22822            table_index: 0,
22823            row_id: "t1".to_owned(),
22824            op: Op::Delete,
22825            row_version: None,
22826            scopes: vec![("project_id".to_owned(), project.to_owned())],
22827            row: None,
22828        }
22829    }
22830
22831    #[test]
22832    fn a_scope_move_keeps_the_row_in_either_section_order() {
22833        for order in [["old", "new"], ["new", "old"]] {
22834            let mut client = client();
22835            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22836            client
22837                .subscribe(
22838                    "old".into(),
22839                    "tasks".into(),
22840                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22841                    None,
22842                )
22843                .unwrap();
22844            client
22845                .subscribe(
22846                    "new".into(),
22847                    "tasks".into(),
22848                    vec![("project_id".to_owned(), vec!["p2".to_owned()])],
22849                    None,
22850                )
22851                .unwrap();
22852            let seed = upsert(&client, "p1");
22853            client
22854                .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
22855                .unwrap();
22856            assert_eq!(
22857                client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
22858                json!("p1")
22859            );
22860
22861            let mut frames = vec![Frame::RespHeader {
22862                required_schema_version: None,
22863                latest_schema_version: None,
22864                log_epoch: Some("epoch-1".to_owned()),
22865                reset_required: Some(false),
22866            }];
22867            for id in order {
22868                let project = if id == "old" { "p1" } else { "p2" };
22869                frames.push(Frame::SubStart {
22870                    id: id.to_owned(),
22871                    status: SubStatus::Active,
22872                    reason_code: String::new(),
22873                    effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
22874                    bootstrap: false,
22875                });
22876                frames.push(Frame::Commit {
22877                    commit_seq: 2,
22878                    created_at_ms: 0,
22879                    actor_id: "actor".to_owned(),
22880                    tables: vec!["tasks".to_owned()],
22881                    changes: vec![if id == "old" {
22882                        delete("p1")
22883                    } else {
22884                        upsert(&client, "p2")
22885                    }],
22886                });
22887                frames.push(Frame::SubEnd {
22888                    next_cursor: 2,
22889                    bootstrap_state: None,
22890                });
22891            }
22892            let (_, meta) = client.build_request(false).unwrap();
22893            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22894            let outcome = client.process_response(
22895                &mut transport,
22896                Message {
22897                    wire_version: WIRE_VERSION,
22898                    msg_kind: MsgKind::Response,
22899                    frames,
22900                },
22901                &meta,
22902            );
22903            assert!(
22904                matches!(outcome, SyncOutcome::Ok(_)),
22905                "{order:?}: {outcome:?}"
22906            );
22907            let rows = client
22908                .query("SELECT id, project_id FROM tasks", &[])
22909                .unwrap();
22910            assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
22911            assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
22912        }
22913    }
22914
22915    struct RefusalTransport;
22916
22917    impl Transport for RefusalTransport {
22918        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
22919            let mut error = TransportError::new(
22920                "sync.invalid_client_id",
22921                "clientId is bound to a different actor in this partition",
22922            );
22923            error.details = Some(json!({
22924                "causeKind": "status",
22925                "httpStatus": 400,
22926                "category": "invalid-request",
22927                "retryable": false,
22928                "recommendedAction": "resetClientId"
22929            }));
22930            Err(error)
22931        }
22932
22933        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
22934            self.sync(request)
22935        }
22936
22937        fn download_segment(
22938            &mut self,
22939            _request: &SegmentRequest,
22940            _on_progress: &mut dyn FnMut(u64),
22941        ) -> Result<Vec<u8>, TransportError> {
22942            Err(TransportError::new("sync.transport_failed", "offline"))
22943        }
22944
22945        fn realtime_connect(&mut self) -> Result<(), TransportError> {
22946            Ok(())
22947        }
22948
22949        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
22950            Ok(())
22951        }
22952
22953        fn realtime_close(&mut self) -> Result<(), TransportError> {
22954            Ok(())
22955        }
22956    }
22957
22958    #[test]
22959    fn an_http_refusal_carries_its_code_and_recommended_action() {
22960        let mut client = client();
22961        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22962        let outcome = client.sync(&mut RefusalTransport);
22963        let SyncOutcome::Failed {
22964            error_code,
22965            recommended_action,
22966            details,
22967            ..
22968        } = outcome
22969        else {
22970            panic!("expected a failed round");
22971        };
22972        assert_eq!(error_code, "sync.invalid_client_id");
22973        assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
22974        assert_eq!(
22975            details
22976                .as_ref()
22977                .and_then(|details| details.get("httpStatus")),
22978            Some(&json!(400))
22979        );
22980    }
22981
22982    #[test]
22983    fn reset_client_id_persists_and_changes_the_request_identity() {
22984        let mut client = client();
22985        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22986        let previous = client.client_id().to_owned();
22987        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22988        let next = client.reset_client_id(&mut transport);
22989        assert_ne!(next, previous);
22990        assert_eq!(client.client_id(), next);
22991        assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
22992
22993        client
22994            .subscribe(
22995                "tasks".into(),
22996                "tasks".into(),
22997                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22998                None,
22999            )
23000            .unwrap();
23001        let (message, _) = client.build_request(false).unwrap();
23002        assert!(matches!(
23003            message.frames.first(),
23004            Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23005        ));
23006    }
23007}